From fefdd42c96aadf16a09dee7a5b8f1755d0ef46d4 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Fri, 31 Jan 2025 10:31:48 +0700 Subject: [PATCH] Merged features tied to 'Chipotle' funding goal --- material/plugins/blog/plugin.py | 145 ++++- material/plugins/blog/structure/__init__.py | 27 +- material/plugins/blog/structure/config.py | 9 +- material/plugins/blog/structure/markdown.py | 5 +- material/plugins/blog/structure/options.py | 29 + material/plugins/meta/__init__.py | 19 + material/plugins/meta/config.py | 33 ++ material/plugins/meta/plugin.py | 122 +++++ material/plugins/tags/__init__.py | 23 +- material/plugins/tags/config.py | 42 +- material/plugins/tags/plugin.py | 399 +++++++++----- material/plugins/tags/renderer/__init__.py | 99 ++++ material/plugins/tags/structure/__init__.py | 19 + .../tags/structure/listing/__init__.py | 218 ++++++++ .../plugins/tags/structure/listing/config.py | 99 ++++ .../structure/listing/manager/__init__.py | 497 ++++++++++++++++++ .../tags/structure/listing/manager/toc.py | 134 +++++ .../tags/structure/listing/tree/__init__.py | 160 ++++++ .../tags/structure/mapping/__init__.py | 98 ++++ .../structure/mapping/manager/__init__.py | 169 ++++++ .../structure/mapping/storage/__init__.py | 211 ++++++++ .../plugins/tags/structure/tag/__init__.py | 149 ++++++ .../plugins/tags/structure/tag/options.py | 108 ++++ .../tags/structure/tag/reference/__init__.py | 80 +++ material/templates/blog-post.html | 21 +- .../fragments/tags/default/listing.html | 19 + .../templates/fragments/tags/default/tag.html | 16 + material/templates/partials/content.html | 4 +- material/templates/partials/nav-item.html | 8 +- material/templates/partials/tags.html | 40 +- material/utilities/__init__.py | 19 + material/utilities/filter/__init__.py | 124 +++++ material/utilities/filter/config.py | 47 ++ pyproject.toml | 1 + src/plugins/blog/plugin.py | 145 ++++- src/plugins/blog/structure/__init__.py | 27 +- src/plugins/blog/structure/config.py | 9 +- src/plugins/blog/structure/markdown.py | 5 +- src/plugins/blog/structure/options.py | 29 + src/plugins/meta/__init__.py | 19 + src/plugins/meta/config.py | 33 ++ src/plugins/meta/plugin.py | 122 +++++ src/plugins/tags/__init__.py | 23 +- src/plugins/tags/config.py | 42 +- src/plugins/tags/plugin.py | 399 +++++++++----- src/plugins/tags/renderer/__init__.py | 99 ++++ src/plugins/tags/structure/__init__.py | 19 + .../tags/structure/listing/__init__.py | 218 ++++++++ src/plugins/tags/structure/listing/config.py | 99 ++++ .../structure/listing/manager/__init__.py | 497 ++++++++++++++++++ .../tags/structure/listing/manager/toc.py | 134 +++++ .../tags/structure/listing/tree/__init__.py | 160 ++++++ .../tags/structure/mapping/__init__.py | 98 ++++ .../structure/mapping/manager/__init__.py | 169 ++++++ .../structure/mapping/storage/__init__.py | 211 ++++++++ src/plugins/tags/structure/tag/__init__.py | 149 ++++++ src/plugins/tags/structure/tag/options.py | 108 ++++ .../tags/structure/tag/reference/__init__.py | 80 +++ src/templates/blog-post.html | 25 +- .../fragments/tags/default/listing.html | 41 ++ src/templates/fragments/tags/default/tag.html | 38 ++ src/templates/partials/content.html | 4 +- src/templates/partials/nav-item.html | 10 +- src/templates/partials/tags.html | 46 +- src/utilities/__init__.py | 19 + src/utilities/filter/__init__.py | 124 +++++ src/utilities/filter/config.py | 47 ++ 67 files changed, 6055 insertions(+), 386 deletions(-) create mode 100644 material/plugins/meta/__init__.py create mode 100644 material/plugins/meta/config.py create mode 100644 material/plugins/meta/plugin.py create mode 100644 material/plugins/tags/renderer/__init__.py create mode 100644 material/plugins/tags/structure/__init__.py create mode 100644 material/plugins/tags/structure/listing/__init__.py create mode 100644 material/plugins/tags/structure/listing/config.py create mode 100644 material/plugins/tags/structure/listing/manager/__init__.py create mode 100644 material/plugins/tags/structure/listing/manager/toc.py create mode 100644 material/plugins/tags/structure/listing/tree/__init__.py create mode 100644 material/plugins/tags/structure/mapping/__init__.py create mode 100644 material/plugins/tags/structure/mapping/manager/__init__.py create mode 100644 material/plugins/tags/structure/mapping/storage/__init__.py create mode 100644 material/plugins/tags/structure/tag/__init__.py create mode 100644 material/plugins/tags/structure/tag/options.py create mode 100644 material/plugins/tags/structure/tag/reference/__init__.py create mode 100644 material/templates/fragments/tags/default/listing.html create mode 100644 material/templates/fragments/tags/default/tag.html create mode 100644 material/utilities/__init__.py create mode 100644 material/utilities/filter/__init__.py create mode 100644 material/utilities/filter/config.py create mode 100644 src/plugins/meta/__init__.py create mode 100644 src/plugins/meta/config.py create mode 100644 src/plugins/meta/plugin.py create mode 100644 src/plugins/tags/renderer/__init__.py create mode 100644 src/plugins/tags/structure/__init__.py create mode 100644 src/plugins/tags/structure/listing/__init__.py create mode 100644 src/plugins/tags/structure/listing/config.py create mode 100644 src/plugins/tags/structure/listing/manager/__init__.py create mode 100644 src/plugins/tags/structure/listing/manager/toc.py create mode 100644 src/plugins/tags/structure/listing/tree/__init__.py create mode 100644 src/plugins/tags/structure/mapping/__init__.py create mode 100644 src/plugins/tags/structure/mapping/manager/__init__.py create mode 100644 src/plugins/tags/structure/mapping/storage/__init__.py create mode 100644 src/plugins/tags/structure/tag/__init__.py create mode 100644 src/plugins/tags/structure/tag/options.py create mode 100644 src/plugins/tags/structure/tag/reference/__init__.py create mode 100644 src/templates/fragments/tags/default/listing.html create mode 100644 src/templates/fragments/tags/default/tag.html create mode 100644 src/utilities/__init__.py create mode 100644 src/utilities/filter/__init__.py create mode 100644 src/utilities/filter/config.py diff --git a/material/plugins/blog/plugin.py b/material/plugins/blog/plugin.py index 5a01d214d..d00366cca 100644 --- a/material/plugins/blog/plugin.py +++ b/material/plugins/blog/plugin.py @@ -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 # ----------------------------------------------------------------------------- diff --git a/material/plugins/blog/structure/__init__.py b/material/plugins/blog/structure/__init__.py index 454797667..90f73ff40 100644 --- a/material/plugins/blog/structure/__init__.py +++ b/material/plugins/blog/structure/__init__.py @@ -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 # ----------------------------------------------------------------------------- diff --git a/material/plugins/blog/structure/config.py b/material/plugins/blog/structure/config.py index bfad74296..68355c962 100644 --- a/material/plugins/blog/structure/config.py +++ b/material/plugins/blog/structure/config.py @@ -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)) diff --git a/material/plugins/blog/structure/markdown.py b/material/plugins/blog/structure/markdown.py index 56f140de2..35c3fbb93 100644 --- a/material/plugins/blog/structure/markdown.py +++ b/material/plugins/blog/structure/markdown.py @@ -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 diff --git a/material/plugins/blog/structure/options.py b/material/plugins/blog/structure/options.py index 031a74d12..2d33926cc 100644 --- a/material/plugins/blog/structure/options.py +++ b/material/plugins/blog/structure/options.py @@ -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)) diff --git a/material/plugins/meta/__init__.py b/material/plugins/meta/__init__.py new file mode 100644 index 000000000..cf4e7db90 --- /dev/null +++ b/material/plugins/meta/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/material/plugins/meta/config.py b/material/plugins/meta/config.py new file mode 100644 index 000000000..b74df7c8c --- /dev/null +++ b/material/plugins/meta/config.py @@ -0,0 +1,33 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from mkdocs.config.config_options import Type +from mkdocs.config.base import Config + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + +# Meta plugin configuration +class MetaConfig(Config): + enabled = Type(bool, default = True) + + # Settings for meta files + meta_file = Type(str, default = ".meta.yml") diff --git a/material/plugins/meta/plugin.py b/material/plugins/meta/plugin.py new file mode 100644 index 000000000..bfaed47fa --- /dev/null +++ b/material/plugins/meta/plugin.py @@ -0,0 +1,122 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +import logging +import 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") diff --git a/material/plugins/tags/__init__.py b/material/plugins/tags/__init__.py index 8911cf508..8a8ee2e01 100644 --- a/material/plugins/tags/__init__.py +++ b/material/plugins/tags/__init__.py @@ -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 diff --git a/material/plugins/tags/config.py b/material/plugins/tags/config.py index 243a2f89c..4d48a3836 100644 --- a/material/plugins/tags/config.py +++ b/material/plugins/tags/config.py @@ -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 = {}) + ) diff --git a/material/plugins/tags/plugin.py b/material/plugins/tags/plugin.py index 709205f55..b4143beca 100644 --- a/material/plugins/tags/plugin.py +++ b/material/plugins/tags/plugin.py @@ -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]", "") - if not "" in markdown: - markdown += "\n" - - # Replace placeholder in Markdown with rendered tags index - return markdown.replace("", "\n".join([ - self._render_tag_links(*args) - for args in sorted(self.tags.items()) - ])) - - # Render the given tag and links to all pages with occurrences - def _render_tag_links(self, tag, pages): - classes = ["md-tag"] - if isinstance(self.tags_map, dict): - classes.append("md-tag-icon") - type = self.tags_map.get(tag) - if type: - classes.append(f"md-tag--{type}") - - # Render section for tag and a link to each page - classes = " ".join(classes) - content = [f"## {tag}", ""] - for page in pages: - url = utils.get_relative_url( - page.file.src_uri, - self.tags_file.src_uri + markdown = markdown.replace( + "[TAGS]", f"" ) - # 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"" - # 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"" + ) + + # Try to find the directive and add it if not present + pattern = r"" + + # Return markdown + return markdown # ----------------------------------------------------------------------------- # Data # ----------------------------------------------------------------------------- # Set up logging -log = logging.getLogger("mkdocs.material.tags") +log = logging.getLogger("mkdocs.material.plugins.tags") diff --git a/material/plugins/tags/renderer/__init__.py b/material/plugins/tags/renderer/__init__.py new file mode 100644 index 000000000..fc50c7b79 --- /dev/null +++ b/material/plugins/tags/renderer/__init__.py @@ -0,0 +1,99 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import 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 + ) diff --git a/material/plugins/tags/structure/__init__.py b/material/plugins/tags/structure/__init__.py new file mode 100644 index 000000000..cf4e7db90 --- /dev/null +++ b/material/plugins/tags/structure/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/material/plugins/tags/structure/listing/__init__.py b/material/plugins/tags/structure/listing/__init__.py new file mode 100644 index 000000000..5e7ba7faa --- /dev/null +++ b/material/plugins/tags/structure/listing/__init__.py @@ -0,0 +1,218 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import 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 `` + 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 + + ``` + + Example 2: use listing configuration from `listings_map.qux` + + ``` markdown + + ``` + + 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 diff --git a/material/plugins/tags/structure/listing/config.py b/material/plugins/tags/structure/listing/config.py new file mode 100644 index 000000000..640c91f02 --- /dev/null +++ b/material/plugins/tags/structure/listing/config.py @@ -0,0 +1,99 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +import 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) diff --git a/material/plugins/tags/structure/listing/manager/__init__.py b/material/plugins/tags/structure/listing/manager/__init__.py new file mode 100644 index 000000000..a22958862 --- /dev/null +++ b/material/plugins/tags/structure/listing/manager/__init__.py @@ -0,0 +1,497 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import logging +import os +import posixpath +import 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"()".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"]+{id}.*?".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") diff --git a/material/plugins/tags/structure/listing/manager/toc.py b/material/plugins/tags/structure/listing/manager/toc.py new file mode 100644 index 000000000..7c8e4275a --- /dev/null +++ b/material/plugins/tags/structure/listing/manager/toc.py @@ -0,0 +1,134 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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 diff --git a/material/plugins/tags/structure/listing/tree/__init__.py b/material/plugins/tags/structure/listing/tree/__init__.py new file mode 100644 index 000000000..5d0e1f59a --- /dev/null +++ b/material/plugins/tags/structure/listing/tree/__init__.py @@ -0,0 +1,160 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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) diff --git a/material/plugins/tags/structure/mapping/__init__.py b/material/plugins/tags/structure/mapping/__init__.py new file mode 100644 index 000000000..6090ac847 --- /dev/null +++ b/material/plugins/tags/structure/mapping/__init__.py @@ -0,0 +1,98 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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. + """ diff --git a/material/plugins/tags/structure/mapping/manager/__init__.py b/material/plugins/tags/structure/mapping/manager/__init__.py new file mode 100644 index 000000000..c43f5b98b --- /dev/null +++ b/material/plugins/tags/structure/mapping/manager/__init__.py @@ -0,0 +1,169 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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) diff --git a/material/plugins/tags/structure/mapping/storage/__init__.py b/material/plugins/tags/structure/mapping/storage/__init__.py new file mode 100644 index 000000000..c824eaacd --- /dev/null +++ b/material/plugins/tags/structure/mapping/storage/__init__.py @@ -0,0 +1,211 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import 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) diff --git a/material/plugins/tags/structure/tag/__init__.py b/material/plugins/tags/structure/tag/__init__.py new file mode 100644 index 000000000..0162c0da0 --- /dev/null +++ b/material/plugins/tags/structure/tag/__init__.py @@ -0,0 +1,149 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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. + """ diff --git a/material/plugins/tags/structure/tag/options.py b/material/plugins/tags/structure/tag/options.py new file mode 100644 index 000000000..65b0715a7 --- /dev/null +++ b/material/plugins/tags/structure/tag/options.py @@ -0,0 +1,108 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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 diff --git a/material/plugins/tags/structure/tag/reference/__init__.py b/material/plugins/tags/structure/tag/reference/__init__.py new file mode 100644 index 000000000..1d069316b --- /dev/null +++ b/material/plugins/tags/structure/tag/reference/__init__.py @@ -0,0 +1,80 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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 diff --git a/material/templates/blog-post.html b/material/templates/blog-post.html index dcaefc457..791b57f91 100644 --- a/material/templates/blog-post.html +++ b/material/templates/blog-post.html @@ -29,7 +29,7 @@ {% if author.url %} - {{ author.name }} + {{ author.name }} {% else %} {{ author.name }} {% endif %} @@ -100,6 +100,25 @@ {% endif %} + {% if page.config.links %} + + {% endif %} diff --git a/material/templates/fragments/tags/default/listing.html b/material/templates/fragments/tags/default/listing.html new file mode 100644 index 000000000..06edac8dc --- /dev/null +++ b/material/templates/fragments/tags/default/listing.html @@ -0,0 +1,19 @@ +{#- + This file was automatically generated - do not edit +-#} +{% macro render(listing) %} + {{ listing.content }} +
    + {% for mapping in listing.mappings %} +
  • + + {{ mapping.item.title }} + +
  • + {% endfor %} + {% for child in listing %} +
  • {{ render(child) }}
  • + {% endfor %} +
