Merged features tied to 'Chipotle' funding goal

This commit is contained in:
squidfunk
2025-01-31 10:31:48 +07:00
parent 07a434b465
commit fefdd42c96
67 changed files with 6055 additions and 386 deletions

View File

@@ -26,6 +26,7 @@ import posixpath
import yaml
from babel.dates import format_date, format_datetime
from copy import copy
from datetime import datetime, timezone
from jinja2 import pass_context
from jinja2.runtime import Context
@@ -34,19 +35,20 @@ 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.nav import Link, Navigation, Section
from mkdocs.structure.pages import Page
from mkdocs.structure.toc import AnchorLink, TableOfContents
from mkdocs.utils import copy_file, get_relative_url
from mkdocs.utils.templates import url_filter
from paginate import Page as Pagination
from shutil import rmtree
from tempfile import mkdtemp
from urllib.parse import urlparse
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 .structure import Archive, Category, Excerpt, Post, Reference, View
# -----------------------------------------------------------------------------
# Classes
@@ -299,10 +301,18 @@ class BlogPlugin(BasePlugin[BlogConfig]):
if not self.config.enabled:
return
# Transform links to point to posts and pages
for post in self.blog.posts:
self._generate_links(post, config, files)
# Filter for formatting dates related to posts
def date_filter(date: datetime):
return self._format_date_for_post(date, config)
# Fetch URL template filter from environment - the filter might
# be overridden by other plugins, so we must retrieve and wrap it
url_filter = env.filters["url"]
# Patch URL template filter to add support for paginated views, i.e.,
# that paginated views never link to themselves but to the main view
@pass_context
@@ -524,14 +534,15 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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:
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Create file in temporary directory and temporarily remove
# from navigation, as we'll add it at a specific location
# Create file in temporary directory
self._save_to_file(file.abs_src_path, f"# {name}")
file.inclusion = InclusionLevel.EXCLUDED
# Temporarily remove view from navigation
file.inclusion = InclusionLevel.EXCLUDED
# Create and yield view
if not isinstance(file.page, Archive):
@@ -560,14 +571,15 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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:
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Create file in temporary directory and temporarily remove
# from navigation, as we'll add it at a specific location
# Create file in temporary directory
self._save_to_file(file.abs_src_path, f"# {name}")
file.inclusion = InclusionLevel.EXCLUDED
# Temporarily remove view from navigation
file.inclusion = InclusionLevel.EXCLUDED
# Create and yield view
if not isinstance(file.page, Category):
@@ -591,14 +603,15 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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:
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Copy file to temporary directory and temporarily remove
# from navigation, as we'll add it at a specific location
# Copy file to temporary directory
copy_file(view.file.abs_src_path, file.abs_src_path)
file.inclusion = InclusionLevel.EXCLUDED
# Temporarily remove view from navigation
file.inclusion = InclusionLevel.EXCLUDED
# Create and yield view
if not isinstance(file.page, View):
@@ -609,6 +622,79 @@ class BlogPlugin(BasePlugin[BlogConfig]):
file.page.pages = view.pages
file.page.posts = view.posts
# Generate links from the given post to other posts, pages, and sections -
# this can only be done once all posts and pages have been parsed
def _generate_links(self, post: Post, config: MkDocsConfig, files: Files):
if not post.config.links:
return
# Resolve path relative to docs directory for error reporting
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(post.file.abs_src_path, docs)
# Find all links to pages and replace them with references - while all
# internal links are processed, external links remain as they are
for link in _find_links(post.config.links.items):
url = urlparse(link.url)
if url.scheme:
continue
# Resolve file for link, and throw if the file could not be found -
# authors can link to other pages, as well as to assets or files of
# any kind, but it is essential that the file that is linked to is
# found, so errors are actually catched and reported
file = files.get_file_from_path(url.path)
if not file:
log.warning(
f"Error reading metadata of post '{path}' in '{docs}':\n"
f"Couldn't find file for link '{url.path}'"
)
continue
# If the file linked to is not a page, but an asset or any other
# file, we resolve the destination URL and continue
if not isinstance(file.page, Page):
link.url = file.url
continue
# Cast link to reference
link.__class__ = Reference
assert isinstance(link, Reference)
# Assign page title, URL and metadata to link
link.title = link.title or file.page.title
link.url = file.page.url
link.meta = copy(file.page.meta)
# If the link has no fragment, we can continue - if it does, we
# need to find the matching anchor in the table of contents
if not url.fragment:
continue
# If we're running under dirty reload, MkDocs will reset all pages,
# so it's not possible to resolve anchor links. Thus, the only way
# to make this work is to skip the entire process of anchor link
# resolution in case of a dirty reload.
if self.is_dirty:
continue
# Resolve anchor for fragment, and throw if the anchor could not be
# found - authors can link to any anchor in the table of contents
anchor = _find_anchor(file.page.toc, url.fragment)
if not anchor:
log.warning(
f"Error reading metadata of post '{path}' in '{docs}':\n"
f"Couldn't find anchor '{url.fragment}' in '{url.path}'"
)
# Restore link to original state
link.url = url.geturl()
continue
# Append anchor to URL and set subtitle
link.url += f"#{anchor.id}"
link.meta["subtitle"] = anchor.title
# -------------------------------------------------------------------------
# Attach a list of pages to each other and to the given parent item without
@@ -864,6 +950,35 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Translate placeholder
return template.module.t(key)
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Find all links in the given list of items
def _find_links(items: list[StructureItem]):
for item in items:
# Resolve link
if isinstance(item, Link):
yield item
# Resolve sections recursively
if isinstance(item, Section):
for item in _find_links(item.children):
assert isinstance(item, Link)
yield item
# Find anchor in table of contents for the given id
def _find_anchor(toc: TableOfContents, id: str):
for anchor in toc:
if anchor.id == id:
return anchor
# Resolve anchors recursively
anchor = _find_anchor(anchor.children, id)
if isinstance(anchor, AnchorLink):
return anchor
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------

View File

@@ -27,10 +27,11 @@ import yaml
from copy import copy
from markdown import Markdown
from material.plugins.blog.author import Author
from material.plugins.meta.plugin import MetaPlugin
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.structure.files import File, Files
from mkdocs.structure.nav import Section
from mkdocs.structure.nav import Link, Section
from mkdocs.structure.pages import Page, _RelativePathTreeprocessor
from mkdocs.structure.toc import get_toc
from mkdocs.utils.meta import YAML_RE
@@ -87,6 +88,19 @@ class Post(Page):
f"{e}"
)
# Hack: if the meta plugin is registered, we need to move the call
# to `on_page_markdown` here, because we need to merge the metadata
# of the post with the metadata of any meta files prior to creating
# the post configuration. To our current knowledge, it's the only
# way to allow posts to receive metadata from meta files, because
# posts must be loaded prior to constructing the navigation in
# `on_files` but the meta plugin first runs in `on_page_markdown`.
plugin: MetaPlugin = config.plugins.get("material/meta")
if plugin:
plugin.on_page_markdown(
self.markdown, page = self, config = config, files = None
)
# Initialize post configuration, but remove all keys that this plugin
# doesn't care about, or they will be reported as invalid configuration
self.config: PostConfig = PostConfig(file.abs_src_path)
@@ -257,6 +271,17 @@ class Archive(View):
class Category(View):
pass
# -----------------------------------------------------------------------------
# Reference
class Reference(Link):
# Initialize reference - this is essentially a crossover of pages and links,
# as it inherits the metadata of the page and allows for anchors
def __init__(self, title: str, url: str):
super().__init__(title, url)
self.meta = {}
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------

View File

@@ -19,9 +19,9 @@
# IN THE SOFTWARE.
from mkdocs.config.base import Config
from mkdocs.config.config_options import ListOfItems, Optional, Type
from mkdocs.config.config_options import Optional, Type
from .options import PostDate
from .options import PostDate, PostLinks, UniqueListOfItems
# -----------------------------------------------------------------------------
# Classes
@@ -29,9 +29,10 @@ from .options import PostDate
# Post configuration
class PostConfig(Config):
authors = ListOfItems(Type(str), default = [])
categories = ListOfItems(Type(str), default = [])
authors = UniqueListOfItems(Type(str), default = [])
categories = UniqueListOfItems(Type(str), default = [])
date = PostDate()
draft = Optional(Type(bool))
links = Optional(PostLinks())
readtime = Optional(Type(int))
slug = Optional(Type(str))

View File

@@ -18,6 +18,8 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from markdown.treeprocessors import Treeprocessor
from mkdocs.structure.pages import Page
from mkdocs.utils import get_relative_url
@@ -31,12 +33,13 @@ from xml.etree.ElementTree import Element
class ExcerptTreeprocessor(Treeprocessor):
# Initialize excerpt tree processor
def __init__(self, page: Page, base: Page = None):
def __init__(self, page: Page, base: Page | None = None):
self.page = page
self.base = base
# Transform HTML after Markdown processing
def run(self, root: Element):
assert self.base
main = True
# We're only interested in anchors, which is why we continue when the

View File

@@ -20,6 +20,11 @@
from datetime import date, datetime, time, timezone
from mkdocs.config.base import BaseConfigOption, Config, ValidationError
from mkdocs.config.config_options import ListOfItems, T
from mkdocs.structure.files import Files
from mkdocs.structure.nav import (
Navigation, _add_parent_links, _data_to_navigation
)
from typing import Dict
# -----------------------------------------------------------------------------
@@ -97,3 +102,27 @@ class PostDate(BaseConfigOption[DateDict]):
# Return date dictionary
return value
# -----------------------------------------------------------------------------
# Post links option
class PostLinks(BaseConfigOption[Navigation]):
# Create navigation from structured items - we don't need to provide a
# configuration object to the function, because it will not be used
def run_validation(self, value: object):
items = _data_to_navigation(value, Files([]), None)
_add_parent_links(items)
# Return navigation
return Navigation(items, [])
# -----------------------------------------------------------------------------
# Unique list of items
class UniqueListOfItems(ListOfItems[T]):
# Ensure that each item is unique
def run_validation(self, value: object):
data = super().run_validation(value)
return list(dict.fromkeys(data))

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,33 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from mkdocs.config.config_options import Type
from mkdocs.config.base import Config
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Meta plugin configuration
class MetaConfig(Config):
enabled = Type(bool, default = True)
# Settings for meta files
meta_file = Type(str, default = ".meta.yml")

View File

@@ -0,0 +1,122 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import logging
import os
import posixpath
from mergedeep import Strategy, merge
from mkdocs.exceptions import PluginError
from mkdocs.structure.files import InclusionLevel
from mkdocs.plugins import BasePlugin, event_priority
from yaml import SafeLoader, load
from .config import MetaConfig
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Meta plugin
class MetaPlugin(BasePlugin[MetaConfig]):
# Construct metadata mapping
def on_files(self, files, *, config):
if not self.config.enabled:
return
# Initialize mapping
self.meta = {}
# Resolve and load meta files in docs directory
docs = os.path.relpath(config.docs_dir)
for file in files:
name = posixpath.basename(file.src_uri)
if not name == self.config.meta_file:
continue
# Exclude meta file from site directory - explicitly excluding the
# meta file allows the author to use a file name without '.' prefix
file.inclusion = InclusionLevel.EXCLUDED
# Open file and parse as YAML
with open(file.abs_src_path, encoding = "utf-8-sig") as f:
path = file.src_path
try:
self.meta[path] = load(f, SafeLoader)
# The meta file could not be loaded because of a syntax error,
# which we display to the author with a nice error message
except Exception as e:
raise PluginError(
f"Error reading meta file '{path}' in '{docs}':\n"
f"{e}"
)
# Set metadata for page, if applicable (run earlier)
@event_priority(50)
def on_page_markdown(self, markdown, *, page, config, files):
if not self.config.enabled:
return
# Start with a clean state, as we first need to apply all meta files
# that are relevant to the current page, and then merge the page meta
# on top of that to ensure that the page meta always takes precedence
# over meta files - see https://t.ly/kvCRn
meta = {}
# Merge matching meta files in level-order
strategy = Strategy.TYPESAFE_ADDITIVE
for path, defaults in self.meta.items():
if not page.file.src_path.startswith(os.path.dirname(path)):
continue
# Skip if meta file was already merged - this happens in case of
# blog posts, as they need to be merged when posts are constructed,
# which is why we need to keep track of which meta files are applied
# to what pages using the `__extends` key.
page.meta.setdefault("__extends", [])
if path in page.meta["__extends"]:
continue
# Try to merge metadata
try:
merge(meta, defaults, strategy = strategy)
page.meta["__extends"].append(path)
# Merging the metadata with the given strategy resulted in an error,
# which we display to the author with a nice error message
except Exception as e:
docs = os.path.relpath(config.docs_dir)
raise PluginError(
f"Error merging meta file '{path}' in '{docs}':\n"
f"{e}"
)
# Ensure page metadata is merged last, so the author can override any
# defaults from the meta files, or even remove them entirely
page.meta = merge(meta, page.meta, strategy = strategy)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.meta")

View File

@@ -18,10 +18,27 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from .structure.mapping import Mapping
from .structure.tag import Tag
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Casefold a string for comparison when sorting
def casefold(tag: str):
return tag.casefold()
# Return tag name for sorting
def tag_name(tag: Tag, *args):
return tag.name
# Return casefolded tag name for sorting
def tag_name_casefold(tag: Tag, *args):
return tag.name.casefold()
# -----------------------------------------------------------------------------
# Return item title for sorting
def item_title(mapping: Mapping):
return mapping.item.title
# Return item URL for sorting
def item_url(mapping: Mapping):
return mapping.item.url

View File

@@ -18,9 +18,17 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from mkdocs.config.config_options import Optional, Type
from collections.abc import Callable
from material.utilities.filter import FilterConfig
from mkdocs.config.config_options import (
DictOfItems, Deprecated, ListOfItems, SubConfig, Type
)
from mkdocs.config.base import Config
from pymdownx.slugs import slugify
from . import item_title, tag_name
from .structure.listing import ListingConfig
from .structure.tag.options import TagSet
# -----------------------------------------------------------------------------
# Classes
@@ -30,6 +38,36 @@ from mkdocs.config.base import Config
class TagsConfig(Config):
enabled = Type(bool, default = True)
# Settings for filtering
filters = SubConfig(FilterConfig)
# Settings for tags
tags = Type(bool, default = True)
tags_file = Optional(Type(str))
tags_slugify = Type(Callable, default = slugify(case = "lower"))
tags_slugify_separator = Type(str, default = "-")
tags_slugify_format = Type(str, default = "tag:{slug}")
tags_sort_by = Type(Callable, default = tag_name)
tags_sort_reverse = Type(bool, default = False)
tags_name_property = Type(str, default = "tags")
tags_name_variable = Type(str, default = "tags")
tags_allowed = TagSet()
# Settings for listings
listings = Type(bool, default = True)
listings_map = DictOfItems(SubConfig(ListingConfig), default = {})
listings_sort_by = Type(Callable, default = item_title)
listings_sort_reverse = Type(bool, default = False)
listings_tags_sort_by = Type(Callable, default = tag_name)
listings_tags_sort_reverse = Type(bool, default = False)
listings_directive = Type(str, default = "material/tags")
listings_layout = Type(str, default = "default")
# Deprecated settings
tags_compare = Deprecated(moved_to = "tags_sort_by")
tags_compare_reverse = Deprecated(moved_to = "tags_sort_reverse")
tags_pages_compare = Deprecated(moved_to = "listings_sort_by")
tags_pages_compare_reverse = Deprecated(moved_to = "listings_sort_reverse")
tags_file = Deprecated(option_type = Type(str))
tags_extra_files = Deprecated(
option_type = DictOfItems(ListOfItems(Type(str)), default = {})
)

View File