+{% endmacro %} +{{ render(listing) }} diff --git a/material/templates/fragments/tags/default/tag.html b/material/templates/fragments/tags/default/tag.html new file mode 100644 index 000000000..3c1863869 --- /dev/null +++ b/material/templates/fragments/tags/default/tag.html @@ -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 %} + + {{- tag.name -}} + diff --git a/material/templates/partials/content.html b/material/templates/partials/content.html index 282a0b6d4..ee069864d 100644 --- a/material/templates/partials/content.html +++ b/material/templates/partials/content.html @@ -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 %}

{{ page.title | d(config.site_name, true)}}

diff --git a/material/templates/partials/nav-item.html b/material/templates/partials/nav-item.html index 08752b760..a0bcdcfb9 100644 --- a/material/templates/partials/nav-item.html +++ b/material/templates/partials/nav-item.html @@ -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 %} {{ ref.title }} + {% if nav_item.meta and nav_item.meta.subtitle %} +
+ {{ nav_item.meta.subtitle }} + {% endif %}
- {% 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 %} diff --git a/material/templates/partials/tags.html b/material/templates/partials/tags.html index 128e95954..aed4b9faf 100644 --- a/material/templates/partials/tags.html +++ b/material/templates/partials/tags.html @@ -4,23 +4,25 @@ {% if page.meta and page.meta.hide %} {% set hidden = "hidden" if "tags" in page.meta.hide %} {% endif %} - +{% endif %} diff --git a/material/utilities/__init__.py b/material/utilities/__init__.py new file mode 100644 index 000000000..cf4e7db90 --- /dev/null +++ b/material/utilities/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/material/utilities/filter/__init__.py b/material/utilities/filter/__init__.py new file mode 100644 index 000000000..d36a04a66 --- /dev/null +++ b/material/utilities/filter/__init__.py @@ -0,0 +1,124 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import logging + +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") diff --git a/material/utilities/filter/config.py b/material/utilities/filter/config.py new file mode 100644 index 000000000..f336df8be --- /dev/null +++ b/material/utilities/filter/config.py @@ -0,0 +1,47 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from mkdocs.config.base import Config +from mkdocs.config.config_options import ListOfItems, 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. + """ diff --git a/pyproject.toml b/pyproject.toml index 48173478b..3a21a953c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/plugins/blog/plugin.py b/src/plugins/blog/plugin.py index 5a01d214d..d00366cca 100644 --- a/src/plugins/blog/plugin.py +++ b/src/plugins/blog/plugin.py @@ -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 # ----------------------------------------------------------------------------- diff --git a/src/plugins/blog/structure/__init__.py b/src/plugins/blog/structure/__init__.py index 454797667..90f73ff40 100644 --- a/src/plugins/blog/structure/__init__.py +++ b/src/plugins/blog/structure/__init__.py @@ -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 # ----------------------------------------------------------------------------- diff --git a/src/plugins/blog/structure/config.py b/src/plugins/blog/structure/config.py index bfad74296..68355c962 100644 --- a/src/plugins/blog/structure/config.py +++ b/src/plugins/blog/structure/config.py @@ -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)) diff --git a/src/plugins/blog/structure/markdown.py b/src/plugins/blog/structure/markdown.py index 56f140de2..35c3fbb93 100644 --- a/src/plugins/blog/structure/markdown.py +++ b/src/plugins/blog/structure/markdown.py @@ -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 diff --git a/src/plugins/blog/structure/options.py b/src/plugins/blog/structure/options.py index 031a74d12..2d33926cc 100644 --- a/src/plugins/blog/structure/options.py +++ b/src/plugins/blog/structure/options.py @@ -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)) diff --git a/src/plugins/meta/__init__.py b/src/plugins/meta/__init__.py new file mode 100644 index 000000000..cf4e7db90 --- /dev/null +++ b/src/plugins/meta/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/src/plugins/meta/config.py b/src/plugins/meta/config.py new file mode 100644 index 000000000..b74df7c8c --- /dev/null +++ b/src/plugins/meta/config.py @@ -0,0 +1,33 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from mkdocs.config.config_options import Type +from mkdocs.config.base import Config + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + +# Meta plugin configuration +class MetaConfig(Config): + enabled = Type(bool, default = True) + + # Settings for meta files + meta_file = Type(str, default = ".meta.yml") diff --git a/src/plugins/meta/plugin.py b/src/plugins/meta/plugin.py new file mode 100644 index 000000000..bfaed47fa --- /dev/null +++ b/src/plugins/meta/plugin.py @@ -0,0 +1,122 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +import logging +import 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") diff --git a/src/plugins/tags/__init__.py b/src/plugins/tags/__init__.py index 8911cf508..8a8ee2e01 100644 --- a/src/plugins/tags/__init__.py +++ b/src/plugins/tags/__init__.py @@ -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 diff --git a/src/plugins/tags/config.py b/src/plugins/tags/config.py index 243a2f89c..4d48a3836 100644 --- a/src/plugins/tags/config.py +++ b/src/plugins/tags/config.py @@ -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 = {}) + ) diff --git a/src/plugins/tags/plugin.py b/src/plugins/tags/plugin.py index 709205f55..b4143beca 100644 --- a/src/plugins/tags/plugin.py +++ b/src/plugins/tags/plugin.py @@ -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]", "") - if not "" in markdown: - markdown += "\n" - - # Replace placeholder in Markdown with rendered tags index - return markdown.replace("", "\n".join([ - self._render_tag_links(*args) - for args in sorted(self.tags.items()) - ])) - - # Render the given tag and links to all pages with occurrences - def _render_tag_links(self, tag, pages): - classes = ["md-tag"] - if isinstance(self.tags_map, dict): - classes.append("md-tag-icon") - type = self.tags_map.get(tag) - if type: - classes.append(f"md-tag--{type}") - - # Render section for tag and a link to each page - classes = " ".join(classes) - content = [f"## {tag}", ""] - for page in pages: - url = utils.get_relative_url( - page.file.src_uri, - self.tags_file.src_uri + markdown = markdown.replace( + "[TAGS]", f"" ) - # 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"" - # 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"" + ) + + # Try to find the directive and add it if not present + pattern = r"" + + # Return markdown + return markdown # ----------------------------------------------------------------------------- # Data # ----------------------------------------------------------------------------- # Set up logging -log = logging.getLogger("mkdocs.material.tags") +log = logging.getLogger("mkdocs.material.plugins.tags") diff --git a/src/plugins/tags/renderer/__init__.py b/src/plugins/tags/renderer/__init__.py new file mode 100644 index 000000000..fc50c7b79 --- /dev/null +++ b/src/plugins/tags/renderer/__init__.py @@ -0,0 +1,99 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import 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 + ) diff --git a/src/plugins/tags/structure/__init__.py b/src/plugins/tags/structure/__init__.py new file mode 100644 index 000000000..cf4e7db90 --- /dev/null +++ b/src/plugins/tags/structure/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/src/plugins/tags/structure/listing/__init__.py b/src/plugins/tags/structure/listing/__init__.py new file mode 100644 index 000000000..5e7ba7faa --- /dev/null +++ b/src/plugins/tags/structure/listing/__init__.py @@ -0,0 +1,218 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import 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 `` + 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 + + ``` + + Example 2: use listing configuration from `listings_map.qux` + + ``` markdown + + ``` + + 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 diff --git a/src/plugins/tags/structure/listing/config.py b/src/plugins/tags/structure/listing/config.py new file mode 100644 index 000000000..640c91f02 --- /dev/null +++ b/src/plugins/tags/structure/listing/config.py @@ -0,0 +1,99 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +import 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) diff --git a/src/plugins/tags/structure/listing/manager/__init__.py b/src/plugins/tags/structure/listing/manager/__init__.py new file mode 100644 index 000000000..a22958862 --- /dev/null +++ b/src/plugins/tags/structure/listing/manager/__init__.py @@ -0,0 +1,497 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import logging +import os +import posixpath +import 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"()".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"]+{id}.*?".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") diff --git a/src/plugins/tags/structure/listing/manager/toc.py b/src/plugins/tags/structure/listing/manager/toc.py new file mode 100644 index 000000000..7c8e4275a --- /dev/null +++ b/src/plugins/tags/structure/listing/manager/toc.py @@ -0,0 +1,134 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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 diff --git a/src/plugins/tags/structure/listing/tree/__init__.py b/src/plugins/tags/structure/listing/tree/__init__.py new file mode 100644 index 000000000..5d0e1f59a --- /dev/null +++ b/src/plugins/tags/structure/listing/tree/__init__.py @@ -0,0 +1,160 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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) diff --git a/src/plugins/tags/structure/mapping/__init__.py b/src/plugins/tags/structure/mapping/__init__.py new file mode 100644 index 000000000..6090ac847 --- /dev/null +++ b/src/plugins/tags/structure/mapping/__init__.py @@ -0,0 +1,98 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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. + """ diff --git a/src/plugins/tags/structure/mapping/manager/__init__.py b/src/plugins/tags/structure/mapping/manager/__init__.py new file mode 100644 index 000000000..c43f5b98b --- /dev/null +++ b/src/plugins/tags/structure/mapping/manager/__init__.py @@ -0,0 +1,169 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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) diff --git a/src/plugins/tags/structure/mapping/storage/__init__.py b/src/plugins/tags/structure/mapping/storage/__init__.py new file mode 100644 index 000000000..c824eaacd --- /dev/null +++ b/src/plugins/tags/structure/mapping/storage/__init__.py @@ -0,0 +1,211 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import 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) diff --git a/src/plugins/tags/structure/tag/__init__.py b/src/plugins/tags/structure/tag/__init__.py new file mode 100644 index 000000000..0162c0da0 --- /dev/null +++ b/src/plugins/tags/structure/tag/__init__.py @@ -0,0 +1,149 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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. + """ diff --git a/src/plugins/tags/structure/tag/options.py b/src/plugins/tags/structure/tag/options.py new file mode 100644 index 000000000..65b0715a7 --- /dev/null +++ b/src/plugins/tags/structure/tag/options.py @@ -0,0 +1,108 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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 diff --git a/src/plugins/tags/structure/tag/reference/__init__.py b/src/plugins/tags/structure/tag/reference/__init__.py new file mode 100644 index 000000000..1d069316b --- /dev/null +++ b/src/plugins/tags/structure/tag/reference/__init__.py @@ -0,0 +1,80 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from 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 diff --git a/src/templates/blog-post.html b/src/templates/blog-post.html index a758779ae..bf049b6fc 100644 --- a/src/templates/blog-post.html +++ b/src/templates/blog-post.html @@ -61,7 +61,7 @@ {% if author.url %} - {{ author.name }} + {{ author.name }} {% else %} {{ author.name }} {% endif %} @@ -148,6 +148,29 @@ {% endif %} + + + {% if page.config.links %} + + {% endif %} diff --git a/src/templates/fragments/tags/default/listing.html b/src/templates/fragments/tags/default/listing.html new file mode 100644 index 000000000..ea5d8cdfe --- /dev/null +++ b/src/templates/fragments/tags/default/listing.html @@ -0,0 +1,41 @@ + + +{% macro render(listing) %} + {{ listing.content }} +
    + {% for mapping in listing.mappings %} +
  • + + {{ mapping.item.title }} + +
  • + {% endfor %} + {% for child in listing %} +
  • {{ render(child) }}
  • + {% endfor %} +
+{% endmacro %} + + + +{{ render(listing) }} diff --git a/src/templates/fragments/tags/default/tag.html b/src/templates/fragments/tags/default/tag.html new file mode 100644 index 000000000..c8c765f0f --- /dev/null +++ b/src/templates/fragments/tags/default/tag.html @@ -0,0 +1,38 @@ + + + +{% 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 %} + + + + {{- tag.name -}} + diff --git a/src/templates/partials/content.html b/src/templates/partials/content.html index 0dc766471..d8c20eb78 100644 --- a/src/templates/partials/content.html +++ b/src/templates/partials/content.html @@ -21,9 +21,7 @@ --> -{% if "material/tags" in config.plugins and tags %} - {% include "partials/tags.html" %} -{% endif %} +{% include "partials/tags.html" %} {% include "partials/actions.html" %} diff --git a/src/templates/partials/nav-item.html b/src/templates/partials/nav-item.html index 1717f5bc7..96349f524 100644 --- a/src/templates/partials/nav-item.html +++ b/src/templates/partials/nav-item.html @@ -42,17 +42,23 @@ {% 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 %} {{ ref.title }} + + + {% if nav_item.meta and nav_item.meta.subtitle %} +
+ {{ nav_item.meta.subtitle }} + {% endif %}
- {% 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 %} diff --git a/src/templates/partials/tags.html b/src/templates/partials/tags.html index 15200e2bd..df7e16461 100644 --- a/src/templates/partials/tags.html +++ b/src/templates/partials/tags.html @@ -26,27 +26,31 @@ {% endif %} - +{% endif %} diff --git a/src/utilities/__init__.py b/src/utilities/__init__.py new file mode 100644 index 000000000..cf4e7db90 --- /dev/null +++ b/src/utilities/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/src/utilities/filter/__init__.py b/src/utilities/filter/__init__.py new file mode 100644 index 000000000..d36a04a66 --- /dev/null +++ b/src/utilities/filter/__init__.py @@ -0,0 +1,124 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import logging + +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") diff --git a/src/utilities/filter/config.py b/src/utilities/filter/config.py new file mode 100644 index 000000000..f336df8be --- /dev/null +++ b/src/utilities/filter/config.py @@ -0,0 +1,47 @@ +# Copyright (c) 2016-2025 Martin Donath + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from mkdocs.config.base import Config +from mkdocs.config.config_options import ListOfItems, 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. + """