@@ -18,174 +18,291 @@
# 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 sys
import os
import re
from collections import defaultdict
from markdown.extensions.toc import slugify
from mkdocs import utils
from mkdocs.plugins import BasePlugin
from jinja2 import Environment
from material.utilities.filter import FileFilter
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure.pages import Page
from mkdocs.utils.templates import TemplateContext
# 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
from .renderer import Renderer
from .structure.listing.manager import ListingManager
from .structure.mapping.manager import MappingManager
from .structure.mapping.storage import MappingStorage
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Tags plugin
class TagsPlugin(BasePlugin[TagsConfig]):
"""
A tags plugin.
This plugin collects tags from the front matter of pages, and builds a tag
structure from them. The tag structure can be used to render listings on
pages, or to just create a site-wide tags index and export all tags and
mappings to a JSON file for consumption in another project.
"""
supports_multiple_instances = True
"""
This plugin supports multiple instances.
"""
# Initialize plugin
def on_config(self, config):
if not self.config.enabled:
return
def __init__(self, *args, **kwargs):
"""
Initialize the plugin.
"""
super().__init__(*args, **kwargs)
# Skip if tags should not be built
if not self.config.tags:
return
# Initialize incremental builds
self.is_serve = False
# 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
tags = page.meta.get("tags", [])
if tags:
for tag in tags:
self.tags[str(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
context["tags"] =[]
if "tags" in page.meta and page.meta["tags"]:
context["tags"] = [
self._render_tag(tag)
for tag in page.meta["tags"]
]
# Initialize mapping and listing managers
self.mappings = None
self.listings = None
# -------------------------------------------------------------------------
# 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)
mappings: MappingManager
"""
Mapping manager.
"""
# Add tags file to files - note: since MkDoc 1.6, not removing the
# file before adding it to the end will trigger a deprecation warning
# The new tags plugin does not require this hack, so we're just going
# to live with it until the new tags plugin is released.
files.remove(file)
files.append(file)
return file
listings: ListingManager
"""
Listing manager.
"""
# Render tags index
def _render_tag_index(self, markdown):
filter: FileFilter
"""
File filter.
"""
# -------------------------------------------------------------------------
def on_startup(self, *, command, **kwargs) -> None:
"""
Determine whether we're serving the site.
Arguments:
command: The command that is being executed.
dirty: Whether dirty builds are enabled.
"""
self.is_serve = command == "serve"
def on_config(self, config: MkDocsConfig) -> None:
"""
Create mapping and listing managers.
"""
# Retrieve toc depth, so we know the maximum level at which we can add
# items to the table of contents - Python Markdown allows to set the
# toc depth as a range, e.g. `2-6`, so we need to account for that as
# well. We need this information for generating listings.
depth = config.mdx_configs.get("toc", {}).get("toc_depth", 6)
if not isinstance(depth, int) and "-" in depth:
_, depth = depth.split("-")
# Initialize mapping and listing managers
self.mappings = MappingManager(self.config)
self.listings = ListingManager(self.config, int(depth))
# Initialize file filter - the file filter is used to include or exclude
# entire subsections of the documentation, allowing for using multiple
# instances of the plugin alongside each other. This can be necessary
# when creating multiple, potentially conflicting listings.
self.filter = FileFilter(self.config.filters)
# Ensure presence of attribute lists extension
for extension in config.markdown_extensions:
if isinstance(extension, str) and extension.endswith("attr_list"):
break
else:
config.markdown_extensions.append("attr_list")
# If the author only wants to extract and export mappings, we allow to
# disable the rendering of all tags and listings with a single setting
if self.config.export_only:
self.config.tags = False
self.config.listings = False
@event_priority(-50)
def on_page_markdown(
self, markdown: str, *, page: Page, config: MkDocsConfig, **kwargs
) -> str:
"""
Collect tags and listings from page.
Priority: -50 (run later)
Arguments:
markdown: The page's Markdown.
page: The page.
config: The MkDocs configuration.
Returns:
The page's Markdown with injection points.
"""
if not self.config.enabled:
return
# Skip if page should not be considered
if not self.filter(page.file):
return
# Handle deprecation of `tags_file` setting
if self.config.tags_file:
markdown = self._handle_deprecated_tags_file(page, markdown)
# Handle deprecation of `tags_extra_files` setting
if self.config.tags_extra_files:
markdown = self._handle_deprecated_tags_extra_files(page, markdown)
# Collect tags from page
try:
self.mappings.add(page, markdown)
# Raise exception if tags could not be read
except Exception as e:
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(page.file.abs_src_path, docs)
raise PluginError(
f"Error reading tags of page '{path}' in '{docs}':\n"
f"{e}"
)
# Collect listings from page
return self.listings.add(page, markdown)
@event_priority(100)
def on_env(
self, env: Environment, *, config: MkDocsConfig, **kwargs
) -> None:
"""
Populate listings.
Priority: 100 (run earliest)
Arguments:
env: The Jinja environment.
config: The MkDocs configuration.
"""
if not self.config.enabled:
return
# Populate and render all listings
self.listings.populate_all(self.mappings, Renderer(env, config))
# Export mappings to file, if enabled
if self.config.export:
path = os.path.join(config.site_dir, self.config.export_file)
path = os.path.normpath(path)
# Serialize mappings and save to file
storage = MappingStorage(self.config)
storage.save(path, self.mappings)
def on_page_context(
self, context: TemplateContext, *, page: Page, **kwargs
) -> None:
"""
Add tag references to page context.
Arguments:
context: The template context.
page: The page.
"""
if not self.config.enabled:
return
# Skip if page should not be considered
if not self.filter(page.file):
return
# Skip if tags should not be built
if not self.config.tags:
return
# Retrieve tags references for page
mapping = self.mappings.get(page)
if mapping:
tags = self.config.tags_name_variable
if tags not in context:
context[tags] = list(self.listings & mapping)
# -------------------------------------------------------------------------
def _handle_deprecated_tags_file(
self, page: Page, markdown: str
) -> str:
"""
Handle deprecation of `tags_file` setting.
Arguments:
page: The page.
"""
directive = self.config.listings_directive
if page.file.src_uri != self.config.tags_file:
return markdown
# Try to find the legacy tags marker and replace with directive
if "[TAGS]" in markdown:
markdown = markdown.replace("[TAGS]", "<!-- material/tags -->")
if not "<!-- material/tags -->" in markdown:
markdown += "\n<!-- material/tags -->"
# Replace placeholder in Markdown with rendered tags index
return markdown.replace("<!-- material/tags -->", "\n".join([
self._render_tag_links(*args)
for args in sorted(self.tags.items())
]))
# Render the given tag and links to all pages with occurrences
def _render_tag_links(self, tag, pages):
classes = ["md-tag"]
if isinstance(self.tags_map, dict):
classes.append("md-tag-icon")
type = self.tags_map.get(tag)
if type:
classes.append(f"md-tag--{type}")
# Render section for tag and a link to each page
classes = " ".join(classes)
content = [f"## <span class=\"{classes}\">{tag}</span>", ""]
for page in pages:
url = utils.get_relative_url(
page.file.src_uri,
self.tags_file.src_uri
markdown = markdown.replace(
"[TAGS]", f"<!-- {directive} -->"
)
# Render link to page
title = page.meta.get("title", page.title)
content.append(f"- [{title}]({url})")
# Try to find the directive and add it if not present
pattern = r"<!--\s+{directive}".format(directive = directive)
if not re.search(pattern, markdown):
markdown += f"\n<!-- {directive} -->"
# Return rendered tag links
return "\n".join(content)
# Return markdown
return markdown
# 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)
def _handle_deprecated_tags_extra_files(
self, page: Page, markdown: str
) -> str:
"""
Handle deprecation of `tags_extra_files` setting.
Arguments:
page: The page.
"""
directive = self.config.listings_directive
if page.file.src_uri not in self.config.tags_extra_files:
return markdown
# Compute tags to render on page
tags = self.config.tags_extra_files[page.file.src_uri]
if tags:
directive += f" {{ include: [{', '.join(tags)}] }}"
# Try to find the legacy tags marker and replace with directive
if "[TAGS]" in markdown:
markdown = markdown.replace(
"[TAGS]", f"<!-- {directive} -->"
)
# Try to find the directive and add it if not present
pattern = r"<!--\s+{directive}".format(directive = re.escape(directive))
if not re.search(pattern, markdown):
markdown += f"\n<!-- {directive} -->"
# Return markdown
return markdown
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.tags")
log = logging.getLogger("mkdocs.material.plugins.tags")

View File

@@ -0,0 +1,99 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import posixpath
from jinja2 import Environment
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.structure.pages import Page
from mkdocs.utils import get_relative_url
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Renderer:
"""
A renderer for tags and listings.
This class implements a simple tag and listing renderer, leveraging the
Jinja environment and the MkDocs configuration as provided to plugins.
Note that the templates must be stored in the `fragments` and not `partials`
directory, because in order to render tags and listings, we must wait for
all pages to be read and processed, as we first need to collect all tags
before we can render listings. Tags induce a graph, not a tree.
For this reason, we consider the templates to be fragments, as they are
not implicitly rendered by MkDocs, but explicitly by the plugin.
"""
def __init__(self, env: Environment, config: MkDocsConfig):
"""
Initialize renderer.
Arguments:
env: The Jinja environment.
config: The MkDocs configuration.
"""
self.env = env
self.config = config
# -------------------------------------------------------------------------
env: Environment
"""
The Jinja environment.
"""
config: MkDocsConfig
"""
The MkDocs configuration.
"""
# -------------------------------------------------------------------------
def render(self, page: Page, name: str, **kwargs) -> str:
"""
Render a template.
Templates are resolved from `fragments/tags`, so if you want to override
templates or provide additional ones place them in this directory.
Arguments:
page: The page.
name: The name of the template.
kwargs: The template variables.
Returns:
The rendered template.
"""
path = posixpath.join("fragments", "tags", name)
path = posixpath.normpath(path)
# Resolve and render template
template = self.env.get_template(path)
return template.render(
config = self.config, page = page,
base_url = get_relative_url(".", page.url),
**kwargs
)

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,218 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import posixpath
from collections.abc import Iterator
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag import Tag
from mkdocs.structure.pages import Page
from .config import ListingConfig
from .tree import ListingTree
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Listing:
"""
A listing of tags.
Listings can be included on a page with the `<!-- material/tags [args] -->`
directive. The arguments are passed to a YAML parser, and are expected to
either be an inline listing configuration, or an identifier that points to
a listing configuration in the `listings_map` setting in `mkdocs.yml`.
Example 1: use inline listing configuration
``` markdown
<!-- material/tags { include: [foo, bar] } -->
```
Example 2: use listing configuration from `listings_map.qux`
``` markdown
<!-- material/tags qux -->
```
If no arguments are given, the default listing configuration is used,
rendering all tags and mappings. In case you're using multiple instances
of the tags plugin, you can use the `listings_directive` setting to change
the directive name.
"""
def __init__(self, page: Page, id: str, config: ListingConfig):
"""
Initialize the listing.
Arguments:
page: The page the listing is embedded in.
id: The listing identifier.
config: The listing configuration.
"""
self.page = page
self.id = id
self.config = config
self.tags = {}
def __repr__(self) -> str:
"""
Return a printable representation of the listing.
Returns:
Printable representation.
"""
return f"Listing({repr(self.page)})"
def __hash__(self) -> int:
"""
Return the hash of the listing.
Returns:
The hash.
"""
return hash(self.id)
def __iter__(self) -> Iterator[ListingTree]:
"""
Iterate over the listing in pre-order.
Yields:
The current listing tree.
"""
stack = list(reversed(self.tags.values()))
while stack:
tree = stack.pop()
yield tree
# Visit subtrees in reverse, so pre-order is preserved
stack += reversed([*tree])
def __and__(self, mapping: Mapping) -> Iterator[Tag]:
"""
Iterate over the tags of a mapping featured in the listing.
When hierarchical tags are used, the set of tags is expanded to include
all parent tags, but only for the inclusion check. The returned tags are
always the actual tags (= leaves) of the mapping. This is done to avoid
duplicate entries in the listing.
If a mapping features one of the tags excluded from the listing, the
entire mapping is excluded from the listing. Additionally, if a listing
should only include tags within the current scope, the mapping is only
included if the page is a child of the page the listing was found on.
Arguments:
mapping: The mapping.
Yields:
The current tag.
"""
assert isinstance(mapping, Mapping)
# If the mapping is on the same page as the listing, we skip it, as
# it makes no sense to link to a listing on the same page
if mapping.item == self.page:
return iter([])
# If the listing should only include tags within the current scope, we
# check if the page is a child of the page the listing is embedded in
if self.config.scope:
assert isinstance(mapping.item, Page)
# Note that we must use `File.src_uri` here, or we won't be able to
# detect the difference between pages, and index pages in sections,
# as `foo/index.md` and `foo.md` look the same when using `Page.url`
base = posixpath.dirname(posixpath.normpath(self.page.file.src_uri))
if not mapping.item.file.src_uri.startswith(posixpath.join(base, "")):
return iter([])
# If an exclusion list is given, expand each tag to check if the tag
# itself or one of its parents is excluded from the listing
if self.config.exclude:
if any(mapping & self.config.exclude):
return iter([])
# If an inclusion list is given, expand each tag to check if the tag
# itself or one of its parents is included in the listing
if self.config.include:
return mapping & self.config.include
# Otherwise, we can just return an iterator over the set of tags of the
# mapping as is, as no expansion is required
return iter(mapping.tags)
# -------------------------------------------------------------------------
page: Page
"""
The page the listing is embedded in.
"""
id: str
"""
The listing identifier.
As authors may place an arbitrary number of listings on any page, we need
to be able to distinguish between them. This is done automatically.
"""
config: ListingConfig
"""
The listing configuration.
"""
tags: dict[Tag, ListingTree]
"""
The listing trees, each of which associated with a tag.
"""
# -------------------------------------------------------------------------
def add(self, mapping: Mapping, *, hidden = True) -> None:
"""
Add mapping to listing.
Arguments:
mapping: The mapping.
hidden: Whether to add hidden tags.
"""
for leaf in self & mapping:
tree = self.tags
# Skip if hidden tags should not be rendered
if not hidden and leaf.hidden:
continue
# Iterate over expanded tags
for tag in reversed([*leaf]):
if tag not in tree:
tree[tag] = ListingTree(tag)
# If the tag is the leaf, i.e., the actual tag we want to add,
# we add the mapping to the listing tree's mappings
if tag == leaf:
tree[tag].mappings.append(mapping)
# Otherwise, we continue walking down the tree
else:
tree = tree[tag].children

View File

@@ -0,0 +1,99 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import yaml
from material.plugins.tags.structure.tag.options import TagSet
from mkdocs.config.base import Config
from mkdocs.config.config_options import Optional, Type
from yaml import Dumper
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class ListingConfig(Config):
"""
A listing configuration.
"""
scope = Type(bool, default = False)
"""
Whether to only include pages in the current subsection.
Enabling this setting will only include pages that are on the same level or
on a lower level than the page the listing is on. This allows to create a
listing of tags on a page that only includes pages that are in the same
subsection of the documentation.
"""
layout = Optional(Type(str))
"""
The layout to use for rendering the listing.
This setting allows to override the global setting for the layout. If this
setting is not specified, the global `listings_layout` setting is used.
"""
include = TagSet()
"""
Tags to include in the listing.
If this set is empty, the listing does not filter pages by tags. Otherwise,
all pages that have at least one of the tags in this set will be included.
"""
exclude = TagSet()
"""
Tags to exclude from the listing.
If this set is empty, the listing does not filter pages by tags. Otherwise,
all pages that have at least one of the tags in this set will be excluded.
"""
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def _representer(dumper: Dumper, config: ListingConfig):
"""
Return a serializable representation of a listing configuration.
Arguments:
dumper: The YAML dumper.
config: The listing configuration.
Returns:
Serializable representation.
"""
copy = config.copy()
# Convert the include and exclude tag sets to lists of strings
copy.include = list(map(str, copy.include)) if copy.include else None
copy.exclude = list(map(str, copy.exclude)) if copy.exclude else None
# Return serializable listing configuration
data = { k: v for k, v in copy.items() if v is not None }
return dumper.represent_dict(data)
# -----------------------------------------------------------------------------
# Register listing configuration YAML representer
yaml.add_representer(ListingConfig, _representer)

View File

@@ -0,0 +1,497 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
import os
import posixpath
import re
import yaml
from collections.abc import Iterable, Iterator
from material.plugins.tags.config import TagsConfig
from material.plugins.tags.renderer import Renderer
from material.plugins.tags.structure.listing import Listing, ListingConfig
from material.plugins.tags.structure.listing.tree import ListingTree
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag import Tag
from material.plugins.tags.structure.tag.reference import TagReference
from mkdocs.exceptions import PluginError
from mkdocs.structure.pages import Page
from mkdocs.structure.nav import Link
from re import Match
from urllib.parse import urlparse
from . import toc
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class ListingManager:
"""
A listing manager.
The listing manager collects all listings from the Markdown of pages, then
populates them with mappings, and renders them. Furthermore, the listing
manager allows to obtain tag references for a given mapping, which are
tags annotated with links to listings.
"""
def __init__(self, config: TagsConfig, depth: int = 6):
"""
Initialize the listing manager.
Arguments:
config: The configuration.
"""
self.config = config
self.data = set()
self.depth = depth
def __repr__(self) -> str:
"""
Return a printable representation of the listing manager.
Returns:
Printable representation.
"""
return _print(self)
def __iter__(self) -> Iterator[Listing]:
"""
Iterate over listings.
Yields:
The current listing.
"""
return iter(self.data)
def __and__(self, mapping: Mapping) -> Iterator[TagReference]:
"""
Iterate over the tag references for the mapping.
Arguments:
mapping: The mapping.
Yields:
The current tag reference.
"""
assert isinstance(mapping, Mapping)
# Iterate over sorted tags and associate tags with listings - note that
# we sort the listings for the mapping by closeness, so that the first
# listing in the list is the closest one to the page or link the
# mapping is associated with
listings = self.closest(mapping)
for tag in self._sort_tags(mapping.tags):
ref = TagReference(tag)
# Iterate over listings and add links
for listing in listings:
if tag in listing & mapping:
value = listing.page.url or "."
# Compute URL for link - make sure to remove fragments, as
# they may be present in links extracted from remote tags.
# Additionally, we need to fallback to `.` if the URL is
# empty (= homepage) or the links will be incorrect.
url = urlparse(value, allow_fragments = False)
url = url._replace(fragment = self._slugify(tag))
# Add listing link to tag reference
ref.links.append(
Link(listing.page.title, url.geturl())
)
# Yield tag reference
yield ref
# -------------------------------------------------------------------------
config: TagsConfig
"""
The configuration.
"""
data: set[Listing]
"""
The listings.
"""
depth: int
"""
Table of contents maximum depth.
"""
# -------------------------------------------------------------------------
def add(self, page: Page, markdown: str) -> str:
"""
Add page.
This method is called by the tags plugin to retrieve all listings of a
page. It will parse the page's Markdown and add injections points into
the page's Markdown, which will be replaced by the renderer with the
actual listing later on.
Note that this method is intended to be called with the page during the
`on_page_markdown` event, as it modifies the page's Markdown. Moreover,
the Markdown must be explicitly passed, as we could otherwise run into
inconsistencies when other plugins modify the Markdown.
Arguments:
page: The page.
markdown: The page's Markdown.
Returns:
The page's Markdown with injection points.
"""
assert isinstance(markdown, str)
# Replace callback
def replace(match: Match) -> str:
config = self._resolve(page, match.group(2))
# Compute listing identifier - as the author might include multiple
# listings on a single page, we must make sure that the identifier
# is unique, so we use the page source file path and the position
# of the match within the page as an identifier.
id = f"{page.file.src_uri}:{match.start()}-{match.end()}"
self.data.add(Listing(page, id, config))
# Replace directive with hx headline if listings are enabled, or
# remove the listing entirely from the page and table of contents
if self.config.listings:
return "#" * self.depth + f" {id}/name {{ #{id}/slug }}"
else:
return
# Hack: replace directive with an hx headline to mark the injection
# point for the anchor links we will generate after parsing all pages.
# By using an hx headline, we can make sure that the injection point
# will always be a child of the preceding headline.
directive = self.config.listings_directive
return re.sub(
r"(<!--\s*?{directive}(.*?)\s*-->)".format(directive = directive),
replace, markdown, flags = re.I | re.M | re.S
)
def closest(self, mapping: Mapping) -> list[Listing]:
"""
Get listings for the mapping ordered by closeness.
Listings are sorted by closeness to the given page, i.e. the number of
common path components. This is useful for hierarchical listings, where
the tags of a page point to the closest listing featuring the tag, with
the option to show all listings featuring that tag.
Arguments:
mapping: The mapping.
Returns:
The listings.
"""
# Retrieve listings featuring tags of mapping
listings: list[Listing] = []
for listing in self.data:
if any(listing & mapping):
listings.append(listing)
# Ranking callback
def rank(listing: Listing) -> int:
path = posixpath.commonpath([mapping.item.url, listing.page.url])
return len(path)
# Return listings ordered by closeness to mapping
return sorted(listings, key = rank, reverse = True)
def populate(
self, listing: Listing, mappings: Iterable[Mapping], renderer: Renderer
) -> None:
"""
Populate listing with tags featured in the mappings.
Arguments:
listing: The listing.
mappings: The mappings.
renderer: The renderer.
"""
page = listing.page
assert isinstance(page.content, str)
# Add mappings to listing
for mapping in mappings:
listing.add(mapping)
# Sort listings and tags - we can only do this after all mappings have
# been added to the listing, because the tags inside the mappings do
# not have a proper order yet, and we need to order them as specified
# in the listing configuration.
listing.tags = self._sort_listing_tags(listing.tags)
# Render tags for listing headlines - the listing configuration allows
# tp specify a custom layout, so we resolve the template for tags here
name = posixpath.join(listing.config.layout, "tag.html")
for tree in listing:
tree.content = renderer.render(page, name, tag = tree.tag)
# Sort mappings and subtrees of listing tree
tree.mappings = self._sort_listing(tree.mappings)
tree.children = self._sort_listing_tags(tree.children)
# Replace callback
def replace(match: Match) -> str:
hx = match.group()
# Populate listing with anchor links to tags
anchors = toc.populate(listing, self._slugify)
if not anchors:
return
# Get reference to first tag in listing
head = next(iter(anchors.values()))
# Replace hx with actual level of listing and listing ids with
# placeholders to create a format string for the headline
hx = re.sub(
r"<(/?)h{}\b".format(self.depth),
r"<\g<1>h{}".format(head.level), hx
)
hx = re.sub(
r"{id}\/(\w+)".format(id = listing.id),
r"{\1}", hx, flags = re.I | re.M
)
# Render listing headlines
for tree in listing:
tree.content = hx.format(
slug = anchors[tree.tag].id,
name = tree.content
)
# Render listing - the listing configuration allows to specify a
# custom layout, so we resolve the template for listings here
name = posixpath.join(listing.config.layout, "listing.html")
return "\n".join([
renderer.render(page, name, listing = tree)
for tree in listing.tags.values()
])
# Hack: replace hx headlines (injection points) we added when parsing
# the page's Markdown with the actual listing content. Additionally,
# replace anchor links in the table of contents with the hierarchy
# generated from mapping over the listing, or remove them.
page.content = re.sub(
r"<h{x}[^>]+{id}.*?</h{x}>".format(
id = f"{listing.id}/slug", x = self.depth
),
replace, page.content, flags = re.I | re.M
)
def populate_all(
self, mappings: Iterable[Mapping], renderer: Renderer
) -> None:
"""
Populate all listings with tags featured in the mappings.
This method is called by the tags plugin to populate all listings with
the given mappings. It will also remove the injection points from the
page's Markdown. Note that this method is intended to be called during
the `on_env` event, after all pages have been rendered.
Arguments:
mappings: The mappings.
renderer: The renderer.
"""
for listing in self.data:
self.populate(listing, mappings, renderer)
# -------------------------------------------------------------------------
def _resolve(self, page: Page, args: str) -> ListingConfig:
"""
Resolve listing configuration.
Arguments:
page: The page the listing in embedded in.
args: The arguments, as parsed from Markdown.
Returns:
The listing configuration.
"""
data = yaml.safe_load(args)
path = page.file.abs_src_path
# Try to resolve available listing configuration
if isinstance(data, str):
config = self.config.listings_map.get(data, None)
if not config:
keys = ", ".join(self.config.listings_map.keys())
raise PluginError(
f"Couldn't find listing configuration: {data}. Available "
f"configurations: {keys}"
)
# Otherwise, handle inline listing configuration
else:
config = ListingConfig(config_file_path = path)
config.load_dict(data or {})
# Validate listing configuration
errors, warnings = config.validate()
for _, w in warnings:
path = os.path.relpath(path)
log.warning(
f"Error reading listing configuration in '{path}':\n"
f"{w}"
)
for _, e in errors:
path = os.path.relpath(path)
raise PluginError(
f"Error reading listing configuration in '{path}':\n"
f"{e}"
)
# Inherit layout configuration, unless explicitly set
if not isinstance(config.layout, str):
config.layout = self.config.listings_layout
# Return listing configuration
return config
# -------------------------------------------------------------------------
def _slugify(self, tag: Tag) -> str:
"""
Slugify tag.
Arguments:
tag: The tag.
Returns:
The slug.
"""
return self.config.tags_slugify_format.format(
slug = self.config.tags_slugify(
tag.name,
self.config.tags_slugify_separator
)
)
# -------------------------------------------------------------------------
def _sort_listing(
self, mappings: Iterable[Mapping]
) -> list[Mapping]:
"""
Sort listing.
When sorting a listing, we sort the mappings of the listing, which is
why the caller must pass the mappings of the listing. That way, we can
keep this implementation to be purely functional, without having to
mutate the listing, which makes testing simpler.
Arguments:
mappings: The mappings.
Returns:
The sorted mappings.
"""
return sorted(
mappings,
key = self.config.listings_sort_by,
reverse = self.config.listings_sort_reverse
)
def _sort_listing_tags(
self, children: dict[Tag, ListingTree]
) -> dict[Tag, ListingTree]:
"""
Sort listing tags.
When sorting a listing's tags, we sort the immediate subtrees of the
listing, which is why the caller must pass the children of the listing.
That way, we can keep this implementation to be purely functional,
without having to mutate the listing.
Arguments:
children: The listing trees, each of which associated with a tag.
Returns:
The sorted listing trees.
"""
return dict(sorted(
children.items(),
key = lambda item: self.config.listings_tags_sort_by(*item),
reverse = self.config.listings_tags_sort_reverse
))
def _sort_tags(
self, tags: Iterable[Tag]
) -> list[Tag]:
"""
Sort tags.
Arguments:
tags: The tags.
Returns:
The sorted tags.
"""
return sorted(
tags,
key = self.config.tags_sort_by,
reverse = self.config.tags_sort_reverse
)
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def _print(manager: ListingManager, indent: int = 0) -> str:
"""
Return a printable representation of a listing manager.
Arguments:
manager: The listing manager.
indent: The indentation level.
Returns:
Printable representation.
"""
lines: list[str] = []
lines.append(" " * indent + f"ListingManager()")
# Print listings
for listing in manager:
lines.append(" " * (indent + 2) + repr(listing))
# Concatenate everything
return "\n".join(lines)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.plugins.tags")

View File

@@ -0,0 +1,134 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from material.plugins.tags.structure.listing import Listing
from material.plugins.tags.structure.tag import Tag
from mkdocs.structure.pages import Page
from mkdocs.structure.toc import AnchorLink
from typing import Callable
# -----------------------------------------------------------------------------
# Typings
# -----------------------------------------------------------------------------
Slugify = Callable[[Tag], str]
"""
Slugify function.
Arguments:
tag: The tag.
Returns:
The slugified tag.
"""
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def populate(listing: Listing, slugify: Slugify) -> dict[Tag, AnchorLink]:
"""
Populate the table of contents of the page the listing is embedded.
Arguments:
listing: The listing.
slugify: Slugify function.
Returns:
The mapping of tags to anchor links.
"""
anchors: dict[Tag, AnchorLink] = {}
# Find injection point for listing
host, at = find(listing)
if at == -1:
return anchors
# Create anchor links
for tree in listing:
# Iterate over expanded tags
for i, tag in enumerate(reversed([*tree.tag])):
if tag not in anchors:
level = host.level + 1 + i
# Create anchor link
anchors[tag] = AnchorLink(tag.name, slugify(tag), level)
if not tag.parent:
continue
# Relate anchor link to parent
anchors[tag.parent].children.append(anchors[tag])
# Filter top-level anchor links and insert them into the page
children = [anchors[tag] for tag in anchors if not tag.parent]
if listing.config.toc:
host.children[at:at + 1] = children
else:
host.children.pop(at)
# Return mapping of tags to anchor links
return anchors
# -----------------------------------------------------------------------------
def find(listing: Listing) -> tuple[AnchorLink | None, int]:
"""
Find anchor link for the given listing.
This function traverses the table of contents of the given page and returns
the anchor's parent and index of the anchor with the given identifier. If
the anchor is on the root level, and the anchor we're looking for is an
injection point, an anchor to host the tags is created and returned.
Arguments:
lising: The listing.
Returns:
The anchor and index.
"""
page = listing.page
# Traverse table of contents
stack = list(page.toc)
while stack:
anchor = stack.pop()
# Traverse children
for i, child in enumerate(anchor.children):
if child.id.startswith(listing.id):
return anchor, i
# Add child to stack
stack.append(child)
# Check if anchor is on the root level
for i, anchor in enumerate(page.toc):
if anchor.id.startswith(listing.id):
# Create anchor link
host = AnchorLink(page.title, page.url, 1)
host.children = page.toc.items
return host, i
# Anchor could not be found
return None, -1

View File

@@ -0,0 +1,160 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from collections.abc import Iterator
from functools import total_ordering
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag import Tag
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
@total_ordering
class ListingTree:
"""
A listing tree.
This is an internal data structure that is used to render listings. It is
also the immediate structure that is passed to the template.
"""
def __init__(self, tag: Tag):
"""
Initialize the listing tree.
Arguments:
tag: The tag.
"""
self.tag = tag
self.content = None
self.mappings = []
self.children = {}
def __repr__(self) -> str:
"""
Return a printable representation of the listing tree.
Returns:
Printable representation.
"""
return _print(self)
def __hash__(self) -> int:
"""
Return the hash of the listing tree.
Returns:
The hash.
"""
return hash(self.tag)
def __iter__(self) -> Iterator[ListingTree]:
"""
Iterate over subtrees of the listing tree.
Yields:
The current subtree.
"""
return iter(self.children.values())
def __eq__(self, other: ListingTree) -> bool:
"""
Check if the listing tree is equal to another listing tree.
Arguments:
other: The other listing tree to check.
Returns:
Whether the listing trees are equal.
"""
assert isinstance(other, ListingTree)
return self.tag == other.tag
def __lt__(self, other: ListingTree) -> bool:
"""
Check if the listing tree is less than another listing tree.
Arguments:
other: The other listing tree to check.
Returns:
Whether the listing tree is less than the other listing tree.
"""
assert isinstance(other, ListingTree)
return self.tag < other.tag
# -------------------------------------------------------------------------
tag: Tag
"""
The tag.
"""
content: str | None
"""
The rendered content of the listing tree.
This attribute holds the result of rendering the `tag.html` template, which
is the rendered tag as displayed in the listing. It is essential that this
is done for all tags (and nested tags) before rendering the tree, as the
rendering process of the listing tree relies on this attribute.
"""
mappings: list[Mapping]
"""
The mappings associated with the tag.
"""
children: dict[Tag, ListingTree]
"""
The subtrees of the listing tree.
"""
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def _print(tree: ListingTree, indent: int = 0) -> str:
"""
Return a printable representation of a listing tree.
Arguments:
tree: The listing tree.
indent: The indentation level.
Returns:
Printable representation.
"""
lines: list[str] = []
lines.append(" " * indent + f"ListingTree({repr(tree.tag)})")
# Print mappings
for mapping in tree.mappings:
lines.append(" " * (indent + 2) + repr(mapping))
# Print subtrees
for child in tree.children.values():
lines.append(_print(child, indent + 2))
# Concatenate everything
return "\n".join(lines)

View File

@@ -0,0 +1,98 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from collections.abc import Iterable, Iterator
from material.plugins.tags.structure.tag import Tag
from mkdocs.structure.nav import Link
from mkdocs.structure.pages import Page
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Mapping:
"""
A mapping between a page or link and a set of tags.
We use this class to store the mapping between a page or link and a set of
tags. This is necessary as we don't want to store the tags directly on the
page or link object, in order not to clutter the internal data structures
of MkDocs, keeping the plugin as unobtrusive as possible.
Links are primarily used when integrating with tags from external projects,
as we can't construct a page object for them as we do for local files.
"""
def __init__(self, item: Page | Link, *, tags: Iterable[Tag] | None = None):
"""
Initialize the mapping.
Tags can be passed upon initialization, but can also be added later on
using the `add` or `update` method. of the `tags` attribute.
Arguments:
item: The page or link.
tags: The tags associated with the page or link.
"""
self.item = item
self.tags = set(tags or [])
def __repr__(self) -> str:
"""
Return a printable representation of the mapping.
Returns:
Printable representation.
"""
return f"Mapping({repr(self.item)}, tags={self.tags})"
def __and__(self, tags: set[Tag]) -> Iterator[Tag]:
"""
Iterate over the tags featured in the mapping.
This method expands each tag in the mapping and checks whether it is
equal to one of the tags in the given set. If so, the tag is yielded.
Arguments:
tags: The set of tags.
Yields:
The current tag.
"""
assert isinstance(tags, set)
# Iterate over expanded tags
for tag in self.tags:
if set(tag) & tags:
yield tag
# -------------------------------------------------------------------------
item: Page | Link
"""
The page or link.
"""
tags: set[Tag]
"""
The tags associated with the page or link.
"""

View File

@@ -0,0 +1,169 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from collections.abc import Iterator
from material.plugins.tags.config import TagsConfig
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag.options import TagSet
from mkdocs.structure.pages import Page
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class MappingManager:
"""
A mapping manager.
The mapping manager is responsible for collecting all tags from the front
matter of pages, and for building a tag structure from them, nothing more.
"""
def __init__(self, config: TagsConfig):
"""
Initialize the mapping manager.
Arguments:
config: The configuration.
"""
self.config = config
self.format = TagSet(allowed = self.config.tags_allowed)
self.data = {}
def __repr__(self) -> str:
"""
Return a printable representation of the mapping manager.
Returns:
Printable representation.
"""
return _print(self)
def __iter__(self) -> Iterator[Mapping]:
"""
Iterate over mappings.
Yields:
The current mapping.
"""
return iter(self.data.values())
# -------------------------------------------------------------------------
config: TagsConfig
"""
The configuration.
"""
format: TagSet
"""
The mapping format.
This is the validator that is used to check if tags are valid, including
the tags in the front matter of pages, as well as the tags defined in the
configuration. Numbers and booleans are always converted to strings before
creating tags, and the allow list is checked as well, if given.
"""
data: dict[str, Mapping]
"""
The mappings.
"""
# -------------------------------------------------------------------------
def add(self, page: Page, markdown: str) -> Mapping | None:
"""
Add page.
This method is called by the tags plugin to retrieve all tags of a page.
It extracts all tags from the front matter of the given page, and adds
them to the mapping. If no tags are found, no mapping is created and
nothing is returned.
Note that this method is intended to be called with the page during the
`on_page_markdown` event, as it reads the front matter of a page. Also,
the Markdown must be explicitly passed, as we could otherwise run into
inconsistencies when other plugins modify the Markdown.
Arguments:
page: The page.
markdown: The page's Markdown.
Returns:
The mapping or nothing.
"""
assert isinstance(markdown, str)
# Return nothing if page doesn't have tags
tags = self.config.tags_name_property
if not page.meta.get(tags, []):
return
# Create mapping and associate with page
mapping = Mapping(page)
self.data[page.url] = mapping
# Retrieve and validate tags, and add to mapping
for tag in self.format.validate(page.meta[tags]):
mapping.tags.add(tag)
# Return mapping
return mapping
def get(self, page: Page) -> Mapping | None:
"""
Get mapping for page, if any.
Arguments:
page: The page.
Returns:
The mapping or nothing.
"""
if page.url in self.data:
return self.data[page.url]
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def _print(manager: MappingManager, indent: int = 0) -> str:
"""
Return a printable representation of a mapping manager.
Arguments:
manager: The mapping manager.
indent: The indentation level.
Returns:
Printable representation.
"""
lines: list[str] = []
lines.append(" " * indent + f"MappingManager()")
# Print mappings
for mapping in manager:
lines.append(" " * (indent + 2) + repr(mapping))
# Concatenate everything
return "\n".join(lines)

View File

@@ -0,0 +1,211 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import json
import os
from collections.abc import Iterable
from material.plugins.tags.config import TagsConfig
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag import Tag
from mkdocs.config.base import ValidationError
from mkdocs.structure.nav import Link
from mkdocs.structure.pages import Page
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class MappingStorage:
"""
A mapping storage.
The mapping storage allows to save and load mappings to and from a JSON
file, which allows for sharing tags across multiple MkDocs projects.
"""
def __init__(self, config: TagsConfig):
"""
Initialize the mapping storage.
Arguments:
config: The configuration.
"""
self.config = config
# -------------------------------------------------------------------------
config: TagsConfig
"""
The configuration.
"""
# -------------------------------------------------------------------------
def save(self, path: str, mappings: Iterable[Mapping]) -> None:
"""
Save mappings to file.
Arguments:
path: The file path.
mappings: The mappings.
"""
path = os.path.abspath(path)
os.makedirs(os.path.dirname(path), exist_ok = True)
# Save serialized mappings to file
with open(path, "w", encoding = "utf-8") as f:
data = [_mapping_to_json(mapping) for mapping in mappings]
json.dump(dict(mappings = data), f)
def load(self, path: str) -> Iterable[Mapping]:
"""
Load mappings from file.
Arguments:
path: The file path.
Yields:
The current mapping.
"""
with open(path, "r", encoding = "utf-8") as f:
data = json.load(f)
# Ensure root dictionary
if not isinstance(data, dict):
raise ValidationError(
f"Expected dictionary, but received: {data}"
)
# Ensure mappings are iterable
mappings = data.get("mappings")
if not isinstance(mappings, list):
raise ValidationError(
f"Expected list, but received: {mappings}"
)
# Create and yield mappings
for mapping in mappings:
yield _mapping_from_json(mapping)
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def _mapping_to_json(mapping: Mapping) -> dict:
"""
Return a serializable representation of a mapping.
Arguments:
mapping: The mapping.
Returns:
Serializable representation.
"""
return dict(
item = _mapping_item_to_json(mapping.item),
tags = [str(tag) for tag in sorted(mapping.tags)]
)
def _mapping_item_to_json(item: Page | Link) -> dict:
"""
Return a serializable representation of a page or link.
Arguments:
item: The page or link.
Returns:
Serializable representation.
"""
return dict(url = item.url, title = item.title)
# -------------------------------------------------------------------------
def _mapping_from_json(data: object) -> Mapping:
"""
Return a mapping from a serialized representation.
Arguments:
data: Serialized representation.
Returns:
The mapping.
"""
if not isinstance(data, dict):
raise ValidationError(
f"Expected dictionary, but received: {data}"
)
# Ensure tags are iterable
tags = data.get("tags")
if not isinstance(tags, list):
raise ValidationError(
f"Expected list, but received: {tags}"
)
# Ensure tags are valid
for tag in tags:
if not isinstance(tag, str):
raise ValidationError(
f"Expected string, but received: {tag}"
)
# Create and return mapping
return Mapping(
_mapping_item_from_json(data.get("item")),
tags = [Tag(tag) for tag in tags]
)
def _mapping_item_from_json(data: object) -> Link:
"""
Return a link from a serialized representation.
When loading a mapping, we must always return a link, as the sources of
pages might not be available because we're building another project.
Arguments:
data: Serialized representation.
Returns:
The link.
"""
if not isinstance(data, dict):
raise ValidationError(
f"Expected dictionary, but received: {data}"
)
# Ensure item has URL
url = data.get("url")
if not isinstance(url, str):
raise ValidationError(
f"Expected string, but received: {url}"
)
# Ensure item has title
title = data.get("title")
if not isinstance(title, str):
raise ValidationError(
f"Expected string, but received: {title}"
)
# Create and return item
return Link(title, url)

View File

@@ -0,0 +1,149 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from collections.abc import Iterator
from functools import total_ordering
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
@total_ordering
class Tag:
"""
A tag.
"""
def __init__(
self, name: str, *, parent: Tag | None = None, hidden = False
):
"""
Initialize the tag.
Arguments:
name: The tag name.
parent: The parent tag.
hidden: Whether the tag is hidden.
"""
self.name = name
self.parent = parent
self.hidden = hidden
def __repr__(self) -> str:
"""
Return a printable representation of the tag.
Returns:
Printable representation.
"""
return f"Tag('{self.name}')"
def __str__(self) -> str:
"""
Return a string representation of the tag.
Returns:
String representation.
"""
return self.name
def __hash__(self) -> int:
"""
Return the hash of the tag.
Returns:
The hash.
"""
return hash(self.name)
def __iter__(self) -> Iterator[Tag]:
"""
Iterate over the tag and its parent tags.
Note that the first tag returned is the tag itself, followed by its
parent tags in ascending order. This allows to iterate over the tag
and its parents in a single loop, which is useful for generating
tree or breadcrumb structures.
Yields:
The current tag.
"""
tag = self
while tag:
yield tag
tag = tag.parent
def __contains__(self, other: Tag) -> bool:
"""
Check if the tag contains another tag.
Arguments:
other: The other tag to check.
Returns:
Whether the tag contains the other tag.
"""
assert isinstance(other, Tag)
return any(tag == other for tag in self)
def __eq__(self, other: Tag) -> bool:
"""
Check if the tag is equal to another tag.
Arguments:
other: The other tag to check.
Returns:
Whether the tags are equal.
"""
assert isinstance(other, Tag)
return self.name == other.name
def __lt__(self, other: Tag) -> bool:
"""
Check if the tag is less than another tag.
Arguments:
other: The other tag to check.
Returns:
Whether the tag is less than the other tag.
"""
assert isinstance(other, Tag)
return self.name < other.name
# -------------------------------------------------------------------------
name: str
"""
The tag name.
"""
parent: Tag | None
"""
The parent tag.
"""
hidden: bool
"""
Whether the tag is hidden.
"""

View File

@@ -0,0 +1,108 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from collections.abc import Iterable
from mkdocs.config.base import BaseConfigOption, ValidationError
from typing import Set
from . import Tag
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class TagSet(BaseConfigOption[Set[Tag]]):
"""
Setting for a set of tags.
This setting describes a set of tags, and is used to validate the actual
tags as defined in the front matter of pages, as well as for filters that
are used to include or exclude pages from a listing and to check if a tag
is allowed to be used.
"""
def __init__(self, *, allowed: set[Tag] = set()):
"""
Initialize the setting.
Arguments:
allowed: The tags allowed to be used.
"""
super().__init__()
self.allowed = allowed
# -------------------------------------------------------------------------
allowed: set[Tag]
"""
The tags allowed to be used.
"""
# -------------------------------------------------------------------------
def run_validation(self, value: object) -> set[Tag]:
"""
Validate list of tags.
If the value is `None`, an empty set is returned. Otherwise, the value
is expected to be a list of tags, which is converted to a set of tags.
This means that tags are automatically deduplicated. Note that tags are
not expanded here, as the set is intended to be checked exactly.
Arguments:
value: The value to validate.
Returns:
A set of tags.
"""
if value is None:
return set()
# Ensure tags are iterable
if not isinstance(value, Iterable) or isinstance(value, str):
raise ValidationError(
f"Expected iterable tags, but received: {value}"
)
# Ensure tags are valid
tags: set[Tag] = set()
for index, tag in enumerate(value):
if not isinstance(tag, (str, int, float, bool)):
raise ValidationError(
f"Expected a {str}, {int}, {float} or {bool} "
f"but received: {type(tag)} at index {index}"
)
# Coerce tag to string and add to set
tags.add(Tag(str(tag)))
# Ensure tags are in allow list, if any
if self.allowed:
invalid = tags.difference(self.allowed)
if invalid:
raise ValidationError(
"Tags not in allow list: " +
",".join([tag.name for tag in invalid])
)
# Return set of tags
return tags

View File

@@ -0,0 +1,80 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from material.plugins.tags.structure.tag import Tag
from mkdocs.structure.nav import Link
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class TagReference(Tag):
"""
A tag reference.
Tag references are a subclass of tags that can have associated links, which
is primarily used for linking tags to listings. The first link is used as
the canonical link, which by default points to the closest listing that
features the tag. This is considered to be the canonical listing.
"""
def __init__(self, tag: Tag, links: list[Link] | None = None):
"""
Initialize the tag reference.
Arguments:
tag: The tag.
links: The links associated with the tag.
"""
super().__init__(**vars(tag))
self.links = links or []
def __repr__(self) -> str:
"""
Return a printable representation of the tag reference.
Returns:
Printable representation.
"""
return f"TagReference('{self.name}')"
# -------------------------------------------------------------------------
links: list[Link]
"""
The links associated with the tag.
"""
# -------------------------------------------------------------------------
@property
def url(self) -> str | None:
"""
Return the URL of the tag reference.
Returns:
The URL of the tag reference.
"""
if self.links:
return self.links[0].url
else:
return None

View File

@@ -29,7 +29,7 @@
<span class="md-profile__description">
<strong>
{% if author.url %}
<a href="{{ author.url }}">{{ author.name }}</a>
<a href="{{ author.url | url }}">{{ author.name }}</a>
{% else %}
{{ author.name }}
{% endif %}
@@ -100,6 +100,25 @@
</li>
{% endif %}
</ul>
{% if page.config.links %}
<ul class="md-post__meta md-nav__list">
<li class="md-nav__item md-nav__item--section">
<div class="md-post__title">
<span class="md-ellipsis">
{{ lang.t("blog.references") }}
</span>
</div>
<nav class="md-nav">
<ul class="md-nav__list">
{% for nav_item in page.config.links %}
{% set path = "__ref_" ~ loop.index %}
{{ item.render(nav_item, path, 1) }}
{% endfor %}
</ul>
</nav>
</li>
</ul>
{% endif %}
</nav>
</li>
</ul>

View File

@@ -0,0 +1,19 @@
{#-
This file was automatically generated - do not edit
-#}
{% macro render(listing) %}
{{ listing.content }}
<ul>
{% for mapping in listing.mappings %}
<li>
<a href="{{ mapping.item.url | url }}">
{{ mapping.item.title }}
</a>
</li>
{% endfor %}
{% for child in listing %}
<li style="list-style-type:none">{{ render(child) }}</li>
{% endfor %}
</ul>
{% endmacro %}
{{ render(listing) }}

View File

@@ -0,0 +1,16 @@
{#-
This file was automatically generated - do not edit
-#}
{% set class = "md-tag" %}
{% if tag.hidden %}
{% set class = class ~ " md-tag-shadow" %}
{% endif %}
{% if config.extra.tags %}
{% set class = class ~ " md-tag-icon" %}
{% if tag.name in config.extra.tags %}
{% set class = class ~ " md-tag--" ~ config.extra.tags[tag.name] %}
{% endif %}
{% endif %}
<span class="{{ class }}">
{{- tag.name -}}
</span>

View File

@@ -1,9 +1,7 @@
{#-
This file was automatically generated - do not edit
-#}
{% if "material/tags" in config.plugins and tags %}
{% include "partials/tags.html" %}
{% endif %}
{% include "partials/tags.html" %}
{% include "partials/actions.html" %}
{% if "\x3ch1" not in page.content %}
<h1>{{ page.title | d(config.site_name, true)}}</h1>

View File

@@ -11,13 +11,17 @@
{% endif %}
{% endmacro %}
{% macro render_content(nav_item, ref = nav_item) %}
{% if nav_item.is_page and nav_item.meta.icon %}
{% if nav_item.meta and nav_item.meta.icon %}
{% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %}
{% endif %}
<span class="md-ellipsis">
{{ ref.title }}
{% if nav_item.meta and nav_item.meta.subtitle %}
<br>
<small>{{ nav_item.meta.subtitle }}</small>
{% endif %}
</span>
{% if nav_item.is_page and nav_item.meta.status %}
{% if nav_item.meta and nav_item.meta.status %}
{{ render_status(nav_item, nav_item.meta.status) }}
{% endif %}
{% endmacro %}

View File

@@ -4,23 +4,25 @@
{% if page.meta and page.meta.hide %}
{% set hidden = "hidden" if "tags" in page.meta.hide %}
{% endif %}
<nav class="md-tags" {{ hidden }}>
{% for tag in tags %}
{% set icon = "" %}
{% if config.extra.tags %}
{% set icon = " md-tag-icon" %}
{% if tag.type %}
{% set icon = icon ~ " md-tag--" ~ tag.type %}
{% if tags %}
<nav class="md-tags" {{ hidden }}>
{% for tag in tags %}
{% set class = "md-tag" %}
{% if config.extra.tags %}
{% set class = class ~ " md-tag-icon" %}
{% if tag.name in config.extra.tags %}
{% set class = class ~ " md-tag--" ~ config.extra.tags[tag.name] %}
{% endif %}
{% endif %}
{% endif %}
{% if tag.url %}
<a href="{{ tag.url | url }}" class="md-tag{{ icon }}">
{{- tag.name -}}
</a>
{% else %}
<span class="md-tag{{ icon }}">
{{- tag.name -}}
</span>
{% endif %}
{% endfor %}
</nav>
{% if tag.url %}
<a href="{{ tag.url | url }}" class="{{ class }}">
{{- tag.name -}}
</a>
{% else %}
<span class="{{ class }}">
{{- tag.name -}}
</span>
{% endif %}
{% endfor %}
</nav>
{% endif %}

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,124 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
from fnmatch import fnmatch
from mkdocs.structure.files import File
from .config import FilterConfig
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Filter:
"""
A filter.
"""
def __init__(self, config: FilterConfig):
"""
Initialize the filter.
Arguments:
config: The filter configuration.
"""
self.config = config
def __call__(self, value: str, ref: str | None = None) -> bool:
"""
Filter a value.
First, the inclusion patterns are checked. Regardless of whether they
are present, the exclusion patterns are checked afterwards. This allows
to exclude values that are included by the inclusion patterns, so that
exclusion patterns can be used to refine inclusion patterns.
Arguments:
value: The value to filter.
ref: The value used for logging.
Returns:
Whether the value should be included.
"""
ref = ref or value
# Check if value matches one of the inclusion patterns
if self.config.include:
for pattern in self.config.include:
if fnmatch(value, pattern):
break
# Value is not included
else:
log.debug(f"Excluding '{ref}' due to inclusion patterns")
return False
# Check if value matches one of the exclusion patterns
for pattern in self.config.exclude:
if fnmatch(value, pattern):
log.debug(f"Excluding '{ref}' due to exclusion patterns")
return False
# Value is not excluded
return True
# -------------------------------------------------------------------------
config: FilterConfig
"""
The filter configuration.
"""
# -----------------------------------------------------------------------------
class FileFilter(Filter):
"""
A file filter.
"""
def __call__(self, file: File) -> bool:
"""
Filter a file by its source path.
Arguments:
file: The file to filter.
Returns:
Whether the file should be included.
"""
if file.inclusion.is_excluded():
return False
# Filter file by source path
return super().__call__(
file.src_uri,
file.src_path
)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.utilities")

View File

@@ -0,0 +1,47 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from mkdocs.config.base import Config
from mkdocs.config.config_options import ListOfItems, Type
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class FilterConfig(Config):
"""
A filter configuration.
"""
include = ListOfItems(Type(str), default = [])
"""
Patterns to include.
This list contains patterns that are matched against the value to filter.
If the value matches at least one pattern, it will be included.
"""
exclude = ListOfItems(Type(str), default = [])
"""
Patterns to exclude.
This list contains patterns that are matched against the value to filter.
If the value matches at least one pattern, it will be excluded.
"""

View File

@@ -78,6 +78,7 @@ Funding = "https://github.com/sponsors/squidfunk"
"material/blog" = "material.plugins.blog.plugin:BlogPlugin"
"material/group" = "material.plugins.group.plugin:GroupPlugin"
"material/info" = "material.plugins.info.plugin:InfoPlugin"
"material/meta" = "material.plugins.meta.plugin:MetaPlugin"
"material/offline" = "material.plugins.offline.plugin:OfflinePlugin"
"material/privacy" = "material.plugins.privacy.plugin:PrivacyPlugin"
"material/search" = "material.plugins.search.plugin:SearchPlugin"

View File

@@ -26,6 +26,7 @@ import posixpath
import yaml
from babel.dates import format_date, format_datetime
from copy import copy
from datetime import datetime, timezone
from jinja2 import pass_context
from jinja2.runtime import Context
@@ -34,19 +35,20 @@ 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.nav import Link, Navigation, Section
from mkdocs.structure.pages import Page
from mkdocs.structure.toc import AnchorLink, TableOfContents
from mkdocs.utils import copy_file, get_relative_url
from mkdocs.utils.templates import url_filter
from paginate import Page as Pagination
from shutil import rmtree
from tempfile import mkdtemp
from urllib.parse import urlparse
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 .structure import Archive, Category, Excerpt, Post, Reference, View
# -----------------------------------------------------------------------------
# Classes
@@ -299,10 +301,18 @@ class BlogPlugin(BasePlugin[BlogConfig]):
if not self.config.enabled:
return
# Transform links to point to posts and pages
for post in self.blog.posts:
self._generate_links(post, config, files)
# Filter for formatting dates related to posts
def date_filter(date: datetime):
return self._format_date_for_post(date, config)
# Fetch URL template filter from environment - the filter might
# be overridden by other plugins, so we must retrieve and wrap it
url_filter = env.filters["url"]
# Patch URL template filter to add support for paginated views, i.e.,
# that paginated views never link to themselves but to the main view
@pass_context
@@ -524,14 +534,15 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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:
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Create file in temporary directory and temporarily remove
# from navigation, as we'll add it at a specific location
# Create file in temporary directory
self._save_to_file(file.abs_src_path, f"# {name}")
file.inclusion = InclusionLevel.EXCLUDED
# Temporarily remove view from navigation
file.inclusion = InclusionLevel.EXCLUDED
# Create and yield view
if not isinstance(file.page, Archive):
@@ -560,14 +571,15 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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:
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Create file in temporary directory and temporarily remove
# from navigation, as we'll add it at a specific location
# Create file in temporary directory
self._save_to_file(file.abs_src_path, f"# {name}")
file.inclusion = InclusionLevel.EXCLUDED
# Temporarily remove view from navigation
file.inclusion = InclusionLevel.EXCLUDED
# Create and yield view
if not isinstance(file.page, Category):
@@ -591,14 +603,15 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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:
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Copy file to temporary directory and temporarily remove
# from navigation, as we'll add it at a specific location
# Copy file to temporary directory
copy_file(view.file.abs_src_path, file.abs_src_path)
file.inclusion = InclusionLevel.EXCLUDED
# Temporarily remove view from navigation
file.inclusion = InclusionLevel.EXCLUDED
# Create and yield view
if not isinstance(file.page, View):
@@ -609,6 +622,79 @@ class BlogPlugin(BasePlugin[BlogConfig]):
file.page.pages = view.pages
file.page.posts = view.posts
# Generate links from the given post to other posts, pages, and sections -
# this can only be done once all posts and pages have been parsed
def _generate_links(self, post: Post, config: MkDocsConfig, files: Files):
if not post.config.links:
return
# Resolve path relative to docs directory for error reporting
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(post.file.abs_src_path, docs)
# Find all links to pages and replace them with references - while all
# internal links are processed, external links remain as they are
for link in _find_links(post.config.links.items):
url = urlparse(link.url)
if url.scheme:
continue
# Resolve file for link, and throw if the file could not be found -
# authors can link to other pages, as well as to assets or files of
# any kind, but it is essential that the file that is linked to is
# found, so errors are actually catched and reported
file = files.get_file_from_path(url.path)
if not file:
log.warning(
f"Error reading metadata of post '{path}' in '{docs}':\n"
f"Couldn't find file for link '{url.path}'"
)
continue
# If the file linked to is not a page, but an asset or any other
# file, we resolve the destination URL and continue
if not isinstance(file.page, Page):
link.url = file.url
continue
# Cast link to reference
link.__class__ = Reference
assert isinstance(link, Reference)
# Assign page title, URL and metadata to link
link.title = link.title or file.page.title
link.url = file.page.url
link.meta = copy(file.page.meta)
# If the link has no fragment, we can continue - if it does, we
# need to find the matching anchor in the table of contents
if not url.fragment:
continue
# If we're running under dirty reload, MkDocs will reset all pages,
# so it's not possible to resolve anchor links. Thus, the only way
# to make this work is to skip the entire process of anchor link
# resolution in case of a dirty reload.
if self.is_dirty:
continue
# Resolve anchor for fragment, and throw if the anchor could not be
# found - authors can link to any anchor in the table of contents
anchor = _find_anchor(file.page.toc, url.fragment)
if not anchor:
log.warning(
f"Error reading metadata of post '{path}' in '{docs}':\n"
f"Couldn't find anchor '{url.fragment}' in '{url.path}'"
)
# Restore link to original state
link.url = url.geturl()
continue
# Append anchor to URL and set subtitle
link.url += f"#{anchor.id}"
link.meta["subtitle"] = anchor.title
# -------------------------------------------------------------------------
# Attach a list of pages to each other and to the given parent item without
@@ -864,6 +950,35 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Translate placeholder
return template.module.t(key)
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------
# Find all links in the given list of items
def _find_links(items: list[StructureItem]):
for item in items:
# Resolve link
if isinstance(item, Link):
yield item
# Resolve sections recursively
if isinstance(item, Section):
for item in _find_links(item.children):
assert isinstance(item, Link)
yield item
# Find anchor in table of contents for the given id
def _find_anchor(toc: TableOfContents, id: str):
for anchor in toc:
if anchor.id == id:
return anchor
# Resolve anchors recursively
anchor = _find_anchor(anchor.children, id)
if isinstance(anchor, AnchorLink):
return anchor
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------

View File

@@ -27,10 +27,11 @@ import yaml
from copy import copy
from markdown import Markdown
from material.plugins.blog.author import Author
from material.plugins.meta.plugin import MetaPlugin
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.structure.files import File, Files
from mkdocs.structure.nav import Section
from mkdocs.structure.nav import Link, Section
from mkdocs.structure.pages import Page, _RelativePathTreeprocessor
from mkdocs.structure.toc import get_toc
from mkdocs.utils.meta import YAML_RE
@@ -87,6 +88,19 @@ class Post(Page):
f"{e}"
)
# Hack: if the meta plugin is registered, we need to move the call
# to `on_page_markdown` here, because we need to merge the metadata
# of the post with the metadata of any meta files prior to creating
# the post configuration. To our current knowledge, it's the only
# way to allow posts to receive metadata from meta files, because
# posts must be loaded prior to constructing the navigation in
# `on_files` but the meta plugin first runs in `on_page_markdown`.
plugin: MetaPlugin = config.plugins.get("material/meta")
if plugin:
plugin.on_page_markdown(
self.markdown, page = self, config = config, files = None
)
# Initialize post configuration, but remove all keys that this plugin
# doesn't care about, or they will be reported as invalid configuration
self.config: PostConfig = PostConfig(file.abs_src_path)
@@ -257,6 +271,17 @@ class Archive(View):
class Category(View):
pass
# -----------------------------------------------------------------------------
# Reference
class Reference(Link):
# Initialize reference - this is essentially a crossover of pages and links,
# as it inherits the metadata of the page and allows for anchors
def __init__(self, title: str, url: str):
super().__init__(title, url)
self.meta = {}
# -----------------------------------------------------------------------------
# Helper functions
# -----------------------------------------------------------------------------

View File

@@ -19,9 +19,9 @@
# IN THE SOFTWARE.
from mkdocs.config.base import Config
from mkdocs.config.config_options import ListOfItems, Optional, Type
from mkdocs.config.config_options import Optional, Type
from .options import PostDate
from .options import PostDate, PostLinks, UniqueListOfItems
# -----------------------------------------------------------------------------
# Classes
@@ -29,9 +29,10 @@ from .options import PostDate
# Post configuration
class PostConfig(Config):
authors = ListOfItems(Type(str), default = [])
categories = ListOfItems(Type(str), default = [])
authors = UniqueListOfItems(Type(str), default = [])
categories = UniqueListOfItems(Type(str), default = [])
date = PostDate()
draft = Optional(Type(bool))
links = Optional(PostLinks())
readtime = Optional(Type(int))
slug = Optional(Type(str))

View File

@@ -18,6 +18,8 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from markdown.treeprocessors import Treeprocessor
from mkdocs.structure.pages import Page
from mkdocs.utils import get_relative_url
@@ -31,12 +33,13 @@ from xml.etree.ElementTree import Element
class ExcerptTreeprocessor(Treeprocessor):
# Initialize excerpt tree processor
def __init__(self, page: Page, base: Page = None):
def __init__(self, page: Page, base: Page | None = None):
self.page = page
self.base = base
# Transform HTML after Markdown processing
def run(self, root: Element):
assert self.base
main = True
# We're only interested in anchors, which is why we continue when the

View File

@@ -20,6 +20,11 @@
from datetime import date, datetime, time, timezone
from mkdocs.config.base import BaseConfigOption, Config, ValidationError
from mkdocs.config.config_options import ListOfItems, T
from mkdocs.structure.files import Files
from mkdocs.structure.nav import (
Navigation, _add_parent_links, _data_to_navigation
)
from typing import Dict
# -----------------------------------------------------------------------------
@@ -97,3 +102,27 @@ class PostDate(BaseConfigOption[DateDict]):
# Return date dictionary
return value
# -----------------------------------------------------------------------------
# Post links option
class PostLinks(BaseConfigOption[Navigation]):
# Create navigation from structured items - we don't need to provide a
# configuration object to the function, because it will not be used
def run_validation(self, value: object):
items = _data_to_navigation(value, Files([]), None)
_add_parent_links(items)
# Return navigation
return Navigation(items, [])
# -----------------------------------------------------------------------------
# Unique list of items
class UniqueListOfItems(ListOfItems[T]):
# Ensure that each item is unique
def run_validation(self, value: object):
data = super().run_validation(value)
return list(dict.fromkeys(data))

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,33 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from mkdocs.config.config_options import Type
from mkdocs.config.base import Config
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Meta plugin configuration
class MetaConfig(Config):
enabled = Type(bool, default = True)
# Settings for meta files
meta_file = Type(str, default = ".meta.yml")

122
src/plugins/meta/plugin.py Normal file
View File

@@ -0,0 +1,122 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import logging
import os
import posixpath
from mergedeep import Strategy, merge
from mkdocs.exceptions import PluginError
from mkdocs.structure.files import InclusionLevel
from mkdocs.plugins import BasePlugin, event_priority
from yaml import SafeLoader, load
from .config import MetaConfig
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Meta plugin
class MetaPlugin(BasePlugin[MetaConfig]):
# Construct metadata mapping
def on_files(self, files, *, config):
if not self.config.enabled:
return
# Initialize mapping
self.meta = {}
# Resolve and load meta files in docs directory
docs = os.path.relpath(config.docs_dir)
for file in files:
name = posixpath.basename(file.src_uri)
if not name == self.config.meta_file:
continue
# Exclude meta file from site directory - explicitly excluding the
# meta file allows the author to use a file name without '.' prefix
file.inclusion = InclusionLevel.EXCLUDED
# Open file and parse as YAML
with open(file.abs_src_path, encoding = "utf-8-sig") as f:
path = file.src_path
try:
self.meta[path] = load(f, SafeLoader)
# The meta file could not be loaded because of a syntax error,
# which we display to the author with a nice error message
except Exception as e:
raise PluginError(
f"Error reading meta file '{path}' in '{docs}':\n"
f"{e}"
)
# Set metadata for page, if applicable (run earlier)
@event_priority(50)
def on_page_markdown(self, markdown, *, page, config, files):
if not self.config.enabled:
return
# Start with a clean state, as we first need to apply all meta files
# that are relevant to the current page, and then merge the page meta
# on top of that to ensure that the page meta always takes precedence
# over meta files - see https://t.ly/kvCRn
meta = {}
# Merge matching meta files in level-order
strategy = Strategy.TYPESAFE_ADDITIVE
for path, defaults in self.meta.items():
if not page.file.src_path.startswith(os.path.dirname(path)):
continue
# Skip if meta file was already merged - this happens in case of
# blog posts, as they need to be merged when posts are constructed,
# which is why we need to keep track of which meta files are applied
# to what pages using the `__extends` key.
page.meta.setdefault("__extends", [])
if path in page.meta["__extends"]:
continue
# Try to merge metadata
try:
merge(meta, defaults, strategy = strategy)
page.meta["__extends"].append(path)
# Merging the metadata with the given strategy resulted in an error,
# which we display to the author with a nice error message
except Exception as e:
docs = os.path.relpath(config.docs_dir)
raise PluginError(
f"Error merging meta file '{path}' in '{docs}':\n"
f"{e}"
)
# Ensure page metadata is merged last, so the author can override any
# defaults from the meta files, or even remove them entirely
page.meta = merge(meta, page.meta, strategy = strategy)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.meta")

View File

@@ -18,10 +18,27 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from .structure.mapping import Mapping
from .structure.tag import Tag
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
# Casefold a string for comparison when sorting
def casefold(tag: str):
return tag.casefold()
# Return tag name for sorting
def tag_name(tag: Tag, *args):
return tag.name
# Return casefolded tag name for sorting
def tag_name_casefold(tag: Tag, *args):
return tag.name.casefold()
# -----------------------------------------------------------------------------
# Return item title for sorting
def item_title(mapping: Mapping):
return mapping.item.title
# Return item URL for sorting
def item_url(mapping: Mapping):
return mapping.item.url

View File

@@ -18,9 +18,17 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from mkdocs.config.config_options import Optional, Type
from collections.abc import Callable
from material.utilities.filter import FilterConfig
from mkdocs.config.config_options import (
DictOfItems, Deprecated, ListOfItems, SubConfig, Type
)
from mkdocs.config.base import Config
from pymdownx.slugs import slugify
from . import item_title, tag_name
from .structure.listing import ListingConfig
from .structure.tag.options import TagSet
# -----------------------------------------------------------------------------
# Classes
@@ -30,6 +38,36 @@ from mkdocs.config.base import Config
class TagsConfig(Config):
enabled = Type(bool, default = True)
# Settings for filtering
filters = SubConfig(FilterConfig)
# Settings for tags
tags = Type(bool, default = True)
tags_file = Optional(Type(str))
tags_slugify = Type(Callable, default = slugify(case = "lower"))
tags_slugify_separator = Type(str, default = "-")
tags_slugify_format = Type(str, default = "tag:{slug}")
tags_sort_by = Type(Callable, default = tag_name)
tags_sort_reverse = Type(bool, default = False)
tags_name_property = Type(str, default = "tags")
tags_name_variable = Type(str, default = "tags")
tags_allowed = TagSet()
# Settings for listings
listings = Type(bool, default = True)
listings_map = DictOfItems(SubConfig(ListingConfig), default = {})
listings_sort_by = Type(Callable, default = item_title)
listings_sort_reverse = Type(bool, default = False)
listings_tags_sort_by = Type(Callable, default = tag_name)
listings_tags_sort_reverse = Type(bool, default = False)
listings_directive = Type(str, default = "material/tags")
listings_layout = Type(str, default = "default")
# Deprecated settings
tags_compare = Deprecated(moved_to = "tags_sort_by")
tags_compare_reverse = Deprecated(moved_to = "tags_sort_reverse")
tags_pages_compare = Deprecated(moved_to = "listings_sort_by")
tags_pages_compare_reverse = Deprecated(moved_to = "listings_sort_reverse")
tags_file = Deprecated(option_type = Type(str))
tags_extra_files = Deprecated(
option_type = DictOfItems(ListOfItems(Type(str)), default = {})
)

View File

@@ -18,174 +18,291 @@
# 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 sys
import os
import re
from collections import defaultdict
from markdown.extensions.toc import slugify
from mkdocs import utils
from mkdocs.plugins import BasePlugin
from jinja2 import Environment
from material.utilities.filter import FileFilter
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import PluginError
from mkdocs.plugins import BasePlugin, event_priority
from mkdocs.structure.pages import Page
from mkdocs.utils.templates import TemplateContext
# 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
from .renderer import Renderer
from .structure.listing.manager import ListingManager
from .structure.mapping.manager import MappingManager
from .structure.mapping.storage import MappingStorage
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Tags plugin
class TagsPlugin(BasePlugin[TagsConfig]):
"""
A tags plugin.
This plugin collects tags from the front matter of pages, and builds a tag
structure from them. The tag structure can be used to render listings on
pages, or to just create a site-wide tags index and export all tags and
mappings to a JSON file for consumption in another project.
"""
supports_multiple_instances = True
"""
This plugin supports multiple instances.
"""
# Initialize plugin
def on_config(self, config):
if not self.config.enabled:
return
def __init__(self, *args, **kwargs):
"""
Initialize the plugin.
"""
super().__init__(*args, **kwargs)
# Skip if tags should not be built
if not self.config.tags:
return
# Initialize incremental builds
self.is_serve = False
# 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
tags = page.meta.get("tags", [])
if tags:
for tag in tags:
self.tags[str(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
context["tags"] =[]
if "tags" in page.meta and page.meta["tags"]:
context["tags"] = [
self._render_tag(tag)
for tag in page.meta["tags"]
]
# Initialize mapping and listing managers
self.mappings = None
self.listings = None
# -------------------------------------------------------------------------
# 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)
mappings: MappingManager
"""
Mapping manager.
"""
# Add tags file to files - note: since MkDoc 1.6, not removing the
# file before adding it to the end will trigger a deprecation warning
# The new tags plugin does not require this hack, so we're just going
# to live with it until the new tags plugin is released.
files.remove(file)
files.append(file)
return file
listings: ListingManager
"""
Listing manager.
"""
# Render tags index
def _render_tag_index(self, markdown):
filter: FileFilter
"""
File filter.
"""
# -------------------------------------------------------------------------
def on_startup(self, *, command, **kwargs) -> None:
"""
Determine whether we're serving the site.
Arguments:
command: The command that is being executed.
dirty: Whether dirty builds are enabled.
"""
self.is_serve = command == "serve"
def on_config(self, config: MkDocsConfig) -> None:
"""
Create mapping and listing managers.
"""
# Retrieve toc depth, so we know the maximum level at which we can add
# items to the table of contents - Python Markdown allows to set the
# toc depth as a range, e.g. `2-6`, so we need to account for that as
# well. We need this information for generating listings.
depth = config.mdx_configs.get("toc", {}).get("toc_depth", 6)
if not isinstance(depth, int) and "-" in depth:
_, depth = depth.split("-")
# Initialize mapping and listing managers
self.mappings = MappingManager(self.config)
self.listings = ListingManager(self.config, int(depth))
# Initialize file filter - the file filter is used to include or exclude
# entire subsections of the documentation, allowing for using multiple
# instances of the plugin alongside each other. This can be necessary
# when creating multiple, potentially conflicting listings.
self.filter = FileFilter(self.config.filters)
# Ensure presence of attribute lists extension
for extension in config.markdown_extensions:
if isinstance(extension, str) and extension.endswith("attr_list"):
break
else:
config.markdown_extensions.append("attr_list")
# If the author only wants to extract and export mappings, we allow to
# disable the rendering of all tags and listings with a single setting
if self.config.export_only:
self.config.tags = False
self.config.listings = False
@event_priority(-50)
def on_page_markdown(
self, markdown: str, *, page: Page, config: MkDocsConfig, **kwargs
) -> str:
"""
Collect tags and listings from page.
Priority: -50 (run later)
Arguments:
markdown: The page's Markdown.
page: The page.
config: The MkDocs configuration.
Returns:
The page's Markdown with injection points.
"""
if not self.config.enabled:
return
# Skip if page should not be considered
if not self.filter(page.file):
return
# Handle deprecation of `tags_file` setting
if self.config.tags_file:
markdown = self._handle_deprecated_tags_file(page, markdown)
# Handle deprecation of `tags_extra_files` setting
if self.config.tags_extra_files:
markdown = self._handle_deprecated_tags_extra_files(page, markdown)
# Collect tags from page
try:
self.mappings.add(page, markdown)
# Raise exception if tags could not be read
except Exception as e:
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(page.file.abs_src_path, docs)
raise PluginError(
f"Error reading tags of page '{path}' in '{docs}':\n"
f"{e}"
)
# Collect listings from page
return self.listings.add(page, markdown)
@event_priority(100)
def on_env(
self, env: Environment, *, config: MkDocsConfig, **kwargs
) -> None:
"""
Populate listings.
Priority: 100 (run earliest)
Arguments:
env: The Jinja environment.
config: The MkDocs configuration.
"""
if not self.config.enabled:
return
# Populate and render all listings
self.listings.populate_all(self.mappings, Renderer(env, config))
# Export mappings to file, if enabled
if self.config.export:
path = os.path.join(config.site_dir, self.config.export_file)
path = os.path.normpath(path)
# Serialize mappings and save to file
storage = MappingStorage(self.config)
storage.save(path, self.mappings)
def on_page_context(
self, context: TemplateContext, *, page: Page, **kwargs
) -> None:
"""
Add tag references to page context.
Arguments:
context: The template context.
page: The page.
"""
if not self.config.enabled:
return
# Skip if page should not be considered
if not self.filter(page.file):
return
# Skip if tags should not be built
if not self.config.tags:
return
# Retrieve tags references for page
mapping = self.mappings.get(page)
if mapping:
tags = self.config.tags_name_variable
if tags not in context:
context[tags] = list(self.listings & mapping)
# -------------------------------------------------------------------------
def _handle_deprecated_tags_file(
self, page: Page, markdown: str
) -> str:
"""
Handle deprecation of `tags_file` setting.
Arguments:
page: The page.
"""
directive = self.config.listings_directive
if page.file.src_uri != self.config.tags_file:
return markdown
# Try to find the legacy tags marker and replace with directive
if "[TAGS]" in markdown:
markdown = markdown.replace("[TAGS]", "<!-- material/tags -->")
if not "<!-- material/tags -->" in markdown:
markdown += "\n<!-- material/tags -->"
# Replace placeholder in Markdown with rendered tags index
return markdown.replace("<!-- material/tags -->", "\n".join([
self._render_tag_links(*args)
for args in sorted(self.tags.items())
]))
# Render the given tag and links to all pages with occurrences
def _render_tag_links(self, tag, pages):
classes = ["md-tag"]
if isinstance(self.tags_map, dict):
classes.append("md-tag-icon")
type = self.tags_map.get(tag)
if type:
classes.append(f"md-tag--{type}")
# Render section for tag and a link to each page
classes = " ".join(classes)
content = [f"## <span class=\"{classes}\">{tag}</span>", ""]
for page in pages:
url = utils.get_relative_url(
page.file.src_uri,
self.tags_file.src_uri
markdown = markdown.replace(
"[TAGS]", f"<!-- {directive} -->"
)
# Render link to page
title = page.meta.get("title", page.title)
content.append(f"- [{title}]({url})")
# Try to find the directive and add it if not present
pattern = r"<!--\s+{directive}".format(directive = directive)
if not re.search(pattern, markdown):
markdown += f"\n<!-- {directive} -->"
# Return rendered tag links
return "\n".join(content)
# Return markdown
return markdown
# 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)
def _handle_deprecated_tags_extra_files(
self, page: Page, markdown: str
) -> str:
"""
Handle deprecation of `tags_extra_files` setting.
Arguments:
page: The page.
"""
directive = self.config.listings_directive
if page.file.src_uri not in self.config.tags_extra_files:
return markdown
# Compute tags to render on page
tags = self.config.tags_extra_files[page.file.src_uri]
if tags:
directive += f" {{ include: [{', '.join(tags)}] }}"
# Try to find the legacy tags marker and replace with directive
if "[TAGS]" in markdown:
markdown = markdown.replace(
"[TAGS]", f"<!-- {directive} -->"
)
# Try to find the directive and add it if not present
pattern = r"<!--\s+{directive}".format(directive = re.escape(directive))
if not re.search(pattern, markdown):
markdown += f"\n<!-- {directive} -->"
# Return markdown
return markdown
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.tags")
log = logging.getLogger("mkdocs.material.plugins.tags")

View File

@@ -0,0 +1,99 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import posixpath
from jinja2 import Environment
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.structure.pages import Page
from mkdocs.utils import get_relative_url
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Renderer:
"""
A renderer for tags and listings.
This class implements a simple tag and listing renderer, leveraging the
Jinja environment and the MkDocs configuration as provided to plugins.
Note that the templates must be stored in the `fragments` and not `partials`
directory, because in order to render tags and listings, we must wait for
all pages to be read and processed, as we first need to collect all tags
before we can render listings. Tags induce a graph, not a tree.
For this reason, we consider the templates to be fragments, as they are
not implicitly rendered by MkDocs, but explicitly by the plugin.
"""
def __init__(self, env: Environment, config: MkDocsConfig):
"""
Initialize renderer.
Arguments:
env: The Jinja environment.
config: The MkDocs configuration.
"""
self.env = env
self.config = config
# -------------------------------------------------------------------------
env: Environment
"""
The Jinja environment.
"""
config: MkDocsConfig
"""
The MkDocs configuration.
"""
# -------------------------------------------------------------------------
def render(self, page: Page, name: str, **kwargs) -> str:
"""
Render a template.
Templates are resolved from `fragments/tags`, so if you want to override
templates or provide additional ones place them in this directory.
Arguments:
page: The page.
name: The name of the template.
kwargs: The template variables.
Returns:
The rendered template.
"""
path = posixpath.join("fragments", "tags", name)
path = posixpath.normpath(path)
# Resolve and render template
template = self.env.get_template(path)
return template.render(
config = self.config, page = page,
base_url = get_relative_url(".", page.url),
**kwargs
)

View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,218 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import posixpath
from collections.abc import Iterator
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag import Tag
from mkdocs.structure.pages import Page
from .config import ListingConfig
from .tree import ListingTree
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Listing:
"""
A listing of tags.
Listings can be included on a page with the `<!-- material/tags [args] -->`
directive. The arguments are passed to a YAML parser, and are expected to
either be an inline listing configuration, or an identifier that points to
a listing configuration in the `listings_map` setting in `mkdocs.yml`.
Example 1: use inline listing configuration
``` markdown
<!-- material/tags { include: [foo, bar] } -->
```
Example 2: use listing configuration from `listings_map.qux`
``` markdown
<!-- material/tags qux -->
```
If no arguments are given, the default listing configuration is used,
rendering all tags and mappings. In case you're using multiple instances
of the tags plugin, you can use the `listings_directive` setting to change
the directive name.
"""
def __init__(self, page: Page, id: str, config: ListingConfig):
"""
Initialize the listing.
Arguments:
page: The page the listing is embedded in.
id: The listing identifier.
config: The listing configuration.
"""
self.page = page
self.id = id
self.config = config
self.tags = {}
def __repr__(self) -> str:
"""
Return a printable representation of the listing.
Returns:
Printable representation.
"""
return f"Listing({repr(self.page)})"
def __hash__(self) -> int:
"""
Return the hash of the listing.
Returns:
The hash.
"""
return hash(self.id)
def __iter__(self) -> Iterator[ListingTree]:
"""
Iterate over the listing in pre-order.
Yields:
The current listing tree.
"""
stack = list(reversed(self.tags.values()))
while stack:
tree = stack.pop()
yield tree
# Visit subtrees in reverse, so pre-order is preserved
stack += reversed([*tree])
def __and__(self, mapping: Mapping) -> Iterator[Tag]:
"""
Iterate over the tags of a mapping featured in the listing.
When hierarchical tags are used, the set of tags is expanded to include
all parent tags, but only for the inclusion check. The returned tags are
always the actual tags (= leaves) of the mapping. This is done to avoid
duplicate entries in the listing.
If a mapping features one of the tags excluded from the listing, the
entire mapping is excluded from the listing. Additionally, if a listing
should only include tags within the current scope, the mapping is only
included if the page is a child of the page the listing was found on.
Arguments:
mapping: The mapping.
Yields:
The current tag.
"""
assert isinstance(mapping, Mapping)
# If the mapping is on the same page as the listing, we skip it, as
# it makes no sense to link to a listing on the same page
if mapping.item == self.page:
return iter([])
# If the listing should only include tags within the current scope, we
# check if the page is a child of the page the listing is embedded in
if self.config.scope:
assert isinstance(mapping.item, Page)
# Note that we must use `File.src_uri` here, or we won't be able to
# detect the difference between pages, and index pages in sections,
# as `foo/index.md` and `foo.md` look the same when using `Page.url`
base = posixpath.dirname(posixpath.normpath(self.page.file.src_uri))
if not mapping.item.file.src_uri.startswith(posixpath.join(base, "")):
return iter([])
# If an exclusion list is given, expand each tag to check if the tag
# itself or one of its parents is excluded from the listing
if self.config.exclude:
if any(mapping & self.config.exclude):
return iter([])
# If an inclusion list is given, expand each tag to check if the tag
# itself or one of its parents is included in the listing
if self.config.include:
return mapping & self.config.include
# Otherwise, we can just return an iterator over the set of tags of the
# mapping as is, as no expansion is required
return iter(mapping.tags)
# -------------------------------------------------------------------------
page: Page
"""
The page the listing is embedded in.
"""
id: str
"""
The listing identifier.
As authors may place an arbitrary number of listings on any page, we need
to be able to distinguish between them. This is done automatically.
"""
config: ListingConfig
"""
The listing configuration.
"""
tags: dict[Tag, ListingTree]
"""
The listing trees, each of which associated with a tag.
"""
# -------------------------------------------------------------------------
def add(self, mapping: Mapping, *, hidden = True) -> None:
"""
Add mapping to listing.
Arguments:
mapping: The mapping.
hidden: Whether to add hidden tags.
"""
for leaf in self & mapping:
tree = self.tags
# Skip if hidden tags should not be rendered
if not hidden and leaf.hidden:
continue
# Iterate over expanded tags
for tag in reversed([*leaf]):
if tag not in tree:
tree[tag] = ListingTree(tag)
# If the tag is the leaf, i.e., the actual tag we want to add,
# we add the mapping to the listing tree's mappings
if tag == leaf:
tree[tag].mappings.append(mapping)
# Otherwise, we continue walking down the tree
else:
tree = tree[tag].children

View File

@@ -0,0 +1,99 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
import yaml
from material.plugins.tags.structure.tag.options import TagSet
from mkdocs.config.base import Config
from mkdocs.config.config_options import Optional, Type
from yaml import Dumper
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class ListingConfig(Config):
"""
A listing configuration.
"""
scope = Type(bool, default = False)
"""
Whether to only include pages in the current subsection.
Enabling this setting will only include pages that are on the same level or
on a lower level than the page the listing is on. This allows to create a
listing of tags on a page that only includes pages that are in the same
subsection of the documentation.
"""
layout = Optional(Type(str))
"""
The layout to use for rendering the listing.
This setting allows to override the global setting for the layout. If this
setting is not specified, the global `listings_layout` setting is used.
"""
include = TagSet()
"""
Tags to include in the listing.
If this set is empty, the listing does not filter pages by tags. Otherwise,
all pages that have at least one of the tags in this set will be included.
"""
exclude = TagSet()
"""
Tags to exclude from the listing.
If this set is empty, the listing does not filter pages by tags. Otherwise,
all pages that have at least one of the tags in this set will be excluded.
"""
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def _representer(dumper: Dumper, config: ListingConfig):
"""
Return a serializable representation of a listing configuration.
Arguments:
dumper: The YAML dumper.
config: The listing configuration.
Returns:
Serializable representation.
"""
copy = config.copy()
# Convert the include and exclude tag sets to lists of strings
copy.include = list(map(str, copy.include)) if copy.include else None
copy.exclude = list(map(str, copy.exclude)) if copy.exclude else None
# Return serializable listing configuration
data = { k: v for k, v in copy.items() if v is not None }
return dumper.represent_dict(data)
# -----------------------------------------------------------------------------
# Register listing configuration YAML representer
yaml.add_representer(ListingConfig, _representer)

View File

@@ -0,0 +1,497 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
import os
import posixpath
import re
import yaml
from collections.abc import Iterable, Iterator
from material.plugins.tags.config import TagsConfig
from material.plugins.tags.renderer import Renderer
from material.plugins.tags.structure.listing import Listing, ListingConfig
from material.plugins.tags.structure.listing.tree import ListingTree
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag import Tag
from material.plugins.tags.structure.tag.reference import TagReference
from mkdocs.exceptions import PluginError
from mkdocs.structure.pages import Page
from mkdocs.structure.nav import Link
from re import Match
from urllib.parse import urlparse
from . import toc
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class ListingManager:
"""
A listing manager.
The listing manager collects all listings from the Markdown of pages, then
populates them with mappings, and renders them. Furthermore, the listing
manager allows to obtain tag references for a given mapping, which are
tags annotated with links to listings.
"""
def __init__(self, config: TagsConfig, depth: int = 6):
"""
Initialize the listing manager.
Arguments:
config: The configuration.
"""
self.config = config
self.data = set()
self.depth = depth
def __repr__(self) -> str:
"""
Return a printable representation of the listing manager.
Returns:
Printable representation.
"""
return _print(self)
def __iter__(self) -> Iterator[Listing]:
"""
Iterate over listings.
Yields:
The current listing.
"""
return iter(self.data)
def __and__(self, mapping: Mapping) -> Iterator[TagReference]:
"""
Iterate over the tag references for the mapping.
Arguments:
mapping: The mapping.
Yields:
The current tag reference.
"""
assert isinstance(mapping, Mapping)
# Iterate over sorted tags and associate tags with listings - note that
# we sort the listings for the mapping by closeness, so that the first
# listing in the list is the closest one to the page or link the
# mapping is associated with
listings = self.closest(mapping)
for tag in self._sort_tags(mapping.tags):
ref = TagReference(tag)
# Iterate over listings and add links
for listing in listings:
if tag in listing & mapping:
value = listing.page.url or "."
# Compute URL for link - make sure to remove fragments, as
# they may be present in links extracted from remote tags.
# Additionally, we need to fallback to `.` if the URL is
# empty (= homepage) or the links will be incorrect.
url = urlparse(value, allow_fragments = False)
url = url._replace(fragment = self._slugify(tag))
# Add listing link to tag reference
ref.links.append(
Link(listing.page.title, url.geturl())
)
# Yield tag reference
yield ref
# -------------------------------------------------------------------------
config: TagsConfig
"""
The configuration.
"""
data: set[Listing]
"""
The listings.
"""
depth: int
"""
Table of contents maximum depth.
"""
# -------------------------------------------------------------------------
def add(self, page: Page, markdown: str) -> str:
"""
Add page.
This method is called by the tags plugin to retrieve all listings of a
page. It will parse the page's Markdown and add injections points into
the page's Markdown, which will be replaced by the renderer with the
actual listing later on.
Note that this method is intended to be called with the page during the
`on_page_markdown` event, as it modifies the page's Markdown. Moreover,
the Markdown must be explicitly passed, as we could otherwise run into
inconsistencies when other plugins modify the Markdown.
Arguments:
page: The page.
markdown: The page's Markdown.
Returns:
The page's Markdown with injection points.
"""
assert isinstance(markdown, str)
# Replace callback
def replace(match: Match) -> str:
config = self._resolve(page, match.group(2))
# Compute listing identifier - as the author might include multiple
# listings on a single page, we must make sure that the identifier
# is unique, so we use the page source file path and the position
# of the match within the page as an identifier.
id = f"{page.file.src_uri}:{match.start()}-{match.end()}"
self.data.add(Listing(page, id, config))
# Replace directive with hx headline if listings are enabled, or
# remove the listing entirely from the page and table of contents
if self.config.listings:
return "#" * self.depth + f" {id}/name {{ #{id}/slug }}"
else:
return
# Hack: replace directive with an hx headline to mark the injection
# point for the anchor links we will generate after parsing all pages.
# By using an hx headline, we can make sure that the injection point
# will always be a child of the preceding headline.
directive = self.config.listings_directive
return re.sub(
r"(<!--\s*?{directive}(.*?)\s*-->)".format(directive = directive),
replace, markdown, flags = re.I | re.M | re.S
)
def closest(self, mapping: Mapping) -> list[Listing]:
"""
Get listings for the mapping ordered by closeness.
Listings are sorted by closeness to the given page, i.e. the number of
common path components. This is useful for hierarchical listings, where
the tags of a page point to the closest listing featuring the tag, with
the option to show all listings featuring that tag.
Arguments:
mapping: The mapping.
Returns:
The listings.
"""
# Retrieve listings featuring tags of mapping
listings: list[Listing] = []
for listing in self.data:
if any(listing & mapping):
listings.append(listing)
# Ranking callback
def rank(listing: Listing) -> int:
path = posixpath.commonpath([mapping.item.url, listing.page.url])
return len(path)
# Return listings ordered by closeness to mapping
return sorted(listings, key = rank, reverse = True)
def populate(
self, listing: Listing, mappings: Iterable[Mapping], renderer: Renderer
) -> None:
"""
Populate listing with tags featured in the mappings.
Arguments:
listing: The listing.
mappings: The mappings.
renderer: The renderer.
"""
page = listing.page
assert isinstance(page.content, str)
# Add mappings to listing
for mapping in mappings:
listing.add(mapping)
# Sort listings and tags - we can only do this after all mappings have
# been added to the listing, because the tags inside the mappings do
# not have a proper order yet, and we need to order them as specified
# in the listing configuration.
listing.tags = self._sort_listing_tags(listing.tags)
# Render tags for listing headlines - the listing configuration allows
# tp specify a custom layout, so we resolve the template for tags here
name = posixpath.join(listing.config.layout, "tag.html")
for tree in listing:
tree.content = renderer.render(page, name, tag = tree.tag)
# Sort mappings and subtrees of listing tree
tree.mappings = self._sort_listing(tree.mappings)
tree.children = self._sort_listing_tags(tree.children)
# Replace callback
def replace(match: Match) -> str:
hx = match.group()
# Populate listing with anchor links to tags
anchors = toc.populate(listing, self._slugify)
if not anchors:
return
# Get reference to first tag in listing
head = next(iter(anchors.values()))
# Replace hx with actual level of listing and listing ids with
# placeholders to create a format string for the headline
hx = re.sub(
r"<(/?)h{}\b".format(self.depth),
r"<\g<1>h{}".format(head.level), hx
)
hx = re.sub(
r"{id}\/(\w+)".format(id = listing.id),
r"{\1}", hx, flags = re.I | re.M
)
# Render listing headlines
for tree in listing:
tree.content = hx.format(
slug = anchors[tree.tag].id,
name = tree.content
)
# Render listing - the listing configuration allows to specify a
# custom layout, so we resolve the template for listings here
name = posixpath.join(listing.config.layout, "listing.html")
return "\n".join([
renderer.render(page, name, listing = tree)
for tree in listing.tags.values()
])
# Hack: replace hx headlines (injection points) we added when parsing
# the page's Markdown with the actual listing content. Additionally,
# replace anchor links in the table of contents with the hierarchy
# generated from mapping over the listing, or remove them.
page.content = re.sub(
r"<h{x}[^>]+{id}.*?</h{x}>".format(
id = f"{listing.id}/slug", x = self.depth
),
replace, page.content, flags = re.I | re.M
)
def populate_all(
self, mappings: Iterable[Mapping], renderer: Renderer
) -> None:
"""
Populate all listings with tags featured in the mappings.
This method is called by the tags plugin to populate all listings with
the given mappings. It will also remove the injection points from the
page's Markdown. Note that this method is intended to be called during
the `on_env` event, after all pages have been rendered.
Arguments:
mappings: The mappings.
renderer: The renderer.
"""
for listing in self.data:
self.populate(listing, mappings, renderer)
# -------------------------------------------------------------------------
def _resolve(self, page: Page, args: str) -> ListingConfig:
"""
Resolve listing configuration.
Arguments:
page: The page the listing in embedded in.
args: The arguments, as parsed from Markdown.
Returns:
The listing configuration.
"""
data = yaml.safe_load(args)
path = page.file.abs_src_path
# Try to resolve available listing configuration
if isinstance(data, str):
config = self.config.listings_map.get(data, None)
if not config:
keys = ", ".join(self.config.listings_map.keys())
raise PluginError(
f"Couldn't find listing configuration: {data}. Available "
f"configurations: {keys}"
)
# Otherwise, handle inline listing configuration
else:
config = ListingConfig(config_file_path = path)
config.load_dict(data or {})
# Validate listing configuration
errors, warnings = config.validate()
for _, w in warnings:
path = os.path.relpath(path)
log.warning(
f"Error reading listing configuration in '{path}':\n"
f"{w}"
)
for _, e in errors:
path = os.path.relpath(path)
raise PluginError(
f"Error reading listing configuration in '{path}':\n"
f"{e}"
)
# Inherit layout configuration, unless explicitly set
if not isinstance(config.layout, str):
config.layout = self.config.listings_layout
# Return listing configuration
return config
# -------------------------------------------------------------------------
def _slugify(self, tag: Tag) -> str:
"""
Slugify tag.
Arguments:
tag: The tag.
Returns:
The slug.
"""
return self.config.tags_slugify_format.format(
slug = self.config.tags_slugify(
tag.name,
self.config.tags_slugify_separator
)
)
# -------------------------------------------------------------------------
def _sort_listing(
self, mappings: Iterable[Mapping]
) -> list[Mapping]:
"""
Sort listing.
When sorting a listing, we sort the mappings of the listing, which is
why the caller must pass the mappings of the listing. That way, we can
keep this implementation to be purely functional, without having to
mutate the listing, which makes testing simpler.
Arguments:
mappings: The mappings.
Returns:
The sorted mappings.
"""
return sorted(
mappings,
key = self.config.listings_sort_by,
reverse = self.config.listings_sort_reverse
)
def _sort_listing_tags(
self, children: dict[Tag, ListingTree]
) -> dict[Tag, ListingTree]:
"""
Sort listing tags.
When sorting a listing's tags, we sort the immediate subtrees of the
listing, which is why the caller must pass the children of the listing.
That way, we can keep this implementation to be purely functional,
without having to mutate the listing.
Arguments:
children: The listing trees, each of which associated with a tag.
Returns:
The sorted listing trees.
"""
return dict(sorted(
children.items(),
key = lambda item: self.config.listings_tags_sort_by(*item),
reverse = self.config.listings_tags_sort_reverse
))
def _sort_tags(
self, tags: Iterable[Tag]
) -> list[Tag]:
"""
Sort tags.
Arguments:
tags: The tags.
Returns:
The sorted tags.
"""
return sorted(
tags,
key = self.config.tags_sort_by,
reverse = self.config.tags_sort_reverse
)
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def _print(manager: ListingManager, indent: int = 0) -> str:
"""
Return a printable representation of a listing manager.
Arguments:
manager: The listing manager.
indent: The indentation level.
Returns:
Printable representation.
"""
lines: list[str] = []
lines.append(" " * indent + f"ListingManager()")
# Print listings
for listing in manager:
lines.append(" " * (indent + 2) + repr(listing))
# Concatenate everything
return "\n".join(lines)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.plugins.tags")

View File

@@ -0,0 +1,134 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from material.plugins.tags.structure.listing import Listing
from material.plugins.tags.structure.tag import Tag
from mkdocs.structure.pages import Page
from mkdocs.structure.toc import AnchorLink
from typing import Callable
# -----------------------------------------------------------------------------
# Typings
# -----------------------------------------------------------------------------
Slugify = Callable[[Tag], str]
"""
Slugify function.
Arguments:
tag: The tag.
Returns:
The slugified tag.
"""
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def populate(listing: Listing, slugify: Slugify) -> dict[Tag, AnchorLink]:
"""
Populate the table of contents of the page the listing is embedded.
Arguments:
listing: The listing.
slugify: Slugify function.
Returns:
The mapping of tags to anchor links.
"""
anchors: dict[Tag, AnchorLink] = {}
# Find injection point for listing
host, at = find(listing)
if at == -1:
return anchors
# Create anchor links
for tree in listing:
# Iterate over expanded tags
for i, tag in enumerate(reversed([*tree.tag])):
if tag not in anchors:
level = host.level + 1 + i
# Create anchor link
anchors[tag] = AnchorLink(tag.name, slugify(tag), level)
if not tag.parent:
continue
# Relate anchor link to parent
anchors[tag.parent].children.append(anchors[tag])
# Filter top-level anchor links and insert them into the page
children = [anchors[tag] for tag in anchors if not tag.parent]
if listing.config.toc:
host.children[at:at + 1] = children
else:
host.children.pop(at)
# Return mapping of tags to anchor links
return anchors
# -----------------------------------------------------------------------------
def find(listing: Listing) -> tuple[AnchorLink | None, int]:
"""
Find anchor link for the given listing.
This function traverses the table of contents of the given page and returns
the anchor's parent and index of the anchor with the given identifier. If
the anchor is on the root level, and the anchor we're looking for is an
injection point, an anchor to host the tags is created and returned.
Arguments:
lising: The listing.
Returns:
The anchor and index.
"""
page = listing.page
# Traverse table of contents
stack = list(page.toc)
while stack:
anchor = stack.pop()
# Traverse children
for i, child in enumerate(anchor.children):
if child.id.startswith(listing.id):
return anchor, i
# Add child to stack
stack.append(child)
# Check if anchor is on the root level
for i, anchor in enumerate(page.toc):
if anchor.id.startswith(listing.id):
# Create anchor link
host = AnchorLink(page.title, page.url, 1)
host.children = page.toc.items
return host, i
# Anchor could not be found
return None, -1

View File

@@ -0,0 +1,160 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from collections.abc import Iterator
from functools import total_ordering
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag import Tag
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
@total_ordering
class ListingTree:
"""
A listing tree.
This is an internal data structure that is used to render listings. It is
also the immediate structure that is passed to the template.
"""
def __init__(self, tag: Tag):
"""
Initialize the listing tree.
Arguments:
tag: The tag.
"""
self.tag = tag
self.content = None
self.mappings = []
self.children = {}
def __repr__(self) -> str:
"""
Return a printable representation of the listing tree.
Returns:
Printable representation.
"""
return _print(self)
def __hash__(self) -> int:
"""
Return the hash of the listing tree.
Returns:
The hash.
"""
return hash(self.tag)
def __iter__(self) -> Iterator[ListingTree]:
"""
Iterate over subtrees of the listing tree.
Yields:
The current subtree.
"""
return iter(self.children.values())
def __eq__(self, other: ListingTree) -> bool:
"""
Check if the listing tree is equal to another listing tree.
Arguments:
other: The other listing tree to check.
Returns:
Whether the listing trees are equal.
"""
assert isinstance(other, ListingTree)
return self.tag == other.tag
def __lt__(self, other: ListingTree) -> bool:
"""
Check if the listing tree is less than another listing tree.
Arguments:
other: The other listing tree to check.
Returns:
Whether the listing tree is less than the other listing tree.
"""
assert isinstance(other, ListingTree)
return self.tag < other.tag
# -------------------------------------------------------------------------
tag: Tag
"""
The tag.
"""
content: str | None
"""
The rendered content of the listing tree.
This attribute holds the result of rendering the `tag.html` template, which
is the rendered tag as displayed in the listing. It is essential that this
is done for all tags (and nested tags) before rendering the tree, as the
rendering process of the listing tree relies on this attribute.
"""
mappings: list[Mapping]
"""
The mappings associated with the tag.
"""
children: dict[Tag, ListingTree]
"""
The subtrees of the listing tree.
"""
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def _print(tree: ListingTree, indent: int = 0) -> str:
"""
Return a printable representation of a listing tree.
Arguments:
tree: The listing tree.
indent: The indentation level.
Returns:
Printable representation.
"""
lines: list[str] = []
lines.append(" " * indent + f"ListingTree({repr(tree.tag)})")
# Print mappings
for mapping in tree.mappings:
lines.append(" " * (indent + 2) + repr(mapping))
# Print subtrees
for child in tree.children.values():
lines.append(_print(child, indent + 2))
# Concatenate everything
return "\n".join(lines)

View File

@@ -0,0 +1,98 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from collections.abc import Iterable, Iterator
from material.plugins.tags.structure.tag import Tag
from mkdocs.structure.nav import Link
from mkdocs.structure.pages import Page
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Mapping:
"""
A mapping between a page or link and a set of tags.
We use this class to store the mapping between a page or link and a set of
tags. This is necessary as we don't want to store the tags directly on the
page or link object, in order not to clutter the internal data structures
of MkDocs, keeping the plugin as unobtrusive as possible.
Links are primarily used when integrating with tags from external projects,
as we can't construct a page object for them as we do for local files.
"""
def __init__(self, item: Page | Link, *, tags: Iterable[Tag] | None = None):
"""
Initialize the mapping.
Tags can be passed upon initialization, but can also be added later on
using the `add` or `update` method. of the `tags` attribute.
Arguments:
item: The page or link.
tags: The tags associated with the page or link.
"""
self.item = item
self.tags = set(tags or [])
def __repr__(self) -> str:
"""
Return a printable representation of the mapping.
Returns:
Printable representation.
"""
return f"Mapping({repr(self.item)}, tags={self.tags})"
def __and__(self, tags: set[Tag]) -> Iterator[Tag]:
"""
Iterate over the tags featured in the mapping.
This method expands each tag in the mapping and checks whether it is
equal to one of the tags in the given set. If so, the tag is yielded.
Arguments:
tags: The set of tags.
Yields:
The current tag.
"""
assert isinstance(tags, set)
# Iterate over expanded tags
for tag in self.tags:
if set(tag) & tags:
yield tag
# -------------------------------------------------------------------------
item: Page | Link
"""
The page or link.
"""
tags: set[Tag]
"""
The tags associated with the page or link.
"""

View File

@@ -0,0 +1,169 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from collections.abc import Iterator
from material.plugins.tags.config import TagsConfig
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag.options import TagSet
from mkdocs.structure.pages import Page
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class MappingManager:
"""
A mapping manager.
The mapping manager is responsible for collecting all tags from the front
matter of pages, and for building a tag structure from them, nothing more.
"""
def __init__(self, config: TagsConfig):
"""
Initialize the mapping manager.
Arguments:
config: The configuration.
"""
self.config = config
self.format = TagSet(allowed = self.config.tags_allowed)
self.data = {}
def __repr__(self) -> str:
"""
Return a printable representation of the mapping manager.
Returns:
Printable representation.
"""
return _print(self)
def __iter__(self) -> Iterator[Mapping]:
"""
Iterate over mappings.
Yields:
The current mapping.
"""
return iter(self.data.values())
# -------------------------------------------------------------------------
config: TagsConfig
"""
The configuration.
"""
format: TagSet
"""
The mapping format.
This is the validator that is used to check if tags are valid, including
the tags in the front matter of pages, as well as the tags defined in the
configuration. Numbers and booleans are always converted to strings before
creating tags, and the allow list is checked as well, if given.
"""
data: dict[str, Mapping]
"""
The mappings.
"""
# -------------------------------------------------------------------------
def add(self, page: Page, markdown: str) -> Mapping | None:
"""
Add page.
This method is called by the tags plugin to retrieve all tags of a page.
It extracts all tags from the front matter of the given page, and adds
them to the mapping. If no tags are found, no mapping is created and
nothing is returned.
Note that this method is intended to be called with the page during the
`on_page_markdown` event, as it reads the front matter of a page. Also,
the Markdown must be explicitly passed, as we could otherwise run into
inconsistencies when other plugins modify the Markdown.
Arguments:
page: The page.
markdown: The page's Markdown.
Returns:
The mapping or nothing.
"""
assert isinstance(markdown, str)
# Return nothing if page doesn't have tags
tags = self.config.tags_name_property
if not page.meta.get(tags, []):
return
# Create mapping and associate with page
mapping = Mapping(page)
self.data[page.url] = mapping
# Retrieve and validate tags, and add to mapping
for tag in self.format.validate(page.meta[tags]):
mapping.tags.add(tag)
# Return mapping
return mapping
def get(self, page: Page) -> Mapping | None:
"""
Get mapping for page, if any.
Arguments:
page: The page.
Returns:
The mapping or nothing.
"""
if page.url in self.data:
return self.data[page.url]
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def _print(manager: MappingManager, indent: int = 0) -> str:
"""
Return a printable representation of a mapping manager.
Arguments:
manager: The mapping manager.
indent: The indentation level.
Returns:
Printable representation.
"""
lines: list[str] = []
lines.append(" " * indent + f"MappingManager()")
# Print mappings
for mapping in manager:
lines.append(" " * (indent + 2) + repr(mapping))
# Concatenate everything
return "\n".join(lines)

View File

@@ -0,0 +1,211 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import json
import os
from collections.abc import Iterable
from material.plugins.tags.config import TagsConfig
from material.plugins.tags.structure.mapping import Mapping
from material.plugins.tags.structure.tag import Tag
from mkdocs.config.base import ValidationError
from mkdocs.structure.nav import Link
from mkdocs.structure.pages import Page
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class MappingStorage:
"""
A mapping storage.
The mapping storage allows to save and load mappings to and from a JSON
file, which allows for sharing tags across multiple MkDocs projects.
"""
def __init__(self, config: TagsConfig):
"""
Initialize the mapping storage.
Arguments:
config: The configuration.
"""
self.config = config
# -------------------------------------------------------------------------
config: TagsConfig
"""
The configuration.
"""
# -------------------------------------------------------------------------
def save(self, path: str, mappings: Iterable[Mapping]) -> None:
"""
Save mappings to file.
Arguments:
path: The file path.
mappings: The mappings.
"""
path = os.path.abspath(path)
os.makedirs(os.path.dirname(path), exist_ok = True)
# Save serialized mappings to file
with open(path, "w", encoding = "utf-8") as f:
data = [_mapping_to_json(mapping) for mapping in mappings]
json.dump(dict(mappings = data), f)
def load(self, path: str) -> Iterable[Mapping]:
"""
Load mappings from file.
Arguments:
path: The file path.
Yields:
The current mapping.
"""
with open(path, "r", encoding = "utf-8") as f:
data = json.load(f)
# Ensure root dictionary
if not isinstance(data, dict):
raise ValidationError(
f"Expected dictionary, but received: {data}"
)
# Ensure mappings are iterable
mappings = data.get("mappings")
if not isinstance(mappings, list):
raise ValidationError(
f"Expected list, but received: {mappings}"
)
# Create and yield mappings
for mapping in mappings:
yield _mapping_from_json(mapping)
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def _mapping_to_json(mapping: Mapping) -> dict:
"""
Return a serializable representation of a mapping.
Arguments:
mapping: The mapping.
Returns:
Serializable representation.
"""
return dict(
item = _mapping_item_to_json(mapping.item),
tags = [str(tag) for tag in sorted(mapping.tags)]
)
def _mapping_item_to_json(item: Page | Link) -> dict:
"""
Return a serializable representation of a page or link.
Arguments:
item: The page or link.
Returns:
Serializable representation.
"""
return dict(url = item.url, title = item.title)
# -------------------------------------------------------------------------
def _mapping_from_json(data: object) -> Mapping:
"""
Return a mapping from a serialized representation.
Arguments:
data: Serialized representation.
Returns:
The mapping.
"""
if not isinstance(data, dict):
raise ValidationError(
f"Expected dictionary, but received: {data}"
)
# Ensure tags are iterable
tags = data.get("tags")
if not isinstance(tags, list):
raise ValidationError(
f"Expected list, but received: {tags}"
)
# Ensure tags are valid
for tag in tags:
if not isinstance(tag, str):
raise ValidationError(
f"Expected string, but received: {tag}"
)
# Create and return mapping
return Mapping(
_mapping_item_from_json(data.get("item")),
tags = [Tag(tag) for tag in tags]
)
def _mapping_item_from_json(data: object) -> Link:
"""
Return a link from a serialized representation.
When loading a mapping, we must always return a link, as the sources of
pages might not be available because we're building another project.
Arguments:
data: Serialized representation.
Returns:
The link.
"""
if not isinstance(data, dict):
raise ValidationError(
f"Expected dictionary, but received: {data}"
)
# Ensure item has URL
url = data.get("url")
if not isinstance(url, str):
raise ValidationError(
f"Expected string, but received: {url}"
)
# Ensure item has title
title = data.get("title")
if not isinstance(title, str):
raise ValidationError(
f"Expected string, but received: {title}"
)
# Create and return item
return Link(title, url)

View File

@@ -0,0 +1,149 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from collections.abc import Iterator
from functools import total_ordering
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
@total_ordering
class Tag:
"""
A tag.
"""
def __init__(
self, name: str, *, parent: Tag | None = None, hidden = False
):
"""
Initialize the tag.
Arguments:
name: The tag name.
parent: The parent tag.
hidden: Whether the tag is hidden.
"""
self.name = name
self.parent = parent
self.hidden = hidden
def __repr__(self) -> str:
"""
Return a printable representation of the tag.
Returns:
Printable representation.
"""
return f"Tag('{self.name}')"
def __str__(self) -> str:
"""
Return a string representation of the tag.
Returns:
String representation.
"""
return self.name
def __hash__(self) -> int:
"""
Return the hash of the tag.
Returns:
The hash.
"""
return hash(self.name)
def __iter__(self) -> Iterator[Tag]:
"""
Iterate over the tag and its parent tags.
Note that the first tag returned is the tag itself, followed by its
parent tags in ascending order. This allows to iterate over the tag
and its parents in a single loop, which is useful for generating
tree or breadcrumb structures.
Yields:
The current tag.
"""
tag = self
while tag:
yield tag
tag = tag.parent
def __contains__(self, other: Tag) -> bool:
"""
Check if the tag contains another tag.
Arguments:
other: The other tag to check.
Returns:
Whether the tag contains the other tag.
"""
assert isinstance(other, Tag)
return any(tag == other for tag in self)
def __eq__(self, other: Tag) -> bool:
"""
Check if the tag is equal to another tag.
Arguments:
other: The other tag to check.
Returns:
Whether the tags are equal.
"""
assert isinstance(other, Tag)
return self.name == other.name
def __lt__(self, other: Tag) -> bool:
"""
Check if the tag is less than another tag.
Arguments:
other: The other tag to check.
Returns:
Whether the tag is less than the other tag.
"""
assert isinstance(other, Tag)
return self.name < other.name
# -------------------------------------------------------------------------
name: str
"""
The tag name.
"""
parent: Tag | None
"""
The parent tag.
"""
hidden: bool
"""
Whether the tag is hidden.
"""

View File

@@ -0,0 +1,108 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from collections.abc import Iterable
from mkdocs.config.base import BaseConfigOption, ValidationError
from typing import Set
from . import Tag
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class TagSet(BaseConfigOption[Set[Tag]]):
"""
Setting for a set of tags.
This setting describes a set of tags, and is used to validate the actual
tags as defined in the front matter of pages, as well as for filters that
are used to include or exclude pages from a listing and to check if a tag
is allowed to be used.
"""
def __init__(self, *, allowed: set[Tag] = set()):
"""
Initialize the setting.
Arguments:
allowed: The tags allowed to be used.
"""
super().__init__()
self.allowed = allowed
# -------------------------------------------------------------------------
allowed: set[Tag]
"""
The tags allowed to be used.
"""
# -------------------------------------------------------------------------
def run_validation(self, value: object) -> set[Tag]:
"""
Validate list of tags.
If the value is `None`, an empty set is returned. Otherwise, the value
is expected to be a list of tags, which is converted to a set of tags.
This means that tags are automatically deduplicated. Note that tags are
not expanded here, as the set is intended to be checked exactly.
Arguments:
value: The value to validate.
Returns:
A set of tags.
"""
if value is None:
return set()
# Ensure tags are iterable
if not isinstance(value, Iterable) or isinstance(value, str):
raise ValidationError(
f"Expected iterable tags, but received: {value}"
)
# Ensure tags are valid
tags: set[Tag] = set()
for index, tag in enumerate(value):
if not isinstance(tag, (str, int, float, bool)):
raise ValidationError(
f"Expected a {str}, {int}, {float} or {bool} "
f"but received: {type(tag)} at index {index}"
)
# Coerce tag to string and add to set
tags.add(Tag(str(tag)))
# Ensure tags are in allow list, if any
if self.allowed:
invalid = tags.difference(self.allowed)
if invalid:
raise ValidationError(
"Tags not in allow list: " +
",".join([tag.name for tag in invalid])
)
# Return set of tags
return tags

View File

@@ -0,0 +1,80 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
from material.plugins.tags.structure.tag import Tag
from mkdocs.structure.nav import Link
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class TagReference(Tag):
"""
A tag reference.
Tag references are a subclass of tags that can have associated links, which
is primarily used for linking tags to listings. The first link is used as
the canonical link, which by default points to the closest listing that
features the tag. This is considered to be the canonical listing.
"""
def __init__(self, tag: Tag, links: list[Link] | None = None):
"""
Initialize the tag reference.
Arguments:
tag: The tag.
links: The links associated with the tag.
"""
super().__init__(**vars(tag))
self.links = links or []
def __repr__(self) -> str:
"""
Return a printable representation of the tag reference.
Returns:
Printable representation.
"""
return f"TagReference('{self.name}')"
# -------------------------------------------------------------------------
links: list[Link]
"""
The links associated with the tag.
"""
# -------------------------------------------------------------------------
@property
def url(self) -> str | None:
"""
Return the URL of the tag reference.
Returns:
The URL of the tag reference.
"""
if self.links:
return self.links[0].url
else:
return None

View File

@@ -61,7 +61,7 @@
<span class="md-profile__description">
<strong>
{% if author.url %}
<a href="{{ author.url }}">{{ author.name }}</a>
<a href="{{ author.url | url }}">{{ author.name }}</a>
{% else %}
{{ author.name }}
{% endif %}
@@ -148,6 +148,29 @@
</li>
{% endif %}
</ul>
<!-- Related links -->
{% if page.config.links %}
<ul class="md-post__meta md-nav__list">
<li class="md-nav__item md-nav__item--section">
<div class="md-post__title">
<span class="md-ellipsis">
{{ lang.t("blog.references") }}
</span>
</div>
<!-- Render related links -->
<nav class="md-nav">
<ul class="md-nav__list">
{% for nav_item in page.config.links %}
{% set path = "__ref_" ~ loop.index %}
{{ item.render(nav_item, path, 1) }}
{% endfor %}
</ul>
</nav>
</li>
</ul>
{% endif %}
</nav>
</li>
</ul>

View File

@@ -0,0 +1,41 @@
<!--
Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
-->
{% macro render(listing) %}
{{ listing.content }}
<ul>
{% for mapping in listing.mappings %}
<li>
<a href="{{ mapping.item.url | url }}">
{{ mapping.item.title }}
</a>
</li>
{% endfor %}
{% for child in listing %}
<li style="list-style-type: none">{{ render(child) }}</li>
{% endfor %}
</ul>
{% endmacro %}
<!-- ---------------------------------------------------------------------- -->
{{ render(listing) }}

View File

@@ -0,0 +1,38 @@
<!--
Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
-->
<!-- Determine classes -->
{% set class = "md-tag" %}
{% if tag.hidden %}
{% set class = class ~ " md-tag-shadow" %}
{% endif %}
{% if config.extra.tags %}
{% set class = class ~ " md-tag-icon" %}
{% if tag.name in config.extra.tags %}
{% set class = class ~ " md-tag--" ~ config.extra.tags[tag.name] %}
{% endif %}
{% endif %}
<!-- Render tag -->
<span class="{{ class }}">
{{- tag.name -}}
</span>

View File

@@ -21,9 +21,7 @@
-->
<!-- Tags -->
{% if "material/tags" in config.plugins and tags %}
{% include "partials/tags.html" %}
{% endif %}
{% include "partials/tags.html" %}
<!-- Actions -->
{% include "partials/actions.html" %}

View File

@@ -42,17 +42,23 @@
{% macro render_content(nav_item, ref = nav_item) %}
<!-- Navigation link icon -->
{% if nav_item.is_page and nav_item.meta.icon %}
{% if nav_item.meta and nav_item.meta.icon %}
{% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %}
{% endif %}
<!-- Navigation link title -->
<span class="md-ellipsis">
{{ ref.title }}
<!-- Navigation link subtitle -->
{% if nav_item.meta and nav_item.meta.subtitle %}
<br />
<small>{{ nav_item.meta.subtitle }}</small>
{% endif %}
</span>
<!-- Navigation link status -->
{% if nav_item.is_page and nav_item.meta.status %}
{% if nav_item.meta and nav_item.meta.status %}
{{ render_status(nav_item, nav_item.meta.status) }}
{% endif %}
{% endmacro %}

View File

@@ -26,27 +26,31 @@
{% endif %}
<!-- Tags -->
<nav class="md-tags" {{ hidden }}>
{% for tag in tags %}
{% set icon = "" %}
{% if config.extra.tags %}
{% set icon = " md-tag-icon" %}
{% if tag.type %}
{% set icon = icon ~ " md-tag--" ~ tag.type %}
{% if tags %}
<nav class="md-tags" {{ hidden }}>
{% for tag in tags %}
<!-- Determine classes -->
{% set class = "md-tag" %}
{% if config.extra.tags %}
{% set class = class ~ " md-tag-icon" %}
{% if tag.name in config.extra.tags %}
{% set class = class ~ " md-tag--" ~ config.extra.tags[tag.name] %}
{% endif %}
{% endif %}
{% endif %}
<!-- Render tag with link -->
{% if tag.url %}
<a href="{{ tag.url | url }}" class="md-tag{{ icon }}">
{{- tag.name -}}
</a>
<!-- Render tag with link -->
{% if tag.url %}
<a href="{{ tag.url | url }}" class="{{ class }}">
{{- tag.name -}}
</a>
<!-- Render tag without link -->
{% else %}
<span class="md-tag{{ icon }}">
{{- tag.name -}}
</span>
{% endif %}
{% endfor %}
</nav>
<!-- Render tag without link -->
{% else %}
<span class="{{ class }}">
{{- tag.name -}}
</span>
{% endif %}
{% endfor %}
</nav>
{% endif %}

19
src/utilities/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

View File

@@ -0,0 +1,124 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from __future__ import annotations
import logging
from fnmatch import fnmatch
from mkdocs.structure.files import File
from .config import FilterConfig
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Filter:
"""
A filter.
"""
def __init__(self, config: FilterConfig):
"""
Initialize the filter.
Arguments:
config: The filter configuration.
"""
self.config = config
def __call__(self, value: str, ref: str | None = None) -> bool:
"""
Filter a value.
First, the inclusion patterns are checked. Regardless of whether they
are present, the exclusion patterns are checked afterwards. This allows
to exclude values that are included by the inclusion patterns, so that
exclusion patterns can be used to refine inclusion patterns.
Arguments:
value: The value to filter.
ref: The value used for logging.
Returns:
Whether the value should be included.
"""
ref = ref or value
# Check if value matches one of the inclusion patterns
if self.config.include:
for pattern in self.config.include:
if fnmatch(value, pattern):
break
# Value is not included
else:
log.debug(f"Excluding '{ref}' due to inclusion patterns")
return False
# Check if value matches one of the exclusion patterns
for pattern in self.config.exclude:
if fnmatch(value, pattern):
log.debug(f"Excluding '{ref}' due to exclusion patterns")
return False
# Value is not excluded
return True
# -------------------------------------------------------------------------
config: FilterConfig
"""
The filter configuration.
"""
# -----------------------------------------------------------------------------
class FileFilter(Filter):
"""
A file filter.
"""
def __call__(self, file: File) -> bool:
"""
Filter a file by its source path.
Arguments:
file: The file to filter.
Returns:
Whether the file should be included.
"""
if file.inclusion.is_excluded():
return False
# Filter file by source path
return super().__call__(
file.src_uri,
file.src_path
)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.utilities")

View File

@@ -0,0 +1,47 @@
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from mkdocs.config.base import Config
from mkdocs.config.config_options import ListOfItems, Type
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class FilterConfig(Config):
"""
A filter configuration.
"""
include = ListOfItems(Type(str), default = [])
"""
Patterns to include.
This list contains patterns that are matched against the value to filter.
If the value matches at least one pattern, it will be included.
"""
exclude = ListOfItems(Type(str), default = [])
"""
Patterns to exclude.
This list contains patterns that are matched against the value to filter.
If the value matches at least one pattern, it will be excluded.
"""