mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2026-03-31 06:33:51 -04:00
Merged features tied to 'Chipotle' funding goal
This commit is contained in:
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
19
material/plugins/meta/__init__.py
Normal file
19
material/plugins/meta/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
33
material/plugins/meta/config.py
Normal file
33
material/plugins/meta/config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from mkdocs.config.config_options import Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Meta plugin configuration
|
||||
class MetaConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
|
||||
# Settings for meta files
|
||||
meta_file = Type(str, default = ".meta.yml")
|
||||
122
material/plugins/meta/plugin.py
Normal file
122
material/plugins/meta/plugin.py
Normal file
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from mergedeep import Strategy, merge
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.structure.files import InclusionLevel
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from yaml import SafeLoader, load
|
||||
|
||||
from .config import MetaConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Meta plugin
|
||||
class MetaPlugin(BasePlugin[MetaConfig]):
|
||||
|
||||
# Construct metadata mapping
|
||||
def on_files(self, files, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Initialize mapping
|
||||
self.meta = {}
|
||||
|
||||
# Resolve and load meta files in docs directory
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
for file in files:
|
||||
name = posixpath.basename(file.src_uri)
|
||||
if not name == self.config.meta_file:
|
||||
continue
|
||||
|
||||
# Exclude meta file from site directory - explicitly excluding the
|
||||
# meta file allows the author to use a file name without '.' prefix
|
||||
file.inclusion = InclusionLevel.EXCLUDED
|
||||
|
||||
# Open file and parse as YAML
|
||||
with open(file.abs_src_path, encoding = "utf-8-sig") as f:
|
||||
path = file.src_path
|
||||
try:
|
||||
self.meta[path] = load(f, SafeLoader)
|
||||
|
||||
# The meta file could not be loaded because of a syntax error,
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
raise PluginError(
|
||||
f"Error reading meta file '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Set metadata for page, if applicable (run earlier)
|
||||
@event_priority(50)
|
||||
def on_page_markdown(self, markdown, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Start with a clean state, as we first need to apply all meta files
|
||||
# that are relevant to the current page, and then merge the page meta
|
||||
# on top of that to ensure that the page meta always takes precedence
|
||||
# over meta files - see https://t.ly/kvCRn
|
||||
meta = {}
|
||||
|
||||
# Merge matching meta files in level-order
|
||||
strategy = Strategy.TYPESAFE_ADDITIVE
|
||||
for path, defaults in self.meta.items():
|
||||
if not page.file.src_path.startswith(os.path.dirname(path)):
|
||||
continue
|
||||
|
||||
# Skip if meta file was already merged - this happens in case of
|
||||
# blog posts, as they need to be merged when posts are constructed,
|
||||
# which is why we need to keep track of which meta files are applied
|
||||
# to what pages using the `__extends` key.
|
||||
page.meta.setdefault("__extends", [])
|
||||
if path in page.meta["__extends"]:
|
||||
continue
|
||||
|
||||
# Try to merge metadata
|
||||
try:
|
||||
merge(meta, defaults, strategy = strategy)
|
||||
page.meta["__extends"].append(path)
|
||||
|
||||
# Merging the metadata with the given strategy resulted in an error,
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
raise PluginError(
|
||||
f"Error merging meta file '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Ensure page metadata is merged last, so the author can override any
|
||||
# defaults from the meta files, or even remove them entirely
|
||||
page.meta = merge(meta, page.meta, strategy = strategy)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.meta")
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {})
|
||||
)
|
||||
|
||||
@@ -18,174 +18,291 @@
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
from collections import defaultdict
|
||||
from markdown.extensions.toc import slugify
|
||||
from mkdocs import utils
|
||||
from mkdocs.plugins import BasePlugin
|
||||
from jinja2 import Environment
|
||||
from material.utilities.filter import FileFilter
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.utils.templates import TemplateContext
|
||||
|
||||
# deprecated, but kept for downward compatibility. Use 'material.plugins.tags'
|
||||
# as an import source instead. This import is removed in the next major version.
|
||||
from . import casefold
|
||||
from .config import TagsConfig
|
||||
from .renderer import Renderer
|
||||
from .structure.listing.manager import ListingManager
|
||||
from .structure.mapping.manager import MappingManager
|
||||
from .structure.mapping.storage import MappingStorage
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Tags plugin
|
||||
class TagsPlugin(BasePlugin[TagsConfig]):
|
||||
"""
|
||||
A tags plugin.
|
||||
|
||||
This plugin collects tags from the front matter of pages, and builds a tag
|
||||
structure from them. The tag structure can be used to render listings on
|
||||
pages, or to just create a site-wide tags index and export all tags and
|
||||
mappings to a JSON file for consumption in another project.
|
||||
"""
|
||||
|
||||
supports_multiple_instances = True
|
||||
"""
|
||||
This plugin supports multiple instances.
|
||||
"""
|
||||
|
||||
# Initialize plugin
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Initialize the plugin.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
|
||||
# Initialize tags
|
||||
self.tags = defaultdict(list)
|
||||
self.tags_file = None
|
||||
|
||||
# Retrieve tags mapping from configuration
|
||||
self.tags_map = config.extra.get("tags")
|
||||
|
||||
# Use override of slugify function
|
||||
toc = { "slugify": slugify, "separator": "-" }
|
||||
if "toc" in config.mdx_configs:
|
||||
toc = { **toc, **config.mdx_configs["toc"] }
|
||||
|
||||
# Partially apply slugify function
|
||||
self.slugify = lambda value: (
|
||||
toc["slugify"](str(value), toc["separator"])
|
||||
)
|
||||
|
||||
# Hack: 2nd pass for tags index page(s)
|
||||
def on_nav(self, nav, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Resolve tags index page
|
||||
file = self.config.tags_file
|
||||
if file:
|
||||
self.tags_file = self._get_tags_file(files, file)
|
||||
|
||||
# Build and render tags index page
|
||||
def on_page_markdown(self, markdown, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Skip, if page is excluded
|
||||
if page.file.inclusion.is_excluded():
|
||||
return
|
||||
|
||||
# Render tags index page
|
||||
if page.file == self.tags_file:
|
||||
return self._render_tag_index(markdown)
|
||||
|
||||
# Add page to tags index
|
||||
tags = page.meta.get("tags", [])
|
||||
if tags:
|
||||
for tag in tags:
|
||||
self.tags[str(tag)].append(page)
|
||||
|
||||
# Inject tags into page (after search and before minification)
|
||||
def on_page_context(self, context, page, config, nav):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Provide tags for page
|
||||
context["tags"] =[]
|
||||
if "tags" in page.meta and page.meta["tags"]:
|
||||
context["tags"] = [
|
||||
self._render_tag(tag)
|
||||
for tag in page.meta["tags"]
|
||||
]
|
||||
# Initialize mapping and listing managers
|
||||
self.mappings = None
|
||||
self.listings = None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Obtain tags file
|
||||
def _get_tags_file(self, files, path):
|
||||
file = files.get_file_from_path(path)
|
||||
if not file:
|
||||
log.error(f"Tags file '{path}' does not exist.")
|
||||
sys.exit(1)
|
||||
mappings: MappingManager
|
||||
"""
|
||||
Mapping manager.
|
||||
"""
|
||||
|
||||
# Add tags file to files - note: since MkDoc 1.6, not removing the
|
||||
# file before adding it to the end will trigger a deprecation warning
|
||||
# The new tags plugin does not require this hack, so we're just going
|
||||
# to live with it until the new tags plugin is released.
|
||||
files.remove(file)
|
||||
files.append(file)
|
||||
return file
|
||||
listings: ListingManager
|
||||
"""
|
||||
Listing manager.
|
||||
"""
|
||||
|
||||
# Render tags index
|
||||
def _render_tag_index(self, markdown):
|
||||
filter: FileFilter
|
||||
"""
|
||||
File filter.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def on_startup(self, *, command, **kwargs) -> None:
|
||||
"""
|
||||
Determine whether we're serving the site.
|
||||
|
||||
Arguments:
|
||||
command: The command that is being executed.
|
||||
dirty: Whether dirty builds are enabled.
|
||||
"""
|
||||
self.is_serve = command == "serve"
|
||||
|
||||
def on_config(self, config: MkDocsConfig) -> None:
|
||||
"""
|
||||
Create mapping and listing managers.
|
||||
"""
|
||||
|
||||
# Retrieve toc depth, so we know the maximum level at which we can add
|
||||
# items to the table of contents - Python Markdown allows to set the
|
||||
# toc depth as a range, e.g. `2-6`, so we need to account for that as
|
||||
# well. We need this information for generating listings.
|
||||
depth = config.mdx_configs.get("toc", {}).get("toc_depth", 6)
|
||||
if not isinstance(depth, int) and "-" in depth:
|
||||
_, depth = depth.split("-")
|
||||
|
||||
# Initialize mapping and listing managers
|
||||
self.mappings = MappingManager(self.config)
|
||||
self.listings = ListingManager(self.config, int(depth))
|
||||
|
||||
# Initialize file filter - the file filter is used to include or exclude
|
||||
# entire subsections of the documentation, allowing for using multiple
|
||||
# instances of the plugin alongside each other. This can be necessary
|
||||
# when creating multiple, potentially conflicting listings.
|
||||
self.filter = FileFilter(self.config.filters)
|
||||
|
||||
# Ensure presence of attribute lists extension
|
||||
for extension in config.markdown_extensions:
|
||||
if isinstance(extension, str) and extension.endswith("attr_list"):
|
||||
break
|
||||
else:
|
||||
config.markdown_extensions.append("attr_list")
|
||||
|
||||
# If the author only wants to extract and export mappings, we allow to
|
||||
# disable the rendering of all tags and listings with a single setting
|
||||
if self.config.export_only:
|
||||
self.config.tags = False
|
||||
self.config.listings = False
|
||||
|
||||
@event_priority(-50)
|
||||
def on_page_markdown(
|
||||
self, markdown: str, *, page: Page, config: MkDocsConfig, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
Collect tags and listings from page.
|
||||
|
||||
Priority: -50 (run later)
|
||||
|
||||
Arguments:
|
||||
markdown: The page's Markdown.
|
||||
page: The page.
|
||||
config: The MkDocs configuration.
|
||||
|
||||
Returns:
|
||||
The page's Markdown with injection points.
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if page should not be considered
|
||||
if not self.filter(page.file):
|
||||
return
|
||||
|
||||
# Handle deprecation of `tags_file` setting
|
||||
if self.config.tags_file:
|
||||
markdown = self._handle_deprecated_tags_file(page, markdown)
|
||||
|
||||
# Handle deprecation of `tags_extra_files` setting
|
||||
if self.config.tags_extra_files:
|
||||
markdown = self._handle_deprecated_tags_extra_files(page, markdown)
|
||||
|
||||
# Collect tags from page
|
||||
try:
|
||||
self.mappings.add(page, markdown)
|
||||
|
||||
# Raise exception if tags could not be read
|
||||
except Exception as e:
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(page.file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Error reading tags of page '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Collect listings from page
|
||||
return self.listings.add(page, markdown)
|
||||
|
||||
@event_priority(100)
|
||||
def on_env(
|
||||
self, env: Environment, *, config: MkDocsConfig, **kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Populate listings.
|
||||
|
||||
Priority: 100 (run earliest)
|
||||
|
||||
Arguments:
|
||||
env: The Jinja environment.
|
||||
config: The MkDocs configuration.
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Populate and render all listings
|
||||
self.listings.populate_all(self.mappings, Renderer(env, config))
|
||||
|
||||
# Export mappings to file, if enabled
|
||||
if self.config.export:
|
||||
path = os.path.join(config.site_dir, self.config.export_file)
|
||||
path = os.path.normpath(path)
|
||||
|
||||
# Serialize mappings and save to file
|
||||
storage = MappingStorage(self.config)
|
||||
storage.save(path, self.mappings)
|
||||
|
||||
def on_page_context(
|
||||
self, context: TemplateContext, *, page: Page, **kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Add tag references to page context.
|
||||
|
||||
Arguments:
|
||||
context: The template context.
|
||||
page: The page.
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if page should not be considered
|
||||
if not self.filter(page.file):
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Retrieve tags references for page
|
||||
mapping = self.mappings.get(page)
|
||||
if mapping:
|
||||
tags = self.config.tags_name_variable
|
||||
if tags not in context:
|
||||
context[tags] = list(self.listings & mapping)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _handle_deprecated_tags_file(
|
||||
self, page: Page, markdown: str
|
||||
) -> str:
|
||||
"""
|
||||
Handle deprecation of `tags_file` setting.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
"""
|
||||
directive = self.config.listings_directive
|
||||
if page.file.src_uri != self.config.tags_file:
|
||||
return markdown
|
||||
|
||||
# Try to find the legacy tags marker and replace with directive
|
||||
if "[TAGS]" in markdown:
|
||||
markdown = markdown.replace("[TAGS]", "<!-- material/tags -->")
|
||||
if not "<!-- material/tags -->" in markdown:
|
||||
markdown += "\n<!-- material/tags -->"
|
||||
|
||||
# Replace placeholder in Markdown with rendered tags index
|
||||
return markdown.replace("<!-- material/tags -->", "\n".join([
|
||||
self._render_tag_links(*args)
|
||||
for args in sorted(self.tags.items())
|
||||
]))
|
||||
|
||||
# Render the given tag and links to all pages with occurrences
|
||||
def _render_tag_links(self, tag, pages):
|
||||
classes = ["md-tag"]
|
||||
if isinstance(self.tags_map, dict):
|
||||
classes.append("md-tag-icon")
|
||||
type = self.tags_map.get(tag)
|
||||
if type:
|
||||
classes.append(f"md-tag--{type}")
|
||||
|
||||
# Render section for tag and a link to each page
|
||||
classes = " ".join(classes)
|
||||
content = [f"## <span class=\"{classes}\">{tag}</span>", ""]
|
||||
for page in pages:
|
||||
url = utils.get_relative_url(
|
||||
page.file.src_uri,
|
||||
self.tags_file.src_uri
|
||||
markdown = markdown.replace(
|
||||
"[TAGS]", f"<!-- {directive} -->"
|
||||
)
|
||||
|
||||
# Render link to page
|
||||
title = page.meta.get("title", page.title)
|
||||
content.append(f"- [{title}]({url})")
|
||||
# Try to find the directive and add it if not present
|
||||
pattern = r"<!--\s+{directive}".format(directive = directive)
|
||||
if not re.search(pattern, markdown):
|
||||
markdown += f"\n<!-- {directive} -->"
|
||||
|
||||
# Return rendered tag links
|
||||
return "\n".join(content)
|
||||
# Return markdown
|
||||
return markdown
|
||||
|
||||
# Render the given tag, linking to the tags index (if enabled)
|
||||
def _render_tag(self, tag):
|
||||
type = self.tags_map.get(tag) if self.tags_map else None
|
||||
if not self.tags_file or not self.slugify:
|
||||
return dict(name = tag, type = type)
|
||||
else:
|
||||
url = f"{self.tags_file.url}#{self.slugify(tag)}"
|
||||
return dict(name = tag, type = type, url = url)
|
||||
def _handle_deprecated_tags_extra_files(
|
||||
self, page: Page, markdown: str
|
||||
) -> str:
|
||||
"""
|
||||
Handle deprecation of `tags_extra_files` setting.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
"""
|
||||
directive = self.config.listings_directive
|
||||
if page.file.src_uri not in self.config.tags_extra_files:
|
||||
return markdown
|
||||
|
||||
# Compute tags to render on page
|
||||
tags = self.config.tags_extra_files[page.file.src_uri]
|
||||
if tags:
|
||||
directive += f" {{ include: [{', '.join(tags)}] }}"
|
||||
|
||||
# Try to find the legacy tags marker and replace with directive
|
||||
if "[TAGS]" in markdown:
|
||||
markdown = markdown.replace(
|
||||
"[TAGS]", f"<!-- {directive} -->"
|
||||
)
|
||||
|
||||
# Try to find the directive and add it if not present
|
||||
pattern = r"<!--\s+{directive}".format(directive = re.escape(directive))
|
||||
if not re.search(pattern, markdown):
|
||||
markdown += f"\n<!-- {directive} -->"
|
||||
|
||||
# Return markdown
|
||||
return markdown
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.tags")
|
||||
log = logging.getLogger("mkdocs.material.plugins.tags")
|
||||
|
||||
99
material/plugins/tags/renderer/__init__.py
Normal file
99
material/plugins/tags/renderer/__init__.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
|
||||
from jinja2 import Environment
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.utils import get_relative_url
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class Renderer:
|
||||
"""
|
||||
A renderer for tags and listings.
|
||||
|
||||
This class implements a simple tag and listing renderer, leveraging the
|
||||
Jinja environment and the MkDocs configuration as provided to plugins.
|
||||
|
||||
Note that the templates must be stored in the `fragments` and not `partials`
|
||||
directory, because in order to render tags and listings, we must wait for
|
||||
all pages to be read and processed, as we first need to collect all tags
|
||||
before we can render listings. Tags induce a graph, not a tree.
|
||||
|
||||
For this reason, we consider the templates to be fragments, as they are
|
||||
not implicitly rendered by MkDocs, but explicitly by the plugin.
|
||||
"""
|
||||
|
||||
def __init__(self, env: Environment, config: MkDocsConfig):
|
||||
"""
|
||||
Initialize renderer.
|
||||
|
||||
Arguments:
|
||||
env: The Jinja environment.
|
||||
config: The MkDocs configuration.
|
||||
"""
|
||||
self.env = env
|
||||
self.config = config
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
env: Environment
|
||||
"""
|
||||
The Jinja environment.
|
||||
"""
|
||||
|
||||
config: MkDocsConfig
|
||||
"""
|
||||
The MkDocs configuration.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def render(self, page: Page, name: str, **kwargs) -> str:
|
||||
"""
|
||||
Render a template.
|
||||
|
||||
Templates are resolved from `fragments/tags`, so if you want to override
|
||||
templates or provide additional ones place them in this directory.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
name: The name of the template.
|
||||
kwargs: The template variables.
|
||||
|
||||
Returns:
|
||||
The rendered template.
|
||||
"""
|
||||
path = posixpath.join("fragments", "tags", name)
|
||||
path = posixpath.normpath(path)
|
||||
|
||||
# Resolve and render template
|
||||
template = self.env.get_template(path)
|
||||
return template.render(
|
||||
config = self.config, page = page,
|
||||
base_url = get_relative_url(".", page.url),
|
||||
**kwargs
|
||||
)
|
||||
19
material/plugins/tags/structure/__init__.py
Normal file
19
material/plugins/tags/structure/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
218
material/plugins/tags/structure/listing/__init__.py
Normal file
218
material/plugins/tags/structure/listing/__init__.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
|
||||
from collections.abc import Iterator
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
from .config import ListingConfig
|
||||
from .tree import ListingTree
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class Listing:
|
||||
"""
|
||||
A listing of tags.
|
||||
|
||||
Listings can be included on a page with the `<!-- material/tags [args] -->`
|
||||
directive. The arguments are passed to a YAML parser, and are expected to
|
||||
either be an inline listing configuration, or an identifier that points to
|
||||
a listing configuration in the `listings_map` setting in `mkdocs.yml`.
|
||||
|
||||
Example 1: use inline listing configuration
|
||||
|
||||
``` markdown
|
||||
<!-- material/tags { include: [foo, bar] } -->
|
||||
```
|
||||
|
||||
Example 2: use listing configuration from `listings_map.qux`
|
||||
|
||||
``` markdown
|
||||
<!-- material/tags qux -->
|
||||
```
|
||||
|
||||
If no arguments are given, the default listing configuration is used,
|
||||
rendering all tags and mappings. In case you're using multiple instances
|
||||
of the tags plugin, you can use the `listings_directive` setting to change
|
||||
the directive name.
|
||||
"""
|
||||
|
||||
def __init__(self, page: Page, id: str, config: ListingConfig):
|
||||
"""
|
||||
Initialize the listing.
|
||||
|
||||
Arguments:
|
||||
page: The page the listing is embedded in.
|
||||
id: The listing identifier.
|
||||
config: The listing configuration.
|
||||
"""
|
||||
self.page = page
|
||||
self.id = id
|
||||
self.config = config
|
||||
self.tags = {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the listing.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return f"Listing({repr(self.page)})"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Return the hash of the listing.
|
||||
|
||||
Returns:
|
||||
The hash.
|
||||
"""
|
||||
return hash(self.id)
|
||||
|
||||
def __iter__(self) -> Iterator[ListingTree]:
|
||||
"""
|
||||
Iterate over the listing in pre-order.
|
||||
|
||||
Yields:
|
||||
The current listing tree.
|
||||
"""
|
||||
stack = list(reversed(self.tags.values()))
|
||||
while stack:
|
||||
tree = stack.pop()
|
||||
yield tree
|
||||
|
||||
# Visit subtrees in reverse, so pre-order is preserved
|
||||
stack += reversed([*tree])
|
||||
|
||||
def __and__(self, mapping: Mapping) -> Iterator[Tag]:
|
||||
"""
|
||||
Iterate over the tags of a mapping featured in the listing.
|
||||
|
||||
When hierarchical tags are used, the set of tags is expanded to include
|
||||
all parent tags, but only for the inclusion check. The returned tags are
|
||||
always the actual tags (= leaves) of the mapping. This is done to avoid
|
||||
duplicate entries in the listing.
|
||||
|
||||
If a mapping features one of the tags excluded from the listing, the
|
||||
entire mapping is excluded from the listing. Additionally, if a listing
|
||||
should only include tags within the current scope, the mapping is only
|
||||
included if the page is a child of the page the listing was found on.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
|
||||
Yields:
|
||||
The current tag.
|
||||
"""
|
||||
assert isinstance(mapping, Mapping)
|
||||
|
||||
# If the mapping is on the same page as the listing, we skip it, as
|
||||
# it makes no sense to link to a listing on the same page
|
||||
if mapping.item == self.page:
|
||||
return iter([])
|
||||
|
||||
# If the listing should only include tags within the current scope, we
|
||||
# check if the page is a child of the page the listing is embedded in
|
||||
if self.config.scope:
|
||||
assert isinstance(mapping.item, Page)
|
||||
# Note that we must use `File.src_uri` here, or we won't be able to
|
||||
# detect the difference between pages, and index pages in sections,
|
||||
# as `foo/index.md` and `foo.md` look the same when using `Page.url`
|
||||
base = posixpath.dirname(posixpath.normpath(self.page.file.src_uri))
|
||||
if not mapping.item.file.src_uri.startswith(posixpath.join(base, "")):
|
||||
return iter([])
|
||||
|
||||
# If an exclusion list is given, expand each tag to check if the tag
|
||||
# itself or one of its parents is excluded from the listing
|
||||
if self.config.exclude:
|
||||
if any(mapping & self.config.exclude):
|
||||
return iter([])
|
||||
|
||||
# If an inclusion list is given, expand each tag to check if the tag
|
||||
# itself or one of its parents is included in the listing
|
||||
if self.config.include:
|
||||
return mapping & self.config.include
|
||||
|
||||
# Otherwise, we can just return an iterator over the set of tags of the
|
||||
# mapping as is, as no expansion is required
|
||||
return iter(mapping.tags)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
page: Page
|
||||
"""
|
||||
The page the listing is embedded in.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""
|
||||
The listing identifier.
|
||||
|
||||
As authors may place an arbitrary number of listings on any page, we need
|
||||
to be able to distinguish between them. This is done automatically.
|
||||
"""
|
||||
|
||||
config: ListingConfig
|
||||
"""
|
||||
The listing configuration.
|
||||
"""
|
||||
|
||||
tags: dict[Tag, ListingTree]
|
||||
"""
|
||||
The listing trees, each of which associated with a tag.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def add(self, mapping: Mapping, *, hidden = True) -> None:
|
||||
"""
|
||||
Add mapping to listing.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
hidden: Whether to add hidden tags.
|
||||
"""
|
||||
for leaf in self & mapping:
|
||||
tree = self.tags
|
||||
|
||||
# Skip if hidden tags should not be rendered
|
||||
if not hidden and leaf.hidden:
|
||||
continue
|
||||
|
||||
# Iterate over expanded tags
|
||||
for tag in reversed([*leaf]):
|
||||
if tag not in tree:
|
||||
tree[tag] = ListingTree(tag)
|
||||
|
||||
# If the tag is the leaf, i.e., the actual tag we want to add,
|
||||
# we add the mapping to the listing tree's mappings
|
||||
if tag == leaf:
|
||||
tree[tag].mappings.append(mapping)
|
||||
|
||||
# Otherwise, we continue walking down the tree
|
||||
else:
|
||||
tree = tree[tag].children
|
||||
99
material/plugins/tags/structure/listing/config.py
Normal file
99
material/plugins/tags/structure/listing/config.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import yaml
|
||||
|
||||
from material.plugins.tags.structure.tag.options import TagSet
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import Optional, Type
|
||||
from yaml import Dumper
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class ListingConfig(Config):
|
||||
"""
|
||||
A listing configuration.
|
||||
"""
|
||||
|
||||
scope = Type(bool, default = False)
|
||||
"""
|
||||
Whether to only include pages in the current subsection.
|
||||
|
||||
Enabling this setting will only include pages that are on the same level or
|
||||
on a lower level than the page the listing is on. This allows to create a
|
||||
listing of tags on a page that only includes pages that are in the same
|
||||
subsection of the documentation.
|
||||
"""
|
||||
|
||||
layout = Optional(Type(str))
|
||||
"""
|
||||
The layout to use for rendering the listing.
|
||||
|
||||
This setting allows to override the global setting for the layout. If this
|
||||
setting is not specified, the global `listings_layout` setting is used.
|
||||
"""
|
||||
|
||||
include = TagSet()
|
||||
"""
|
||||
Tags to include in the listing.
|
||||
|
||||
If this set is empty, the listing does not filter pages by tags. Otherwise,
|
||||
all pages that have at least one of the tags in this set will be included.
|
||||
"""
|
||||
|
||||
exclude = TagSet()
|
||||
"""
|
||||
Tags to exclude from the listing.
|
||||
|
||||
If this set is empty, the listing does not filter pages by tags. Otherwise,
|
||||
all pages that have at least one of the tags in this set will be excluded.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _representer(dumper: Dumper, config: ListingConfig):
|
||||
"""
|
||||
Return a serializable representation of a listing configuration.
|
||||
|
||||
Arguments:
|
||||
dumper: The YAML dumper.
|
||||
config: The listing configuration.
|
||||
|
||||
Returns:
|
||||
Serializable representation.
|
||||
"""
|
||||
copy = config.copy()
|
||||
|
||||
# Convert the include and exclude tag sets to lists of strings
|
||||
copy.include = list(map(str, copy.include)) if copy.include else None
|
||||
copy.exclude = list(map(str, copy.exclude)) if copy.exclude else None
|
||||
|
||||
# Return serializable listing configuration
|
||||
data = { k: v for k, v in copy.items() if v is not None }
|
||||
return dumper.represent_dict(data)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Register listing configuration YAML representer
|
||||
yaml.add_representer(ListingConfig, _representer)
|
||||
497
material/plugins/tags/structure/listing/manager/__init__.py
Normal file
497
material/plugins/tags/structure/listing/manager/__init__.py
Normal file
@@ -0,0 +1,497 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import yaml
|
||||
|
||||
from collections.abc import Iterable, Iterator
|
||||
from material.plugins.tags.config import TagsConfig
|
||||
from material.plugins.tags.renderer import Renderer
|
||||
from material.plugins.tags.structure.listing import Listing, ListingConfig
|
||||
from material.plugins.tags.structure.listing.tree import ListingTree
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from material.plugins.tags.structure.tag.reference import TagReference
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.structure.nav import Link
|
||||
from re import Match
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import toc
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class ListingManager:
|
||||
"""
|
||||
A listing manager.
|
||||
|
||||
The listing manager collects all listings from the Markdown of pages, then
|
||||
populates them with mappings, and renders them. Furthermore, the listing
|
||||
manager allows to obtain tag references for a given mapping, which are
|
||||
tags annotated with links to listings.
|
||||
"""
|
||||
|
||||
def __init__(self, config: TagsConfig, depth: int = 6):
|
||||
"""
|
||||
Initialize the listing manager.
|
||||
|
||||
Arguments:
|
||||
config: The configuration.
|
||||
"""
|
||||
self.config = config
|
||||
self.data = set()
|
||||
self.depth = depth
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the listing manager.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return _print(self)
|
||||
|
||||
def __iter__(self) -> Iterator[Listing]:
|
||||
"""
|
||||
Iterate over listings.
|
||||
|
||||
Yields:
|
||||
The current listing.
|
||||
"""
|
||||
return iter(self.data)
|
||||
|
||||
def __and__(self, mapping: Mapping) -> Iterator[TagReference]:
|
||||
"""
|
||||
Iterate over the tag references for the mapping.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
|
||||
Yields:
|
||||
The current tag reference.
|
||||
"""
|
||||
assert isinstance(mapping, Mapping)
|
||||
|
||||
# Iterate over sorted tags and associate tags with listings - note that
|
||||
# we sort the listings for the mapping by closeness, so that the first
|
||||
# listing in the list is the closest one to the page or link the
|
||||
# mapping is associated with
|
||||
listings = self.closest(mapping)
|
||||
for tag in self._sort_tags(mapping.tags):
|
||||
ref = TagReference(tag)
|
||||
|
||||
# Iterate over listings and add links
|
||||
for listing in listings:
|
||||
if tag in listing & mapping:
|
||||
value = listing.page.url or "."
|
||||
|
||||
# Compute URL for link - make sure to remove fragments, as
|
||||
# they may be present in links extracted from remote tags.
|
||||
# Additionally, we need to fallback to `.` if the URL is
|
||||
# empty (= homepage) or the links will be incorrect.
|
||||
url = urlparse(value, allow_fragments = False)
|
||||
url = url._replace(fragment = self._slugify(tag))
|
||||
|
||||
# Add listing link to tag reference
|
||||
ref.links.append(
|
||||
Link(listing.page.title, url.geturl())
|
||||
)
|
||||
|
||||
# Yield tag reference
|
||||
yield ref
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
config: TagsConfig
|
||||
"""
|
||||
The configuration.
|
||||
"""
|
||||
|
||||
data: set[Listing]
|
||||
"""
|
||||
The listings.
|
||||
"""
|
||||
|
||||
depth: int
|
||||
"""
|
||||
Table of contents maximum depth.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def add(self, page: Page, markdown: str) -> str:
|
||||
"""
|
||||
Add page.
|
||||
|
||||
This method is called by the tags plugin to retrieve all listings of a
|
||||
page. It will parse the page's Markdown and add injections points into
|
||||
the page's Markdown, which will be replaced by the renderer with the
|
||||
actual listing later on.
|
||||
|
||||
Note that this method is intended to be called with the page during the
|
||||
`on_page_markdown` event, as it modifies the page's Markdown. Moreover,
|
||||
the Markdown must be explicitly passed, as we could otherwise run into
|
||||
inconsistencies when other plugins modify the Markdown.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
markdown: The page's Markdown.
|
||||
|
||||
Returns:
|
||||
The page's Markdown with injection points.
|
||||
"""
|
||||
assert isinstance(markdown, str)
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match) -> str:
|
||||
config = self._resolve(page, match.group(2))
|
||||
|
||||
# Compute listing identifier - as the author might include multiple
|
||||
# listings on a single page, we must make sure that the identifier
|
||||
# is unique, so we use the page source file path and the position
|
||||
# of the match within the page as an identifier.
|
||||
id = f"{page.file.src_uri}:{match.start()}-{match.end()}"
|
||||
self.data.add(Listing(page, id, config))
|
||||
|
||||
# Replace directive with hx headline if listings are enabled, or
|
||||
# remove the listing entirely from the page and table of contents
|
||||
if self.config.listings:
|
||||
return "#" * self.depth + f" {id}/name {{ #{id}/slug }}"
|
||||
else:
|
||||
return
|
||||
|
||||
# Hack: replace directive with an hx headline to mark the injection
|
||||
# point for the anchor links we will generate after parsing all pages.
|
||||
# By using an hx headline, we can make sure that the injection point
|
||||
# will always be a child of the preceding headline.
|
||||
directive = self.config.listings_directive
|
||||
return re.sub(
|
||||
r"(<!--\s*?{directive}(.*?)\s*-->)".format(directive = directive),
|
||||
replace, markdown, flags = re.I | re.M | re.S
|
||||
)
|
||||
|
||||
def closest(self, mapping: Mapping) -> list[Listing]:
|
||||
"""
|
||||
Get listings for the mapping ordered by closeness.
|
||||
|
||||
Listings are sorted by closeness to the given page, i.e. the number of
|
||||
common path components. This is useful for hierarchical listings, where
|
||||
the tags of a page point to the closest listing featuring the tag, with
|
||||
the option to show all listings featuring that tag.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
|
||||
Returns:
|
||||
The listings.
|
||||
"""
|
||||
|
||||
# Retrieve listings featuring tags of mapping
|
||||
listings: list[Listing] = []
|
||||
for listing in self.data:
|
||||
if any(listing & mapping):
|
||||
listings.append(listing)
|
||||
|
||||
# Ranking callback
|
||||
def rank(listing: Listing) -> int:
|
||||
path = posixpath.commonpath([mapping.item.url, listing.page.url])
|
||||
return len(path)
|
||||
|
||||
# Return listings ordered by closeness to mapping
|
||||
return sorted(listings, key = rank, reverse = True)
|
||||
|
||||
def populate(
|
||||
self, listing: Listing, mappings: Iterable[Mapping], renderer: Renderer
|
||||
) -> None:
|
||||
"""
|
||||
Populate listing with tags featured in the mappings.
|
||||
|
||||
Arguments:
|
||||
listing: The listing.
|
||||
mappings: The mappings.
|
||||
renderer: The renderer.
|
||||
"""
|
||||
page = listing.page
|
||||
assert isinstance(page.content, str)
|
||||
|
||||
# Add mappings to listing
|
||||
for mapping in mappings:
|
||||
listing.add(mapping)
|
||||
|
||||
# Sort listings and tags - we can only do this after all mappings have
|
||||
# been added to the listing, because the tags inside the mappings do
|
||||
# not have a proper order yet, and we need to order them as specified
|
||||
# in the listing configuration.
|
||||
listing.tags = self._sort_listing_tags(listing.tags)
|
||||
|
||||
# Render tags for listing headlines - the listing configuration allows
|
||||
# tp specify a custom layout, so we resolve the template for tags here
|
||||
name = posixpath.join(listing.config.layout, "tag.html")
|
||||
for tree in listing:
|
||||
tree.content = renderer.render(page, name, tag = tree.tag)
|
||||
|
||||
# Sort mappings and subtrees of listing tree
|
||||
tree.mappings = self._sort_listing(tree.mappings)
|
||||
tree.children = self._sort_listing_tags(tree.children)
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match) -> str:
|
||||
hx = match.group()
|
||||
|
||||
# Populate listing with anchor links to tags
|
||||
anchors = toc.populate(listing, self._slugify)
|
||||
if not anchors:
|
||||
return
|
||||
|
||||
# Get reference to first tag in listing
|
||||
head = next(iter(anchors.values()))
|
||||
|
||||
# Replace hx with actual level of listing and listing ids with
|
||||
# placeholders to create a format string for the headline
|
||||
hx = re.sub(
|
||||
r"<(/?)h{}\b".format(self.depth),
|
||||
r"<\g<1>h{}".format(head.level), hx
|
||||
)
|
||||
hx = re.sub(
|
||||
r"{id}\/(\w+)".format(id = listing.id),
|
||||
r"{\1}", hx, flags = re.I | re.M
|
||||
)
|
||||
|
||||
# Render listing headlines
|
||||
for tree in listing:
|
||||
tree.content = hx.format(
|
||||
slug = anchors[tree.tag].id,
|
||||
name = tree.content
|
||||
)
|
||||
|
||||
# Render listing - the listing configuration allows to specify a
|
||||
# custom layout, so we resolve the template for listings here
|
||||
name = posixpath.join(listing.config.layout, "listing.html")
|
||||
return "\n".join([
|
||||
renderer.render(page, name, listing = tree)
|
||||
for tree in listing.tags.values()
|
||||
])
|
||||
|
||||
# Hack: replace hx headlines (injection points) we added when parsing
|
||||
# the page's Markdown with the actual listing content. Additionally,
|
||||
# replace anchor links in the table of contents with the hierarchy
|
||||
# generated from mapping over the listing, or remove them.
|
||||
page.content = re.sub(
|
||||
r"<h{x}[^>]+{id}.*?</h{x}>".format(
|
||||
id = f"{listing.id}/slug", x = self.depth
|
||||
),
|
||||
replace, page.content, flags = re.I | re.M
|
||||
)
|
||||
|
||||
def populate_all(
|
||||
self, mappings: Iterable[Mapping], renderer: Renderer
|
||||
) -> None:
|
||||
"""
|
||||
Populate all listings with tags featured in the mappings.
|
||||
|
||||
This method is called by the tags plugin to populate all listings with
|
||||
the given mappings. It will also remove the injection points from the
|
||||
page's Markdown. Note that this method is intended to be called during
|
||||
the `on_env` event, after all pages have been rendered.
|
||||
|
||||
Arguments:
|
||||
mappings: The mappings.
|
||||
renderer: The renderer.
|
||||
"""
|
||||
for listing in self.data:
|
||||
self.populate(listing, mappings, renderer)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _resolve(self, page: Page, args: str) -> ListingConfig:
|
||||
"""
|
||||
Resolve listing configuration.
|
||||
|
||||
Arguments:
|
||||
page: The page the listing in embedded in.
|
||||
args: The arguments, as parsed from Markdown.
|
||||
|
||||
Returns:
|
||||
The listing configuration.
|
||||
"""
|
||||
data = yaml.safe_load(args)
|
||||
path = page.file.abs_src_path
|
||||
|
||||
# Try to resolve available listing configuration
|
||||
if isinstance(data, str):
|
||||
config = self.config.listings_map.get(data, None)
|
||||
if not config:
|
||||
keys = ", ".join(self.config.listings_map.keys())
|
||||
raise PluginError(
|
||||
f"Couldn't find listing configuration: {data}. Available "
|
||||
f"configurations: {keys}"
|
||||
)
|
||||
|
||||
# Otherwise, handle inline listing configuration
|
||||
else:
|
||||
config = ListingConfig(config_file_path = path)
|
||||
config.load_dict(data or {})
|
||||
|
||||
# Validate listing configuration
|
||||
errors, warnings = config.validate()
|
||||
for _, w in warnings:
|
||||
path = os.path.relpath(path)
|
||||
log.warning(
|
||||
f"Error reading listing configuration in '{path}':\n"
|
||||
f"{w}"
|
||||
)
|
||||
for _, e in errors:
|
||||
path = os.path.relpath(path)
|
||||
raise PluginError(
|
||||
f"Error reading listing configuration in '{path}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Inherit layout configuration, unless explicitly set
|
||||
if not isinstance(config.layout, str):
|
||||
config.layout = self.config.listings_layout
|
||||
|
||||
# Return listing configuration
|
||||
return config
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _slugify(self, tag: Tag) -> str:
|
||||
"""
|
||||
Slugify tag.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
|
||||
Returns:
|
||||
The slug.
|
||||
"""
|
||||
return self.config.tags_slugify_format.format(
|
||||
slug = self.config.tags_slugify(
|
||||
tag.name,
|
||||
self.config.tags_slugify_separator
|
||||
)
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _sort_listing(
|
||||
self, mappings: Iterable[Mapping]
|
||||
) -> list[Mapping]:
|
||||
"""
|
||||
Sort listing.
|
||||
|
||||
When sorting a listing, we sort the mappings of the listing, which is
|
||||
why the caller must pass the mappings of the listing. That way, we can
|
||||
keep this implementation to be purely functional, without having to
|
||||
mutate the listing, which makes testing simpler.
|
||||
|
||||
Arguments:
|
||||
mappings: The mappings.
|
||||
|
||||
Returns:
|
||||
The sorted mappings.
|
||||
"""
|
||||
return sorted(
|
||||
mappings,
|
||||
key = self.config.listings_sort_by,
|
||||
reverse = self.config.listings_sort_reverse
|
||||
)
|
||||
|
||||
def _sort_listing_tags(
|
||||
self, children: dict[Tag, ListingTree]
|
||||
) -> dict[Tag, ListingTree]:
|
||||
"""
|
||||
Sort listing tags.
|
||||
|
||||
When sorting a listing's tags, we sort the immediate subtrees of the
|
||||
listing, which is why the caller must pass the children of the listing.
|
||||
That way, we can keep this implementation to be purely functional,
|
||||
without having to mutate the listing.
|
||||
|
||||
Arguments:
|
||||
children: The listing trees, each of which associated with a tag.
|
||||
|
||||
Returns:
|
||||
The sorted listing trees.
|
||||
"""
|
||||
return dict(sorted(
|
||||
children.items(),
|
||||
key = lambda item: self.config.listings_tags_sort_by(*item),
|
||||
reverse = self.config.listings_tags_sort_reverse
|
||||
))
|
||||
|
||||
def _sort_tags(
|
||||
self, tags: Iterable[Tag]
|
||||
) -> list[Tag]:
|
||||
"""
|
||||
Sort tags.
|
||||
|
||||
Arguments:
|
||||
tags: The tags.
|
||||
|
||||
Returns:
|
||||
The sorted tags.
|
||||
"""
|
||||
return sorted(
|
||||
tags,
|
||||
key = self.config.tags_sort_by,
|
||||
reverse = self.config.tags_sort_reverse
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _print(manager: ListingManager, indent: int = 0) -> str:
|
||||
"""
|
||||
Return a printable representation of a listing manager.
|
||||
|
||||
Arguments:
|
||||
manager: The listing manager.
|
||||
indent: The indentation level.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append(" " * indent + f"ListingManager()")
|
||||
|
||||
# Print listings
|
||||
for listing in manager:
|
||||
lines.append(" " * (indent + 2) + repr(listing))
|
||||
|
||||
# Concatenate everything
|
||||
return "\n".join(lines)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.plugins.tags")
|
||||
134
material/plugins/tags/structure/listing/manager/toc.py
Normal file
134
material/plugins/tags/structure/listing/manager/toc.py
Normal file
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from material.plugins.tags.structure.listing import Listing
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.structure.toc import AnchorLink
|
||||
from typing import Callable
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Typings
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
Slugify = Callable[[Tag], str]
|
||||
"""
|
||||
Slugify function.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
|
||||
Returns:
|
||||
The slugified tag.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def populate(listing: Listing, slugify: Slugify) -> dict[Tag, AnchorLink]:
|
||||
"""
|
||||
Populate the table of contents of the page the listing is embedded.
|
||||
|
||||
Arguments:
|
||||
listing: The listing.
|
||||
slugify: Slugify function.
|
||||
|
||||
Returns:
|
||||
The mapping of tags to anchor links.
|
||||
"""
|
||||
anchors: dict[Tag, AnchorLink] = {}
|
||||
|
||||
# Find injection point for listing
|
||||
host, at = find(listing)
|
||||
if at == -1:
|
||||
return anchors
|
||||
|
||||
# Create anchor links
|
||||
for tree in listing:
|
||||
|
||||
# Iterate over expanded tags
|
||||
for i, tag in enumerate(reversed([*tree.tag])):
|
||||
if tag not in anchors:
|
||||
level = host.level + 1 + i
|
||||
|
||||
# Create anchor link
|
||||
anchors[tag] = AnchorLink(tag.name, slugify(tag), level)
|
||||
if not tag.parent:
|
||||
continue
|
||||
|
||||
# Relate anchor link to parent
|
||||
anchors[tag.parent].children.append(anchors[tag])
|
||||
|
||||
# Filter top-level anchor links and insert them into the page
|
||||
children = [anchors[tag] for tag in anchors if not tag.parent]
|
||||
if listing.config.toc:
|
||||
host.children[at:at + 1] = children
|
||||
else:
|
||||
host.children.pop(at)
|
||||
|
||||
# Return mapping of tags to anchor links
|
||||
return anchors
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def find(listing: Listing) -> tuple[AnchorLink | None, int]:
|
||||
"""
|
||||
Find anchor link for the given listing.
|
||||
|
||||
This function traverses the table of contents of the given page and returns
|
||||
the anchor's parent and index of the anchor with the given identifier. If
|
||||
the anchor is on the root level, and the anchor we're looking for is an
|
||||
injection point, an anchor to host the tags is created and returned.
|
||||
|
||||
Arguments:
|
||||
lising: The listing.
|
||||
|
||||
Returns:
|
||||
The anchor and index.
|
||||
"""
|
||||
page = listing.page
|
||||
|
||||
# Traverse table of contents
|
||||
stack = list(page.toc)
|
||||
while stack:
|
||||
anchor = stack.pop()
|
||||
|
||||
# Traverse children
|
||||
for i, child in enumerate(anchor.children):
|
||||
if child.id.startswith(listing.id):
|
||||
return anchor, i
|
||||
|
||||
# Add child to stack
|
||||
stack.append(child)
|
||||
|
||||
# Check if anchor is on the root level
|
||||
for i, anchor in enumerate(page.toc):
|
||||
if anchor.id.startswith(listing.id):
|
||||
|
||||
# Create anchor link
|
||||
host = AnchorLink(page.title, page.url, 1)
|
||||
host.children = page.toc.items
|
||||
return host, i
|
||||
|
||||
# Anchor could not be found
|
||||
return None, -1
|
||||
160
material/plugins/tags/structure/listing/tree/__init__.py
Normal file
160
material/plugins/tags/structure/listing/tree/__init__.py
Normal file
@@ -0,0 +1,160 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from functools import total_ordering
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@total_ordering
|
||||
class ListingTree:
|
||||
"""
|
||||
A listing tree.
|
||||
|
||||
This is an internal data structure that is used to render listings. It is
|
||||
also the immediate structure that is passed to the template.
|
||||
"""
|
||||
|
||||
def __init__(self, tag: Tag):
|
||||
"""
|
||||
Initialize the listing tree.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
"""
|
||||
self.tag = tag
|
||||
self.content = None
|
||||
self.mappings = []
|
||||
self.children = {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the listing tree.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return _print(self)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Return the hash of the listing tree.
|
||||
|
||||
Returns:
|
||||
The hash.
|
||||
"""
|
||||
return hash(self.tag)
|
||||
|
||||
def __iter__(self) -> Iterator[ListingTree]:
|
||||
"""
|
||||
Iterate over subtrees of the listing tree.
|
||||
|
||||
Yields:
|
||||
The current subtree.
|
||||
"""
|
||||
return iter(self.children.values())
|
||||
|
||||
def __eq__(self, other: ListingTree) -> bool:
|
||||
"""
|
||||
Check if the listing tree is equal to another listing tree.
|
||||
|
||||
Arguments:
|
||||
other: The other listing tree to check.
|
||||
|
||||
Returns:
|
||||
Whether the listing trees are equal.
|
||||
"""
|
||||
assert isinstance(other, ListingTree)
|
||||
return self.tag == other.tag
|
||||
|
||||
def __lt__(self, other: ListingTree) -> bool:
|
||||
"""
|
||||
Check if the listing tree is less than another listing tree.
|
||||
|
||||
Arguments:
|
||||
other: The other listing tree to check.
|
||||
|
||||
Returns:
|
||||
Whether the listing tree is less than the other listing tree.
|
||||
"""
|
||||
assert isinstance(other, ListingTree)
|
||||
return self.tag < other.tag
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
tag: Tag
|
||||
"""
|
||||
The tag.
|
||||
"""
|
||||
|
||||
content: str | None
|
||||
"""
|
||||
The rendered content of the listing tree.
|
||||
|
||||
This attribute holds the result of rendering the `tag.html` template, which
|
||||
is the rendered tag as displayed in the listing. It is essential that this
|
||||
is done for all tags (and nested tags) before rendering the tree, as the
|
||||
rendering process of the listing tree relies on this attribute.
|
||||
"""
|
||||
|
||||
mappings: list[Mapping]
|
||||
"""
|
||||
The mappings associated with the tag.
|
||||
"""
|
||||
|
||||
children: dict[Tag, ListingTree]
|
||||
"""
|
||||
The subtrees of the listing tree.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _print(tree: ListingTree, indent: int = 0) -> str:
|
||||
"""
|
||||
Return a printable representation of a listing tree.
|
||||
|
||||
Arguments:
|
||||
tree: The listing tree.
|
||||
indent: The indentation level.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append(" " * indent + f"ListingTree({repr(tree.tag)})")
|
||||
|
||||
# Print mappings
|
||||
for mapping in tree.mappings:
|
||||
lines.append(" " * (indent + 2) + repr(mapping))
|
||||
|
||||
# Print subtrees
|
||||
for child in tree.children.values():
|
||||
lines.append(_print(child, indent + 2))
|
||||
|
||||
# Concatenate everything
|
||||
return "\n".join(lines)
|
||||
98
material/plugins/tags/structure/mapping/__init__.py
Normal file
98
material/plugins/tags/structure/mapping/__init__.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from mkdocs.structure.nav import Link
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class Mapping:
|
||||
"""
|
||||
A mapping between a page or link and a set of tags.
|
||||
|
||||
We use this class to store the mapping between a page or link and a set of
|
||||
tags. This is necessary as we don't want to store the tags directly on the
|
||||
page or link object, in order not to clutter the internal data structures
|
||||
of MkDocs, keeping the plugin as unobtrusive as possible.
|
||||
|
||||
Links are primarily used when integrating with tags from external projects,
|
||||
as we can't construct a page object for them as we do for local files.
|
||||
"""
|
||||
|
||||
def __init__(self, item: Page | Link, *, tags: Iterable[Tag] | None = None):
|
||||
"""
|
||||
Initialize the mapping.
|
||||
|
||||
Tags can be passed upon initialization, but can also be added later on
|
||||
using the `add` or `update` method. of the `tags` attribute.
|
||||
|
||||
Arguments:
|
||||
item: The page or link.
|
||||
tags: The tags associated with the page or link.
|
||||
"""
|
||||
self.item = item
|
||||
self.tags = set(tags or [])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the mapping.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return f"Mapping({repr(self.item)}, tags={self.tags})"
|
||||
|
||||
def __and__(self, tags: set[Tag]) -> Iterator[Tag]:
|
||||
"""
|
||||
Iterate over the tags featured in the mapping.
|
||||
|
||||
This method expands each tag in the mapping and checks whether it is
|
||||
equal to one of the tags in the given set. If so, the tag is yielded.
|
||||
|
||||
Arguments:
|
||||
tags: The set of tags.
|
||||
|
||||
Yields:
|
||||
The current tag.
|
||||
"""
|
||||
assert isinstance(tags, set)
|
||||
|
||||
# Iterate over expanded tags
|
||||
for tag in self.tags:
|
||||
if set(tag) & tags:
|
||||
yield tag
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
item: Page | Link
|
||||
"""
|
||||
The page or link.
|
||||
"""
|
||||
|
||||
tags: set[Tag]
|
||||
"""
|
||||
The tags associated with the page or link.
|
||||
"""
|
||||
169
material/plugins/tags/structure/mapping/manager/__init__.py
Normal file
169
material/plugins/tags/structure/mapping/manager/__init__.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from material.plugins.tags.config import TagsConfig
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag.options import TagSet
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class MappingManager:
|
||||
"""
|
||||
A mapping manager.
|
||||
|
||||
The mapping manager is responsible for collecting all tags from the front
|
||||
matter of pages, and for building a tag structure from them, nothing more.
|
||||
"""
|
||||
|
||||
def __init__(self, config: TagsConfig):
|
||||
"""
|
||||
Initialize the mapping manager.
|
||||
|
||||
Arguments:
|
||||
config: The configuration.
|
||||
"""
|
||||
self.config = config
|
||||
self.format = TagSet(allowed = self.config.tags_allowed)
|
||||
self.data = {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the mapping manager.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return _print(self)
|
||||
|
||||
def __iter__(self) -> Iterator[Mapping]:
|
||||
"""
|
||||
Iterate over mappings.
|
||||
|
||||
Yields:
|
||||
The current mapping.
|
||||
"""
|
||||
return iter(self.data.values())
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
config: TagsConfig
|
||||
"""
|
||||
The configuration.
|
||||
"""
|
||||
|
||||
format: TagSet
|
||||
"""
|
||||
The mapping format.
|
||||
|
||||
This is the validator that is used to check if tags are valid, including
|
||||
the tags in the front matter of pages, as well as the tags defined in the
|
||||
configuration. Numbers and booleans are always converted to strings before
|
||||
creating tags, and the allow list is checked as well, if given.
|
||||
"""
|
||||
|
||||
data: dict[str, Mapping]
|
||||
"""
|
||||
The mappings.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def add(self, page: Page, markdown: str) -> Mapping | None:
|
||||
"""
|
||||
Add page.
|
||||
|
||||
This method is called by the tags plugin to retrieve all tags of a page.
|
||||
It extracts all tags from the front matter of the given page, and adds
|
||||
them to the mapping. If no tags are found, no mapping is created and
|
||||
nothing is returned.
|
||||
|
||||
Note that this method is intended to be called with the page during the
|
||||
`on_page_markdown` event, as it reads the front matter of a page. Also,
|
||||
the Markdown must be explicitly passed, as we could otherwise run into
|
||||
inconsistencies when other plugins modify the Markdown.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
markdown: The page's Markdown.
|
||||
|
||||
Returns:
|
||||
The mapping or nothing.
|
||||
"""
|
||||
assert isinstance(markdown, str)
|
||||
|
||||
# Return nothing if page doesn't have tags
|
||||
tags = self.config.tags_name_property
|
||||
if not page.meta.get(tags, []):
|
||||
return
|
||||
|
||||
# Create mapping and associate with page
|
||||
mapping = Mapping(page)
|
||||
self.data[page.url] = mapping
|
||||
|
||||
# Retrieve and validate tags, and add to mapping
|
||||
for tag in self.format.validate(page.meta[tags]):
|
||||
mapping.tags.add(tag)
|
||||
|
||||
# Return mapping
|
||||
return mapping
|
||||
|
||||
def get(self, page: Page) -> Mapping | None:
|
||||
"""
|
||||
Get mapping for page, if any.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
|
||||
Returns:
|
||||
The mapping or nothing.
|
||||
"""
|
||||
if page.url in self.data:
|
||||
return self.data[page.url]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _print(manager: MappingManager, indent: int = 0) -> str:
|
||||
"""
|
||||
Return a printable representation of a mapping manager.
|
||||
|
||||
Arguments:
|
||||
manager: The mapping manager.
|
||||
indent: The indentation level.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append(" " * indent + f"MappingManager()")
|
||||
|
||||
# Print mappings
|
||||
for mapping in manager:
|
||||
lines.append(" " * (indent + 2) + repr(mapping))
|
||||
|
||||
# Concatenate everything
|
||||
return "\n".join(lines)
|
||||
211
material/plugins/tags/structure/mapping/storage/__init__.py
Normal file
211
material/plugins/tags/structure/mapping/storage/__init__.py
Normal file
@@ -0,0 +1,211 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from collections.abc import Iterable
|
||||
from material.plugins.tags.config import TagsConfig
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from mkdocs.config.base import ValidationError
|
||||
from mkdocs.structure.nav import Link
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class MappingStorage:
|
||||
"""
|
||||
A mapping storage.
|
||||
|
||||
The mapping storage allows to save and load mappings to and from a JSON
|
||||
file, which allows for sharing tags across multiple MkDocs projects.
|
||||
"""
|
||||
|
||||
def __init__(self, config: TagsConfig):
|
||||
"""
|
||||
Initialize the mapping storage.
|
||||
|
||||
Arguments:
|
||||
config: The configuration.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
config: TagsConfig
|
||||
"""
|
||||
The configuration.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def save(self, path: str, mappings: Iterable[Mapping]) -> None:
|
||||
"""
|
||||
Save mappings to file.
|
||||
|
||||
Arguments:
|
||||
path: The file path.
|
||||
mappings: The mappings.
|
||||
"""
|
||||
path = os.path.abspath(path)
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
|
||||
# Save serialized mappings to file
|
||||
with open(path, "w", encoding = "utf-8") as f:
|
||||
data = [_mapping_to_json(mapping) for mapping in mappings]
|
||||
json.dump(dict(mappings = data), f)
|
||||
|
||||
def load(self, path: str) -> Iterable[Mapping]:
|
||||
"""
|
||||
Load mappings from file.
|
||||
|
||||
Arguments:
|
||||
path: The file path.
|
||||
|
||||
Yields:
|
||||
The current mapping.
|
||||
"""
|
||||
with open(path, "r", encoding = "utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Ensure root dictionary
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"Expected dictionary, but received: {data}"
|
||||
)
|
||||
|
||||
# Ensure mappings are iterable
|
||||
mappings = data.get("mappings")
|
||||
if not isinstance(mappings, list):
|
||||
raise ValidationError(
|
||||
f"Expected list, but received: {mappings}"
|
||||
)
|
||||
|
||||
# Create and yield mappings
|
||||
for mapping in mappings:
|
||||
yield _mapping_from_json(mapping)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _mapping_to_json(mapping: Mapping) -> dict:
|
||||
"""
|
||||
Return a serializable representation of a mapping.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
|
||||
Returns:
|
||||
Serializable representation.
|
||||
"""
|
||||
return dict(
|
||||
item = _mapping_item_to_json(mapping.item),
|
||||
tags = [str(tag) for tag in sorted(mapping.tags)]
|
||||
)
|
||||
|
||||
def _mapping_item_to_json(item: Page | Link) -> dict:
|
||||
"""
|
||||
Return a serializable representation of a page or link.
|
||||
|
||||
Arguments:
|
||||
item: The page or link.
|
||||
|
||||
Returns:
|
||||
Serializable representation.
|
||||
"""
|
||||
return dict(url = item.url, title = item.title)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _mapping_from_json(data: object) -> Mapping:
|
||||
"""
|
||||
Return a mapping from a serialized representation.
|
||||
|
||||
Arguments:
|
||||
data: Serialized representation.
|
||||
|
||||
Returns:
|
||||
The mapping.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"Expected dictionary, but received: {data}"
|
||||
)
|
||||
|
||||
# Ensure tags are iterable
|
||||
tags = data.get("tags")
|
||||
if not isinstance(tags, list):
|
||||
raise ValidationError(
|
||||
f"Expected list, but received: {tags}"
|
||||
)
|
||||
|
||||
# Ensure tags are valid
|
||||
for tag in tags:
|
||||
if not isinstance(tag, str):
|
||||
raise ValidationError(
|
||||
f"Expected string, but received: {tag}"
|
||||
)
|
||||
|
||||
# Create and return mapping
|
||||
return Mapping(
|
||||
_mapping_item_from_json(data.get("item")),
|
||||
tags = [Tag(tag) for tag in tags]
|
||||
)
|
||||
|
||||
def _mapping_item_from_json(data: object) -> Link:
|
||||
"""
|
||||
Return a link from a serialized representation.
|
||||
|
||||
When loading a mapping, we must always return a link, as the sources of
|
||||
pages might not be available because we're building another project.
|
||||
|
||||
Arguments:
|
||||
data: Serialized representation.
|
||||
|
||||
Returns:
|
||||
The link.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"Expected dictionary, but received: {data}"
|
||||
)
|
||||
|
||||
# Ensure item has URL
|
||||
url = data.get("url")
|
||||
if not isinstance(url, str):
|
||||
raise ValidationError(
|
||||
f"Expected string, but received: {url}"
|
||||
)
|
||||
|
||||
# Ensure item has title
|
||||
title = data.get("title")
|
||||
if not isinstance(title, str):
|
||||
raise ValidationError(
|
||||
f"Expected string, but received: {title}"
|
||||
)
|
||||
|
||||
# Create and return item
|
||||
return Link(title, url)
|
||||
149
material/plugins/tags/structure/tag/__init__.py
Normal file
149
material/plugins/tags/structure/tag/__init__.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from functools import total_ordering
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@total_ordering
|
||||
class Tag:
|
||||
"""
|
||||
A tag.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, *, parent: Tag | None = None, hidden = False
|
||||
):
|
||||
"""
|
||||
Initialize the tag.
|
||||
|
||||
Arguments:
|
||||
name: The tag name.
|
||||
parent: The parent tag.
|
||||
hidden: Whether the tag is hidden.
|
||||
"""
|
||||
self.name = name
|
||||
self.parent = parent
|
||||
self.hidden = hidden
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the tag.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return f"Tag('{self.name}')"
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Return a string representation of the tag.
|
||||
|
||||
Returns:
|
||||
String representation.
|
||||
"""
|
||||
return self.name
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Return the hash of the tag.
|
||||
|
||||
Returns:
|
||||
The hash.
|
||||
"""
|
||||
return hash(self.name)
|
||||
|
||||
def __iter__(self) -> Iterator[Tag]:
|
||||
"""
|
||||
Iterate over the tag and its parent tags.
|
||||
|
||||
Note that the first tag returned is the tag itself, followed by its
|
||||
parent tags in ascending order. This allows to iterate over the tag
|
||||
and its parents in a single loop, which is useful for generating
|
||||
tree or breadcrumb structures.
|
||||
|
||||
Yields:
|
||||
The current tag.
|
||||
"""
|
||||
tag = self
|
||||
while tag:
|
||||
yield tag
|
||||
tag = tag.parent
|
||||
|
||||
def __contains__(self, other: Tag) -> bool:
|
||||
"""
|
||||
Check if the tag contains another tag.
|
||||
|
||||
Arguments:
|
||||
other: The other tag to check.
|
||||
|
||||
Returns:
|
||||
Whether the tag contains the other tag.
|
||||
"""
|
||||
assert isinstance(other, Tag)
|
||||
return any(tag == other for tag in self)
|
||||
|
||||
def __eq__(self, other: Tag) -> bool:
|
||||
"""
|
||||
Check if the tag is equal to another tag.
|
||||
|
||||
Arguments:
|
||||
other: The other tag to check.
|
||||
|
||||
Returns:
|
||||
Whether the tags are equal.
|
||||
"""
|
||||
assert isinstance(other, Tag)
|
||||
return self.name == other.name
|
||||
|
||||
def __lt__(self, other: Tag) -> bool:
|
||||
"""
|
||||
Check if the tag is less than another tag.
|
||||
|
||||
Arguments:
|
||||
other: The other tag to check.
|
||||
|
||||
Returns:
|
||||
Whether the tag is less than the other tag.
|
||||
"""
|
||||
assert isinstance(other, Tag)
|
||||
return self.name < other.name
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
name: str
|
||||
"""
|
||||
The tag name.
|
||||
"""
|
||||
|
||||
parent: Tag | None
|
||||
"""
|
||||
The parent tag.
|
||||
"""
|
||||
|
||||
hidden: bool
|
||||
"""
|
||||
Whether the tag is hidden.
|
||||
"""
|
||||
108
material/plugins/tags/structure/tag/options.py
Normal file
108
material/plugins/tags/structure/tag/options.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from mkdocs.config.base import BaseConfigOption, ValidationError
|
||||
from typing import Set
|
||||
|
||||
from . import Tag
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class TagSet(BaseConfigOption[Set[Tag]]):
|
||||
"""
|
||||
Setting for a set of tags.
|
||||
|
||||
This setting describes a set of tags, and is used to validate the actual
|
||||
tags as defined in the front matter of pages, as well as for filters that
|
||||
are used to include or exclude pages from a listing and to check if a tag
|
||||
is allowed to be used.
|
||||
"""
|
||||
|
||||
def __init__(self, *, allowed: set[Tag] = set()):
|
||||
"""
|
||||
Initialize the setting.
|
||||
|
||||
Arguments:
|
||||
allowed: The tags allowed to be used.
|
||||
"""
|
||||
super().__init__()
|
||||
self.allowed = allowed
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
allowed: set[Tag]
|
||||
"""
|
||||
The tags allowed to be used.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_validation(self, value: object) -> set[Tag]:
|
||||
"""
|
||||
Validate list of tags.
|
||||
|
||||
If the value is `None`, an empty set is returned. Otherwise, the value
|
||||
is expected to be a list of tags, which is converted to a set of tags.
|
||||
This means that tags are automatically deduplicated. Note that tags are
|
||||
not expanded here, as the set is intended to be checked exactly.
|
||||
|
||||
Arguments:
|
||||
value: The value to validate.
|
||||
|
||||
Returns:
|
||||
A set of tags.
|
||||
"""
|
||||
if value is None:
|
||||
return set()
|
||||
|
||||
# Ensure tags are iterable
|
||||
if not isinstance(value, Iterable) or isinstance(value, str):
|
||||
raise ValidationError(
|
||||
f"Expected iterable tags, but received: {value}"
|
||||
)
|
||||
|
||||
# Ensure tags are valid
|
||||
tags: set[Tag] = set()
|
||||
for index, tag in enumerate(value):
|
||||
if not isinstance(tag, (str, int, float, bool)):
|
||||
raise ValidationError(
|
||||
f"Expected a {str}, {int}, {float} or {bool} "
|
||||
f"but received: {type(tag)} at index {index}"
|
||||
)
|
||||
|
||||
# Coerce tag to string and add to set
|
||||
tags.add(Tag(str(tag)))
|
||||
|
||||
# Ensure tags are in allow list, if any
|
||||
if self.allowed:
|
||||
invalid = tags.difference(self.allowed)
|
||||
if invalid:
|
||||
raise ValidationError(
|
||||
"Tags not in allow list: " +
|
||||
",".join([tag.name for tag in invalid])
|
||||
)
|
||||
|
||||
# Return set of tags
|
||||
return tags
|
||||
80
material/plugins/tags/structure/tag/reference/__init__.py
Normal file
80
material/plugins/tags/structure/tag/reference/__init__.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from mkdocs.structure.nav import Link
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class TagReference(Tag):
|
||||
"""
|
||||
A tag reference.
|
||||
|
||||
Tag references are a subclass of tags that can have associated links, which
|
||||
is primarily used for linking tags to listings. The first link is used as
|
||||
the canonical link, which by default points to the closest listing that
|
||||
features the tag. This is considered to be the canonical listing.
|
||||
"""
|
||||
|
||||
def __init__(self, tag: Tag, links: list[Link] | None = None):
|
||||
"""
|
||||
Initialize the tag reference.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
links: The links associated with the tag.
|
||||
"""
|
||||
super().__init__(**vars(tag))
|
||||
self.links = links or []
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the tag reference.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return f"TagReference('{self.name}')"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
links: list[Link]
|
||||
"""
|
||||
The links associated with the tag.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def url(self) -> str | None:
|
||||
"""
|
||||
Return the URL of the tag reference.
|
||||
|
||||
Returns:
|
||||
The URL of the tag reference.
|
||||
"""
|
||||
if self.links:
|
||||
return self.links[0].url
|
||||
else:
|
||||
return None
|
||||
@@ -29,7 +29,7 @@
|
||||
<span class="md-profile__description">
|
||||
<strong>
|
||||
{% if author.url %}
|
||||
<a href="{{ author.url }}">{{ author.name }}</a>
|
||||
<a href="{{ author.url | url }}">{{ author.name }}</a>
|
||||
{% else %}
|
||||
{{ author.name }}
|
||||
{% endif %}
|
||||
@@ -100,6 +100,25 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if page.config.links %}
|
||||
<ul class="md-post__meta md-nav__list">
|
||||
<li class="md-nav__item md-nav__item--section">
|
||||
<div class="md-post__title">
|
||||
<span class="md-ellipsis">
|
||||
{{ lang.t("blog.references") }}
|
||||
</span>
|
||||
</div>
|
||||
<nav class="md-nav">
|
||||
<ul class="md-nav__list">
|
||||
{% for nav_item in page.config.links %}
|
||||
{% set path = "__ref_" ~ loop.index %}
|
||||
{{ item.render(nav_item, path, 1) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
19
material/templates/fragments/tags/default/listing.html
Normal file
19
material/templates/fragments/tags/default/listing.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{#-
|
||||
This file was automatically generated - do not edit
|
||||
-#}
|
||||
{% macro render(listing) %}
|
||||
{{ listing.content }}
|
||||
<ul>
|
||||
{% for mapping in listing.mappings %}
|
||||
<li>
|
||||
<a href="{{ mapping.item.url | url }}">
|
||||
{{ mapping.item.title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% for child in listing %}
|
||||
<li style="list-style-type:none">{{ render(child) }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
{{ render(listing) }}
|
||||
16
material/templates/fragments/tags/default/tag.html
Normal file
16
material/templates/fragments/tags/default/tag.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{#-
|
||||
This file was automatically generated - do not edit
|
||||
-#}
|
||||
{% set class = "md-tag" %}
|
||||
{% if tag.hidden %}
|
||||
{% set class = class ~ " md-tag-shadow" %}
|
||||
{% endif %}
|
||||
{% if config.extra.tags %}
|
||||
{% set class = class ~ " md-tag-icon" %}
|
||||
{% if tag.name in config.extra.tags %}
|
||||
{% set class = class ~ " md-tag--" ~ config.extra.tags[tag.name] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<span class="{{ class }}">
|
||||
{{- tag.name -}}
|
||||
</span>
|
||||
@@ -1,9 +1,7 @@
|
||||
{#-
|
||||
This file was automatically generated - do not edit
|
||||
-#}
|
||||
{% if "material/tags" in config.plugins and tags %}
|
||||
{% include "partials/tags.html" %}
|
||||
{% endif %}
|
||||
{% include "partials/tags.html" %}
|
||||
{% include "partials/actions.html" %}
|
||||
{% if "\x3ch1" not in page.content %}
|
||||
<h1>{{ page.title | d(config.site_name, true)}}</h1>
|
||||
|
||||
@@ -11,13 +11,17 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{% macro render_content(nav_item, ref = nav_item) %}
|
||||
{% if nav_item.is_page and nav_item.meta.icon %}
|
||||
{% if nav_item.meta and nav_item.meta.icon %}
|
||||
{% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %}
|
||||
{% endif %}
|
||||
<span class="md-ellipsis">
|
||||
{{ ref.title }}
|
||||
{% if nav_item.meta and nav_item.meta.subtitle %}
|
||||
<br>
|
||||
<small>{{ nav_item.meta.subtitle }}</small>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if nav_item.is_page and nav_item.meta.status %}
|
||||
{% if nav_item.meta and nav_item.meta.status %}
|
||||
{{ render_status(nav_item, nav_item.meta.status) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
@@ -4,23 +4,25 @@
|
||||
{% if page.meta and page.meta.hide %}
|
||||
{% set hidden = "hidden" if "tags" in page.meta.hide %}
|
||||
{% endif %}
|
||||
<nav class="md-tags" {{ hidden }}>
|
||||
{% for tag in tags %}
|
||||
{% set icon = "" %}
|
||||
{% if config.extra.tags %}
|
||||
{% set icon = " md-tag-icon" %}
|
||||
{% if tag.type %}
|
||||
{% set icon = icon ~ " md-tag--" ~ tag.type %}
|
||||
{% if tags %}
|
||||
<nav class="md-tags" {{ hidden }}>
|
||||
{% for tag in tags %}
|
||||
{% set class = "md-tag" %}
|
||||
{% if config.extra.tags %}
|
||||
{% set class = class ~ " md-tag-icon" %}
|
||||
{% if tag.name in config.extra.tags %}
|
||||
{% set class = class ~ " md-tag--" ~ config.extra.tags[tag.name] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if tag.url %}
|
||||
<a href="{{ tag.url | url }}" class="md-tag{{ icon }}">
|
||||
{{- tag.name -}}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="md-tag{{ icon }}">
|
||||
{{- tag.name -}}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% if tag.url %}
|
||||
<a href="{{ tag.url | url }}" class="{{ class }}">
|
||||
{{- tag.name -}}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="{{ class }}">
|
||||
{{- tag.name -}}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
19
material/utilities/__init__.py
Normal file
19
material/utilities/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
124
material/utilities/filter/__init__.py
Normal file
124
material/utilities/filter/__init__.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fnmatch import fnmatch
|
||||
from mkdocs.structure.files import File
|
||||
|
||||
from .config import FilterConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class Filter:
|
||||
"""
|
||||
A filter.
|
||||
"""
|
||||
|
||||
def __init__(self, config: FilterConfig):
|
||||
"""
|
||||
Initialize the filter.
|
||||
|
||||
Arguments:
|
||||
config: The filter configuration.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
def __call__(self, value: str, ref: str | None = None) -> bool:
|
||||
"""
|
||||
Filter a value.
|
||||
|
||||
First, the inclusion patterns are checked. Regardless of whether they
|
||||
are present, the exclusion patterns are checked afterwards. This allows
|
||||
to exclude values that are included by the inclusion patterns, so that
|
||||
exclusion patterns can be used to refine inclusion patterns.
|
||||
|
||||
Arguments:
|
||||
value: The value to filter.
|
||||
ref: The value used for logging.
|
||||
|
||||
Returns:
|
||||
Whether the value should be included.
|
||||
"""
|
||||
ref = ref or value
|
||||
|
||||
# Check if value matches one of the inclusion patterns
|
||||
if self.config.include:
|
||||
for pattern in self.config.include:
|
||||
if fnmatch(value, pattern):
|
||||
break
|
||||
|
||||
# Value is not included
|
||||
else:
|
||||
log.debug(f"Excluding '{ref}' due to inclusion patterns")
|
||||
return False
|
||||
|
||||
# Check if value matches one of the exclusion patterns
|
||||
for pattern in self.config.exclude:
|
||||
if fnmatch(value, pattern):
|
||||
log.debug(f"Excluding '{ref}' due to exclusion patterns")
|
||||
return False
|
||||
|
||||
# Value is not excluded
|
||||
return True
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
config: FilterConfig
|
||||
"""
|
||||
The filter configuration.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class FileFilter(Filter):
|
||||
"""
|
||||
A file filter.
|
||||
"""
|
||||
|
||||
def __call__(self, file: File) -> bool:
|
||||
"""
|
||||
Filter a file by its source path.
|
||||
|
||||
Arguments:
|
||||
file: The file to filter.
|
||||
|
||||
Returns:
|
||||
Whether the file should be included.
|
||||
"""
|
||||
if file.inclusion.is_excluded():
|
||||
return False
|
||||
|
||||
# Filter file by source path
|
||||
return super().__call__(
|
||||
file.src_uri,
|
||||
file.src_path
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.utilities")
|
||||
47
material/utilities/filter/config.py
Normal file
47
material/utilities/filter/config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import ListOfItems, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class FilterConfig(Config):
|
||||
"""
|
||||
A filter configuration.
|
||||
"""
|
||||
|
||||
include = ListOfItems(Type(str), default = [])
|
||||
"""
|
||||
Patterns to include.
|
||||
|
||||
This list contains patterns that are matched against the value to filter.
|
||||
If the value matches at least one pattern, it will be included.
|
||||
"""
|
||||
|
||||
exclude = ListOfItems(Type(str), default = [])
|
||||
"""
|
||||
Patterns to exclude.
|
||||
|
||||
This list contains patterns that are matched against the value to filter.
|
||||
If the value matches at least one pattern, it will be excluded.
|
||||
"""
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
19
src/plugins/meta/__init__.py
Normal file
19
src/plugins/meta/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
33
src/plugins/meta/config.py
Normal file
33
src/plugins/meta/config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from mkdocs.config.config_options import Type
|
||||
from mkdocs.config.base import Config
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Meta plugin configuration
|
||||
class MetaConfig(Config):
|
||||
enabled = Type(bool, default = True)
|
||||
|
||||
# Settings for meta files
|
||||
meta_file = Type(str, default = ".meta.yml")
|
||||
122
src/plugins/meta/plugin.py
Normal file
122
src/plugins/meta/plugin.py
Normal file
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from mergedeep import Strategy, merge
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.structure.files import InclusionLevel
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from yaml import SafeLoader, load
|
||||
|
||||
from .config import MetaConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Meta plugin
|
||||
class MetaPlugin(BasePlugin[MetaConfig]):
|
||||
|
||||
# Construct metadata mapping
|
||||
def on_files(self, files, *, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Initialize mapping
|
||||
self.meta = {}
|
||||
|
||||
# Resolve and load meta files in docs directory
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
for file in files:
|
||||
name = posixpath.basename(file.src_uri)
|
||||
if not name == self.config.meta_file:
|
||||
continue
|
||||
|
||||
# Exclude meta file from site directory - explicitly excluding the
|
||||
# meta file allows the author to use a file name without '.' prefix
|
||||
file.inclusion = InclusionLevel.EXCLUDED
|
||||
|
||||
# Open file and parse as YAML
|
||||
with open(file.abs_src_path, encoding = "utf-8-sig") as f:
|
||||
path = file.src_path
|
||||
try:
|
||||
self.meta[path] = load(f, SafeLoader)
|
||||
|
||||
# The meta file could not be loaded because of a syntax error,
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
raise PluginError(
|
||||
f"Error reading meta file '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Set metadata for page, if applicable (run earlier)
|
||||
@event_priority(50)
|
||||
def on_page_markdown(self, markdown, *, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Start with a clean state, as we first need to apply all meta files
|
||||
# that are relevant to the current page, and then merge the page meta
|
||||
# on top of that to ensure that the page meta always takes precedence
|
||||
# over meta files - see https://t.ly/kvCRn
|
||||
meta = {}
|
||||
|
||||
# Merge matching meta files in level-order
|
||||
strategy = Strategy.TYPESAFE_ADDITIVE
|
||||
for path, defaults in self.meta.items():
|
||||
if not page.file.src_path.startswith(os.path.dirname(path)):
|
||||
continue
|
||||
|
||||
# Skip if meta file was already merged - this happens in case of
|
||||
# blog posts, as they need to be merged when posts are constructed,
|
||||
# which is why we need to keep track of which meta files are applied
|
||||
# to what pages using the `__extends` key.
|
||||
page.meta.setdefault("__extends", [])
|
||||
if path in page.meta["__extends"]:
|
||||
continue
|
||||
|
||||
# Try to merge metadata
|
||||
try:
|
||||
merge(meta, defaults, strategy = strategy)
|
||||
page.meta["__extends"].append(path)
|
||||
|
||||
# Merging the metadata with the given strategy resulted in an error,
|
||||
# which we display to the author with a nice error message
|
||||
except Exception as e:
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
raise PluginError(
|
||||
f"Error merging meta file '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Ensure page metadata is merged last, so the author can override any
|
||||
# defaults from the meta files, or even remove them entirely
|
||||
page.meta = merge(meta, page.meta, strategy = strategy)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.meta")
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {})
|
||||
)
|
||||
|
||||
@@ -18,174 +18,291 @@
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
from collections import defaultdict
|
||||
from markdown.extensions.toc import slugify
|
||||
from mkdocs import utils
|
||||
from mkdocs.plugins import BasePlugin
|
||||
from jinja2 import Environment
|
||||
from material.utilities.filter import FileFilter
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.plugins import BasePlugin, event_priority
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.utils.templates import TemplateContext
|
||||
|
||||
# deprecated, but kept for downward compatibility. Use 'material.plugins.tags'
|
||||
# as an import source instead. This import is removed in the next major version.
|
||||
from . import casefold
|
||||
from .config import TagsConfig
|
||||
from .renderer import Renderer
|
||||
from .structure.listing.manager import ListingManager
|
||||
from .structure.mapping.manager import MappingManager
|
||||
from .structure.mapping.storage import MappingStorage
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Tags plugin
|
||||
class TagsPlugin(BasePlugin[TagsConfig]):
|
||||
"""
|
||||
A tags plugin.
|
||||
|
||||
This plugin collects tags from the front matter of pages, and builds a tag
|
||||
structure from them. The tag structure can be used to render listings on
|
||||
pages, or to just create a site-wide tags index and export all tags and
|
||||
mappings to a JSON file for consumption in another project.
|
||||
"""
|
||||
|
||||
supports_multiple_instances = True
|
||||
"""
|
||||
This plugin supports multiple instances.
|
||||
"""
|
||||
|
||||
# Initialize plugin
|
||||
def on_config(self, config):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Initialize the plugin.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
# Initialize incremental builds
|
||||
self.is_serve = False
|
||||
|
||||
# Initialize tags
|
||||
self.tags = defaultdict(list)
|
||||
self.tags_file = None
|
||||
|
||||
# Retrieve tags mapping from configuration
|
||||
self.tags_map = config.extra.get("tags")
|
||||
|
||||
# Use override of slugify function
|
||||
toc = { "slugify": slugify, "separator": "-" }
|
||||
if "toc" in config.mdx_configs:
|
||||
toc = { **toc, **config.mdx_configs["toc"] }
|
||||
|
||||
# Partially apply slugify function
|
||||
self.slugify = lambda value: (
|
||||
toc["slugify"](str(value), toc["separator"])
|
||||
)
|
||||
|
||||
# Hack: 2nd pass for tags index page(s)
|
||||
def on_nav(self, nav, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Resolve tags index page
|
||||
file = self.config.tags_file
|
||||
if file:
|
||||
self.tags_file = self._get_tags_file(files, file)
|
||||
|
||||
# Build and render tags index page
|
||||
def on_page_markdown(self, markdown, page, config, files):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Skip, if page is excluded
|
||||
if page.file.inclusion.is_excluded():
|
||||
return
|
||||
|
||||
# Render tags index page
|
||||
if page.file == self.tags_file:
|
||||
return self._render_tag_index(markdown)
|
||||
|
||||
# Add page to tags index
|
||||
tags = page.meta.get("tags", [])
|
||||
if tags:
|
||||
for tag in tags:
|
||||
self.tags[str(tag)].append(page)
|
||||
|
||||
# Inject tags into page (after search and before minification)
|
||||
def on_page_context(self, context, page, config, nav):
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Provide tags for page
|
||||
context["tags"] =[]
|
||||
if "tags" in page.meta and page.meta["tags"]:
|
||||
context["tags"] = [
|
||||
self._render_tag(tag)
|
||||
for tag in page.meta["tags"]
|
||||
]
|
||||
# Initialize mapping and listing managers
|
||||
self.mappings = None
|
||||
self.listings = None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Obtain tags file
|
||||
def _get_tags_file(self, files, path):
|
||||
file = files.get_file_from_path(path)
|
||||
if not file:
|
||||
log.error(f"Tags file '{path}' does not exist.")
|
||||
sys.exit(1)
|
||||
mappings: MappingManager
|
||||
"""
|
||||
Mapping manager.
|
||||
"""
|
||||
|
||||
# Add tags file to files - note: since MkDoc 1.6, not removing the
|
||||
# file before adding it to the end will trigger a deprecation warning
|
||||
# The new tags plugin does not require this hack, so we're just going
|
||||
# to live with it until the new tags plugin is released.
|
||||
files.remove(file)
|
||||
files.append(file)
|
||||
return file
|
||||
listings: ListingManager
|
||||
"""
|
||||
Listing manager.
|
||||
"""
|
||||
|
||||
# Render tags index
|
||||
def _render_tag_index(self, markdown):
|
||||
filter: FileFilter
|
||||
"""
|
||||
File filter.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def on_startup(self, *, command, **kwargs) -> None:
|
||||
"""
|
||||
Determine whether we're serving the site.
|
||||
|
||||
Arguments:
|
||||
command: The command that is being executed.
|
||||
dirty: Whether dirty builds are enabled.
|
||||
"""
|
||||
self.is_serve = command == "serve"
|
||||
|
||||
def on_config(self, config: MkDocsConfig) -> None:
|
||||
"""
|
||||
Create mapping and listing managers.
|
||||
"""
|
||||
|
||||
# Retrieve toc depth, so we know the maximum level at which we can add
|
||||
# items to the table of contents - Python Markdown allows to set the
|
||||
# toc depth as a range, e.g. `2-6`, so we need to account for that as
|
||||
# well. We need this information for generating listings.
|
||||
depth = config.mdx_configs.get("toc", {}).get("toc_depth", 6)
|
||||
if not isinstance(depth, int) and "-" in depth:
|
||||
_, depth = depth.split("-")
|
||||
|
||||
# Initialize mapping and listing managers
|
||||
self.mappings = MappingManager(self.config)
|
||||
self.listings = ListingManager(self.config, int(depth))
|
||||
|
||||
# Initialize file filter - the file filter is used to include or exclude
|
||||
# entire subsections of the documentation, allowing for using multiple
|
||||
# instances of the plugin alongside each other. This can be necessary
|
||||
# when creating multiple, potentially conflicting listings.
|
||||
self.filter = FileFilter(self.config.filters)
|
||||
|
||||
# Ensure presence of attribute lists extension
|
||||
for extension in config.markdown_extensions:
|
||||
if isinstance(extension, str) and extension.endswith("attr_list"):
|
||||
break
|
||||
else:
|
||||
config.markdown_extensions.append("attr_list")
|
||||
|
||||
# If the author only wants to extract and export mappings, we allow to
|
||||
# disable the rendering of all tags and listings with a single setting
|
||||
if self.config.export_only:
|
||||
self.config.tags = False
|
||||
self.config.listings = False
|
||||
|
||||
@event_priority(-50)
|
||||
def on_page_markdown(
|
||||
self, markdown: str, *, page: Page, config: MkDocsConfig, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
Collect tags and listings from page.
|
||||
|
||||
Priority: -50 (run later)
|
||||
|
||||
Arguments:
|
||||
markdown: The page's Markdown.
|
||||
page: The page.
|
||||
config: The MkDocs configuration.
|
||||
|
||||
Returns:
|
||||
The page's Markdown with injection points.
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if page should not be considered
|
||||
if not self.filter(page.file):
|
||||
return
|
||||
|
||||
# Handle deprecation of `tags_file` setting
|
||||
if self.config.tags_file:
|
||||
markdown = self._handle_deprecated_tags_file(page, markdown)
|
||||
|
||||
# Handle deprecation of `tags_extra_files` setting
|
||||
if self.config.tags_extra_files:
|
||||
markdown = self._handle_deprecated_tags_extra_files(page, markdown)
|
||||
|
||||
# Collect tags from page
|
||||
try:
|
||||
self.mappings.add(page, markdown)
|
||||
|
||||
# Raise exception if tags could not be read
|
||||
except Exception as e:
|
||||
docs = os.path.relpath(config.docs_dir)
|
||||
path = os.path.relpath(page.file.abs_src_path, docs)
|
||||
raise PluginError(
|
||||
f"Error reading tags of page '{path}' in '{docs}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Collect listings from page
|
||||
return self.listings.add(page, markdown)
|
||||
|
||||
@event_priority(100)
|
||||
def on_env(
|
||||
self, env: Environment, *, config: MkDocsConfig, **kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Populate listings.
|
||||
|
||||
Priority: 100 (run earliest)
|
||||
|
||||
Arguments:
|
||||
env: The Jinja environment.
|
||||
config: The MkDocs configuration.
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Populate and render all listings
|
||||
self.listings.populate_all(self.mappings, Renderer(env, config))
|
||||
|
||||
# Export mappings to file, if enabled
|
||||
if self.config.export:
|
||||
path = os.path.join(config.site_dir, self.config.export_file)
|
||||
path = os.path.normpath(path)
|
||||
|
||||
# Serialize mappings and save to file
|
||||
storage = MappingStorage(self.config)
|
||||
storage.save(path, self.mappings)
|
||||
|
||||
def on_page_context(
|
||||
self, context: TemplateContext, *, page: Page, **kwargs
|
||||
) -> None:
|
||||
"""
|
||||
Add tag references to page context.
|
||||
|
||||
Arguments:
|
||||
context: The template context.
|
||||
page: The page.
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return
|
||||
|
||||
# Skip if page should not be considered
|
||||
if not self.filter(page.file):
|
||||
return
|
||||
|
||||
# Skip if tags should not be built
|
||||
if not self.config.tags:
|
||||
return
|
||||
|
||||
# Retrieve tags references for page
|
||||
mapping = self.mappings.get(page)
|
||||
if mapping:
|
||||
tags = self.config.tags_name_variable
|
||||
if tags not in context:
|
||||
context[tags] = list(self.listings & mapping)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _handle_deprecated_tags_file(
|
||||
self, page: Page, markdown: str
|
||||
) -> str:
|
||||
"""
|
||||
Handle deprecation of `tags_file` setting.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
"""
|
||||
directive = self.config.listings_directive
|
||||
if page.file.src_uri != self.config.tags_file:
|
||||
return markdown
|
||||
|
||||
# Try to find the legacy tags marker and replace with directive
|
||||
if "[TAGS]" in markdown:
|
||||
markdown = markdown.replace("[TAGS]", "<!-- material/tags -->")
|
||||
if not "<!-- material/tags -->" in markdown:
|
||||
markdown += "\n<!-- material/tags -->"
|
||||
|
||||
# Replace placeholder in Markdown with rendered tags index
|
||||
return markdown.replace("<!-- material/tags -->", "\n".join([
|
||||
self._render_tag_links(*args)
|
||||
for args in sorted(self.tags.items())
|
||||
]))
|
||||
|
||||
# Render the given tag and links to all pages with occurrences
|
||||
def _render_tag_links(self, tag, pages):
|
||||
classes = ["md-tag"]
|
||||
if isinstance(self.tags_map, dict):
|
||||
classes.append("md-tag-icon")
|
||||
type = self.tags_map.get(tag)
|
||||
if type:
|
||||
classes.append(f"md-tag--{type}")
|
||||
|
||||
# Render section for tag and a link to each page
|
||||
classes = " ".join(classes)
|
||||
content = [f"## <span class=\"{classes}\">{tag}</span>", ""]
|
||||
for page in pages:
|
||||
url = utils.get_relative_url(
|
||||
page.file.src_uri,
|
||||
self.tags_file.src_uri
|
||||
markdown = markdown.replace(
|
||||
"[TAGS]", f"<!-- {directive} -->"
|
||||
)
|
||||
|
||||
# Render link to page
|
||||
title = page.meta.get("title", page.title)
|
||||
content.append(f"- [{title}]({url})")
|
||||
# Try to find the directive and add it if not present
|
||||
pattern = r"<!--\s+{directive}".format(directive = directive)
|
||||
if not re.search(pattern, markdown):
|
||||
markdown += f"\n<!-- {directive} -->"
|
||||
|
||||
# Return rendered tag links
|
||||
return "\n".join(content)
|
||||
# Return markdown
|
||||
return markdown
|
||||
|
||||
# Render the given tag, linking to the tags index (if enabled)
|
||||
def _render_tag(self, tag):
|
||||
type = self.tags_map.get(tag) if self.tags_map else None
|
||||
if not self.tags_file or not self.slugify:
|
||||
return dict(name = tag, type = type)
|
||||
else:
|
||||
url = f"{self.tags_file.url}#{self.slugify(tag)}"
|
||||
return dict(name = tag, type = type, url = url)
|
||||
def _handle_deprecated_tags_extra_files(
|
||||
self, page: Page, markdown: str
|
||||
) -> str:
|
||||
"""
|
||||
Handle deprecation of `tags_extra_files` setting.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
"""
|
||||
directive = self.config.listings_directive
|
||||
if page.file.src_uri not in self.config.tags_extra_files:
|
||||
return markdown
|
||||
|
||||
# Compute tags to render on page
|
||||
tags = self.config.tags_extra_files[page.file.src_uri]
|
||||
if tags:
|
||||
directive += f" {{ include: [{', '.join(tags)}] }}"
|
||||
|
||||
# Try to find the legacy tags marker and replace with directive
|
||||
if "[TAGS]" in markdown:
|
||||
markdown = markdown.replace(
|
||||
"[TAGS]", f"<!-- {directive} -->"
|
||||
)
|
||||
|
||||
# Try to find the directive and add it if not present
|
||||
pattern = r"<!--\s+{directive}".format(directive = re.escape(directive))
|
||||
if not re.search(pattern, markdown):
|
||||
markdown += f"\n<!-- {directive} -->"
|
||||
|
||||
# Return markdown
|
||||
return markdown
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.tags")
|
||||
log = logging.getLogger("mkdocs.material.plugins.tags")
|
||||
|
||||
99
src/plugins/tags/renderer/__init__.py
Normal file
99
src/plugins/tags/renderer/__init__.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
|
||||
from jinja2 import Environment
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.utils import get_relative_url
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class Renderer:
|
||||
"""
|
||||
A renderer for tags and listings.
|
||||
|
||||
This class implements a simple tag and listing renderer, leveraging the
|
||||
Jinja environment and the MkDocs configuration as provided to plugins.
|
||||
|
||||
Note that the templates must be stored in the `fragments` and not `partials`
|
||||
directory, because in order to render tags and listings, we must wait for
|
||||
all pages to be read and processed, as we first need to collect all tags
|
||||
before we can render listings. Tags induce a graph, not a tree.
|
||||
|
||||
For this reason, we consider the templates to be fragments, as they are
|
||||
not implicitly rendered by MkDocs, but explicitly by the plugin.
|
||||
"""
|
||||
|
||||
def __init__(self, env: Environment, config: MkDocsConfig):
|
||||
"""
|
||||
Initialize renderer.
|
||||
|
||||
Arguments:
|
||||
env: The Jinja environment.
|
||||
config: The MkDocs configuration.
|
||||
"""
|
||||
self.env = env
|
||||
self.config = config
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
env: Environment
|
||||
"""
|
||||
The Jinja environment.
|
||||
"""
|
||||
|
||||
config: MkDocsConfig
|
||||
"""
|
||||
The MkDocs configuration.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def render(self, page: Page, name: str, **kwargs) -> str:
|
||||
"""
|
||||
Render a template.
|
||||
|
||||
Templates are resolved from `fragments/tags`, so if you want to override
|
||||
templates or provide additional ones place them in this directory.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
name: The name of the template.
|
||||
kwargs: The template variables.
|
||||
|
||||
Returns:
|
||||
The rendered template.
|
||||
"""
|
||||
path = posixpath.join("fragments", "tags", name)
|
||||
path = posixpath.normpath(path)
|
||||
|
||||
# Resolve and render template
|
||||
template = self.env.get_template(path)
|
||||
return template.render(
|
||||
config = self.config, page = page,
|
||||
base_url = get_relative_url(".", page.url),
|
||||
**kwargs
|
||||
)
|
||||
19
src/plugins/tags/structure/__init__.py
Normal file
19
src/plugins/tags/structure/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
218
src/plugins/tags/structure/listing/__init__.py
Normal file
218
src/plugins/tags/structure/listing/__init__.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
|
||||
from collections.abc import Iterator
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
from .config import ListingConfig
|
||||
from .tree import ListingTree
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class Listing:
|
||||
"""
|
||||
A listing of tags.
|
||||
|
||||
Listings can be included on a page with the `<!-- material/tags [args] -->`
|
||||
directive. The arguments are passed to a YAML parser, and are expected to
|
||||
either be an inline listing configuration, or an identifier that points to
|
||||
a listing configuration in the `listings_map` setting in `mkdocs.yml`.
|
||||
|
||||
Example 1: use inline listing configuration
|
||||
|
||||
``` markdown
|
||||
<!-- material/tags { include: [foo, bar] } -->
|
||||
```
|
||||
|
||||
Example 2: use listing configuration from `listings_map.qux`
|
||||
|
||||
``` markdown
|
||||
<!-- material/tags qux -->
|
||||
```
|
||||
|
||||
If no arguments are given, the default listing configuration is used,
|
||||
rendering all tags and mappings. In case you're using multiple instances
|
||||
of the tags plugin, you can use the `listings_directive` setting to change
|
||||
the directive name.
|
||||
"""
|
||||
|
||||
def __init__(self, page: Page, id: str, config: ListingConfig):
|
||||
"""
|
||||
Initialize the listing.
|
||||
|
||||
Arguments:
|
||||
page: The page the listing is embedded in.
|
||||
id: The listing identifier.
|
||||
config: The listing configuration.
|
||||
"""
|
||||
self.page = page
|
||||
self.id = id
|
||||
self.config = config
|
||||
self.tags = {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the listing.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return f"Listing({repr(self.page)})"
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Return the hash of the listing.
|
||||
|
||||
Returns:
|
||||
The hash.
|
||||
"""
|
||||
return hash(self.id)
|
||||
|
||||
def __iter__(self) -> Iterator[ListingTree]:
|
||||
"""
|
||||
Iterate over the listing in pre-order.
|
||||
|
||||
Yields:
|
||||
The current listing tree.
|
||||
"""
|
||||
stack = list(reversed(self.tags.values()))
|
||||
while stack:
|
||||
tree = stack.pop()
|
||||
yield tree
|
||||
|
||||
# Visit subtrees in reverse, so pre-order is preserved
|
||||
stack += reversed([*tree])
|
||||
|
||||
def __and__(self, mapping: Mapping) -> Iterator[Tag]:
|
||||
"""
|
||||
Iterate over the tags of a mapping featured in the listing.
|
||||
|
||||
When hierarchical tags are used, the set of tags is expanded to include
|
||||
all parent tags, but only for the inclusion check. The returned tags are
|
||||
always the actual tags (= leaves) of the mapping. This is done to avoid
|
||||
duplicate entries in the listing.
|
||||
|
||||
If a mapping features one of the tags excluded from the listing, the
|
||||
entire mapping is excluded from the listing. Additionally, if a listing
|
||||
should only include tags within the current scope, the mapping is only
|
||||
included if the page is a child of the page the listing was found on.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
|
||||
Yields:
|
||||
The current tag.
|
||||
"""
|
||||
assert isinstance(mapping, Mapping)
|
||||
|
||||
# If the mapping is on the same page as the listing, we skip it, as
|
||||
# it makes no sense to link to a listing on the same page
|
||||
if mapping.item == self.page:
|
||||
return iter([])
|
||||
|
||||
# If the listing should only include tags within the current scope, we
|
||||
# check if the page is a child of the page the listing is embedded in
|
||||
if self.config.scope:
|
||||
assert isinstance(mapping.item, Page)
|
||||
# Note that we must use `File.src_uri` here, or we won't be able to
|
||||
# detect the difference between pages, and index pages in sections,
|
||||
# as `foo/index.md` and `foo.md` look the same when using `Page.url`
|
||||
base = posixpath.dirname(posixpath.normpath(self.page.file.src_uri))
|
||||
if not mapping.item.file.src_uri.startswith(posixpath.join(base, "")):
|
||||
return iter([])
|
||||
|
||||
# If an exclusion list is given, expand each tag to check if the tag
|
||||
# itself or one of its parents is excluded from the listing
|
||||
if self.config.exclude:
|
||||
if any(mapping & self.config.exclude):
|
||||
return iter([])
|
||||
|
||||
# If an inclusion list is given, expand each tag to check if the tag
|
||||
# itself or one of its parents is included in the listing
|
||||
if self.config.include:
|
||||
return mapping & self.config.include
|
||||
|
||||
# Otherwise, we can just return an iterator over the set of tags of the
|
||||
# mapping as is, as no expansion is required
|
||||
return iter(mapping.tags)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
page: Page
|
||||
"""
|
||||
The page the listing is embedded in.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""
|
||||
The listing identifier.
|
||||
|
||||
As authors may place an arbitrary number of listings on any page, we need
|
||||
to be able to distinguish between them. This is done automatically.
|
||||
"""
|
||||
|
||||
config: ListingConfig
|
||||
"""
|
||||
The listing configuration.
|
||||
"""
|
||||
|
||||
tags: dict[Tag, ListingTree]
|
||||
"""
|
||||
The listing trees, each of which associated with a tag.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def add(self, mapping: Mapping, *, hidden = True) -> None:
|
||||
"""
|
||||
Add mapping to listing.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
hidden: Whether to add hidden tags.
|
||||
"""
|
||||
for leaf in self & mapping:
|
||||
tree = self.tags
|
||||
|
||||
# Skip if hidden tags should not be rendered
|
||||
if not hidden and leaf.hidden:
|
||||
continue
|
||||
|
||||
# Iterate over expanded tags
|
||||
for tag in reversed([*leaf]):
|
||||
if tag not in tree:
|
||||
tree[tag] = ListingTree(tag)
|
||||
|
||||
# If the tag is the leaf, i.e., the actual tag we want to add,
|
||||
# we add the mapping to the listing tree's mappings
|
||||
if tag == leaf:
|
||||
tree[tag].mappings.append(mapping)
|
||||
|
||||
# Otherwise, we continue walking down the tree
|
||||
else:
|
||||
tree = tree[tag].children
|
||||
99
src/plugins/tags/structure/listing/config.py
Normal file
99
src/plugins/tags/structure/listing/config.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
import yaml
|
||||
|
||||
from material.plugins.tags.structure.tag.options import TagSet
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import Optional, Type
|
||||
from yaml import Dumper
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class ListingConfig(Config):
|
||||
"""
|
||||
A listing configuration.
|
||||
"""
|
||||
|
||||
scope = Type(bool, default = False)
|
||||
"""
|
||||
Whether to only include pages in the current subsection.
|
||||
|
||||
Enabling this setting will only include pages that are on the same level or
|
||||
on a lower level than the page the listing is on. This allows to create a
|
||||
listing of tags on a page that only includes pages that are in the same
|
||||
subsection of the documentation.
|
||||
"""
|
||||
|
||||
layout = Optional(Type(str))
|
||||
"""
|
||||
The layout to use for rendering the listing.
|
||||
|
||||
This setting allows to override the global setting for the layout. If this
|
||||
setting is not specified, the global `listings_layout` setting is used.
|
||||
"""
|
||||
|
||||
include = TagSet()
|
||||
"""
|
||||
Tags to include in the listing.
|
||||
|
||||
If this set is empty, the listing does not filter pages by tags. Otherwise,
|
||||
all pages that have at least one of the tags in this set will be included.
|
||||
"""
|
||||
|
||||
exclude = TagSet()
|
||||
"""
|
||||
Tags to exclude from the listing.
|
||||
|
||||
If this set is empty, the listing does not filter pages by tags. Otherwise,
|
||||
all pages that have at least one of the tags in this set will be excluded.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _representer(dumper: Dumper, config: ListingConfig):
|
||||
"""
|
||||
Return a serializable representation of a listing configuration.
|
||||
|
||||
Arguments:
|
||||
dumper: The YAML dumper.
|
||||
config: The listing configuration.
|
||||
|
||||
Returns:
|
||||
Serializable representation.
|
||||
"""
|
||||
copy = config.copy()
|
||||
|
||||
# Convert the include and exclude tag sets to lists of strings
|
||||
copy.include = list(map(str, copy.include)) if copy.include else None
|
||||
copy.exclude = list(map(str, copy.exclude)) if copy.exclude else None
|
||||
|
||||
# Return serializable listing configuration
|
||||
data = { k: v for k, v in copy.items() if v is not None }
|
||||
return dumper.represent_dict(data)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Register listing configuration YAML representer
|
||||
yaml.add_representer(ListingConfig, _representer)
|
||||
497
src/plugins/tags/structure/listing/manager/__init__.py
Normal file
497
src/plugins/tags/structure/listing/manager/__init__.py
Normal file
@@ -0,0 +1,497 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import yaml
|
||||
|
||||
from collections.abc import Iterable, Iterator
|
||||
from material.plugins.tags.config import TagsConfig
|
||||
from material.plugins.tags.renderer import Renderer
|
||||
from material.plugins.tags.structure.listing import Listing, ListingConfig
|
||||
from material.plugins.tags.structure.listing.tree import ListingTree
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from material.plugins.tags.structure.tag.reference import TagReference
|
||||
from mkdocs.exceptions import PluginError
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.structure.nav import Link
|
||||
from re import Match
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import toc
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class ListingManager:
|
||||
"""
|
||||
A listing manager.
|
||||
|
||||
The listing manager collects all listings from the Markdown of pages, then
|
||||
populates them with mappings, and renders them. Furthermore, the listing
|
||||
manager allows to obtain tag references for a given mapping, which are
|
||||
tags annotated with links to listings.
|
||||
"""
|
||||
|
||||
def __init__(self, config: TagsConfig, depth: int = 6):
|
||||
"""
|
||||
Initialize the listing manager.
|
||||
|
||||
Arguments:
|
||||
config: The configuration.
|
||||
"""
|
||||
self.config = config
|
||||
self.data = set()
|
||||
self.depth = depth
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the listing manager.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return _print(self)
|
||||
|
||||
def __iter__(self) -> Iterator[Listing]:
|
||||
"""
|
||||
Iterate over listings.
|
||||
|
||||
Yields:
|
||||
The current listing.
|
||||
"""
|
||||
return iter(self.data)
|
||||
|
||||
def __and__(self, mapping: Mapping) -> Iterator[TagReference]:
|
||||
"""
|
||||
Iterate over the tag references for the mapping.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
|
||||
Yields:
|
||||
The current tag reference.
|
||||
"""
|
||||
assert isinstance(mapping, Mapping)
|
||||
|
||||
# Iterate over sorted tags and associate tags with listings - note that
|
||||
# we sort the listings for the mapping by closeness, so that the first
|
||||
# listing in the list is the closest one to the page or link the
|
||||
# mapping is associated with
|
||||
listings = self.closest(mapping)
|
||||
for tag in self._sort_tags(mapping.tags):
|
||||
ref = TagReference(tag)
|
||||
|
||||
# Iterate over listings and add links
|
||||
for listing in listings:
|
||||
if tag in listing & mapping:
|
||||
value = listing.page.url or "."
|
||||
|
||||
# Compute URL for link - make sure to remove fragments, as
|
||||
# they may be present in links extracted from remote tags.
|
||||
# Additionally, we need to fallback to `.` if the URL is
|
||||
# empty (= homepage) or the links will be incorrect.
|
||||
url = urlparse(value, allow_fragments = False)
|
||||
url = url._replace(fragment = self._slugify(tag))
|
||||
|
||||
# Add listing link to tag reference
|
||||
ref.links.append(
|
||||
Link(listing.page.title, url.geturl())
|
||||
)
|
||||
|
||||
# Yield tag reference
|
||||
yield ref
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
config: TagsConfig
|
||||
"""
|
||||
The configuration.
|
||||
"""
|
||||
|
||||
data: set[Listing]
|
||||
"""
|
||||
The listings.
|
||||
"""
|
||||
|
||||
depth: int
|
||||
"""
|
||||
Table of contents maximum depth.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def add(self, page: Page, markdown: str) -> str:
|
||||
"""
|
||||
Add page.
|
||||
|
||||
This method is called by the tags plugin to retrieve all listings of a
|
||||
page. It will parse the page's Markdown and add injections points into
|
||||
the page's Markdown, which will be replaced by the renderer with the
|
||||
actual listing later on.
|
||||
|
||||
Note that this method is intended to be called with the page during the
|
||||
`on_page_markdown` event, as it modifies the page's Markdown. Moreover,
|
||||
the Markdown must be explicitly passed, as we could otherwise run into
|
||||
inconsistencies when other plugins modify the Markdown.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
markdown: The page's Markdown.
|
||||
|
||||
Returns:
|
||||
The page's Markdown with injection points.
|
||||
"""
|
||||
assert isinstance(markdown, str)
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match) -> str:
|
||||
config = self._resolve(page, match.group(2))
|
||||
|
||||
# Compute listing identifier - as the author might include multiple
|
||||
# listings on a single page, we must make sure that the identifier
|
||||
# is unique, so we use the page source file path and the position
|
||||
# of the match within the page as an identifier.
|
||||
id = f"{page.file.src_uri}:{match.start()}-{match.end()}"
|
||||
self.data.add(Listing(page, id, config))
|
||||
|
||||
# Replace directive with hx headline if listings are enabled, or
|
||||
# remove the listing entirely from the page and table of contents
|
||||
if self.config.listings:
|
||||
return "#" * self.depth + f" {id}/name {{ #{id}/slug }}"
|
||||
else:
|
||||
return
|
||||
|
||||
# Hack: replace directive with an hx headline to mark the injection
|
||||
# point for the anchor links we will generate after parsing all pages.
|
||||
# By using an hx headline, we can make sure that the injection point
|
||||
# will always be a child of the preceding headline.
|
||||
directive = self.config.listings_directive
|
||||
return re.sub(
|
||||
r"(<!--\s*?{directive}(.*?)\s*-->)".format(directive = directive),
|
||||
replace, markdown, flags = re.I | re.M | re.S
|
||||
)
|
||||
|
||||
def closest(self, mapping: Mapping) -> list[Listing]:
|
||||
"""
|
||||
Get listings for the mapping ordered by closeness.
|
||||
|
||||
Listings are sorted by closeness to the given page, i.e. the number of
|
||||
common path components. This is useful for hierarchical listings, where
|
||||
the tags of a page point to the closest listing featuring the tag, with
|
||||
the option to show all listings featuring that tag.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
|
||||
Returns:
|
||||
The listings.
|
||||
"""
|
||||
|
||||
# Retrieve listings featuring tags of mapping
|
||||
listings: list[Listing] = []
|
||||
for listing in self.data:
|
||||
if any(listing & mapping):
|
||||
listings.append(listing)
|
||||
|
||||
# Ranking callback
|
||||
def rank(listing: Listing) -> int:
|
||||
path = posixpath.commonpath([mapping.item.url, listing.page.url])
|
||||
return len(path)
|
||||
|
||||
# Return listings ordered by closeness to mapping
|
||||
return sorted(listings, key = rank, reverse = True)
|
||||
|
||||
def populate(
|
||||
self, listing: Listing, mappings: Iterable[Mapping], renderer: Renderer
|
||||
) -> None:
|
||||
"""
|
||||
Populate listing with tags featured in the mappings.
|
||||
|
||||
Arguments:
|
||||
listing: The listing.
|
||||
mappings: The mappings.
|
||||
renderer: The renderer.
|
||||
"""
|
||||
page = listing.page
|
||||
assert isinstance(page.content, str)
|
||||
|
||||
# Add mappings to listing
|
||||
for mapping in mappings:
|
||||
listing.add(mapping)
|
||||
|
||||
# Sort listings and tags - we can only do this after all mappings have
|
||||
# been added to the listing, because the tags inside the mappings do
|
||||
# not have a proper order yet, and we need to order them as specified
|
||||
# in the listing configuration.
|
||||
listing.tags = self._sort_listing_tags(listing.tags)
|
||||
|
||||
# Render tags for listing headlines - the listing configuration allows
|
||||
# tp specify a custom layout, so we resolve the template for tags here
|
||||
name = posixpath.join(listing.config.layout, "tag.html")
|
||||
for tree in listing:
|
||||
tree.content = renderer.render(page, name, tag = tree.tag)
|
||||
|
||||
# Sort mappings and subtrees of listing tree
|
||||
tree.mappings = self._sort_listing(tree.mappings)
|
||||
tree.children = self._sort_listing_tags(tree.children)
|
||||
|
||||
# Replace callback
|
||||
def replace(match: Match) -> str:
|
||||
hx = match.group()
|
||||
|
||||
# Populate listing with anchor links to tags
|
||||
anchors = toc.populate(listing, self._slugify)
|
||||
if not anchors:
|
||||
return
|
||||
|
||||
# Get reference to first tag in listing
|
||||
head = next(iter(anchors.values()))
|
||||
|
||||
# Replace hx with actual level of listing and listing ids with
|
||||
# placeholders to create a format string for the headline
|
||||
hx = re.sub(
|
||||
r"<(/?)h{}\b".format(self.depth),
|
||||
r"<\g<1>h{}".format(head.level), hx
|
||||
)
|
||||
hx = re.sub(
|
||||
r"{id}\/(\w+)".format(id = listing.id),
|
||||
r"{\1}", hx, flags = re.I | re.M
|
||||
)
|
||||
|
||||
# Render listing headlines
|
||||
for tree in listing:
|
||||
tree.content = hx.format(
|
||||
slug = anchors[tree.tag].id,
|
||||
name = tree.content
|
||||
)
|
||||
|
||||
# Render listing - the listing configuration allows to specify a
|
||||
# custom layout, so we resolve the template for listings here
|
||||
name = posixpath.join(listing.config.layout, "listing.html")
|
||||
return "\n".join([
|
||||
renderer.render(page, name, listing = tree)
|
||||
for tree in listing.tags.values()
|
||||
])
|
||||
|
||||
# Hack: replace hx headlines (injection points) we added when parsing
|
||||
# the page's Markdown with the actual listing content. Additionally,
|
||||
# replace anchor links in the table of contents with the hierarchy
|
||||
# generated from mapping over the listing, or remove them.
|
||||
page.content = re.sub(
|
||||
r"<h{x}[^>]+{id}.*?</h{x}>".format(
|
||||
id = f"{listing.id}/slug", x = self.depth
|
||||
),
|
||||
replace, page.content, flags = re.I | re.M
|
||||
)
|
||||
|
||||
def populate_all(
|
||||
self, mappings: Iterable[Mapping], renderer: Renderer
|
||||
) -> None:
|
||||
"""
|
||||
Populate all listings with tags featured in the mappings.
|
||||
|
||||
This method is called by the tags plugin to populate all listings with
|
||||
the given mappings. It will also remove the injection points from the
|
||||
page's Markdown. Note that this method is intended to be called during
|
||||
the `on_env` event, after all pages have been rendered.
|
||||
|
||||
Arguments:
|
||||
mappings: The mappings.
|
||||
renderer: The renderer.
|
||||
"""
|
||||
for listing in self.data:
|
||||
self.populate(listing, mappings, renderer)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _resolve(self, page: Page, args: str) -> ListingConfig:
|
||||
"""
|
||||
Resolve listing configuration.
|
||||
|
||||
Arguments:
|
||||
page: The page the listing in embedded in.
|
||||
args: The arguments, as parsed from Markdown.
|
||||
|
||||
Returns:
|
||||
The listing configuration.
|
||||
"""
|
||||
data = yaml.safe_load(args)
|
||||
path = page.file.abs_src_path
|
||||
|
||||
# Try to resolve available listing configuration
|
||||
if isinstance(data, str):
|
||||
config = self.config.listings_map.get(data, None)
|
||||
if not config:
|
||||
keys = ", ".join(self.config.listings_map.keys())
|
||||
raise PluginError(
|
||||
f"Couldn't find listing configuration: {data}. Available "
|
||||
f"configurations: {keys}"
|
||||
)
|
||||
|
||||
# Otherwise, handle inline listing configuration
|
||||
else:
|
||||
config = ListingConfig(config_file_path = path)
|
||||
config.load_dict(data or {})
|
||||
|
||||
# Validate listing configuration
|
||||
errors, warnings = config.validate()
|
||||
for _, w in warnings:
|
||||
path = os.path.relpath(path)
|
||||
log.warning(
|
||||
f"Error reading listing configuration in '{path}':\n"
|
||||
f"{w}"
|
||||
)
|
||||
for _, e in errors:
|
||||
path = os.path.relpath(path)
|
||||
raise PluginError(
|
||||
f"Error reading listing configuration in '{path}':\n"
|
||||
f"{e}"
|
||||
)
|
||||
|
||||
# Inherit layout configuration, unless explicitly set
|
||||
if not isinstance(config.layout, str):
|
||||
config.layout = self.config.listings_layout
|
||||
|
||||
# Return listing configuration
|
||||
return config
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _slugify(self, tag: Tag) -> str:
|
||||
"""
|
||||
Slugify tag.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
|
||||
Returns:
|
||||
The slug.
|
||||
"""
|
||||
return self.config.tags_slugify_format.format(
|
||||
slug = self.config.tags_slugify(
|
||||
tag.name,
|
||||
self.config.tags_slugify_separator
|
||||
)
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _sort_listing(
|
||||
self, mappings: Iterable[Mapping]
|
||||
) -> list[Mapping]:
|
||||
"""
|
||||
Sort listing.
|
||||
|
||||
When sorting a listing, we sort the mappings of the listing, which is
|
||||
why the caller must pass the mappings of the listing. That way, we can
|
||||
keep this implementation to be purely functional, without having to
|
||||
mutate the listing, which makes testing simpler.
|
||||
|
||||
Arguments:
|
||||
mappings: The mappings.
|
||||
|
||||
Returns:
|
||||
The sorted mappings.
|
||||
"""
|
||||
return sorted(
|
||||
mappings,
|
||||
key = self.config.listings_sort_by,
|
||||
reverse = self.config.listings_sort_reverse
|
||||
)
|
||||
|
||||
def _sort_listing_tags(
|
||||
self, children: dict[Tag, ListingTree]
|
||||
) -> dict[Tag, ListingTree]:
|
||||
"""
|
||||
Sort listing tags.
|
||||
|
||||
When sorting a listing's tags, we sort the immediate subtrees of the
|
||||
listing, which is why the caller must pass the children of the listing.
|
||||
That way, we can keep this implementation to be purely functional,
|
||||
without having to mutate the listing.
|
||||
|
||||
Arguments:
|
||||
children: The listing trees, each of which associated with a tag.
|
||||
|
||||
Returns:
|
||||
The sorted listing trees.
|
||||
"""
|
||||
return dict(sorted(
|
||||
children.items(),
|
||||
key = lambda item: self.config.listings_tags_sort_by(*item),
|
||||
reverse = self.config.listings_tags_sort_reverse
|
||||
))
|
||||
|
||||
def _sort_tags(
|
||||
self, tags: Iterable[Tag]
|
||||
) -> list[Tag]:
|
||||
"""
|
||||
Sort tags.
|
||||
|
||||
Arguments:
|
||||
tags: The tags.
|
||||
|
||||
Returns:
|
||||
The sorted tags.
|
||||
"""
|
||||
return sorted(
|
||||
tags,
|
||||
key = self.config.tags_sort_by,
|
||||
reverse = self.config.tags_sort_reverse
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _print(manager: ListingManager, indent: int = 0) -> str:
|
||||
"""
|
||||
Return a printable representation of a listing manager.
|
||||
|
||||
Arguments:
|
||||
manager: The listing manager.
|
||||
indent: The indentation level.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append(" " * indent + f"ListingManager()")
|
||||
|
||||
# Print listings
|
||||
for listing in manager:
|
||||
lines.append(" " * (indent + 2) + repr(listing))
|
||||
|
||||
# Concatenate everything
|
||||
return "\n".join(lines)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.plugins.tags")
|
||||
134
src/plugins/tags/structure/listing/manager/toc.py
Normal file
134
src/plugins/tags/structure/listing/manager/toc.py
Normal file
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from material.plugins.tags.structure.listing import Listing
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.structure.toc import AnchorLink
|
||||
from typing import Callable
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Typings
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
Slugify = Callable[[Tag], str]
|
||||
"""
|
||||
Slugify function.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
|
||||
Returns:
|
||||
The slugified tag.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def populate(listing: Listing, slugify: Slugify) -> dict[Tag, AnchorLink]:
|
||||
"""
|
||||
Populate the table of contents of the page the listing is embedded.
|
||||
|
||||
Arguments:
|
||||
listing: The listing.
|
||||
slugify: Slugify function.
|
||||
|
||||
Returns:
|
||||
The mapping of tags to anchor links.
|
||||
"""
|
||||
anchors: dict[Tag, AnchorLink] = {}
|
||||
|
||||
# Find injection point for listing
|
||||
host, at = find(listing)
|
||||
if at == -1:
|
||||
return anchors
|
||||
|
||||
# Create anchor links
|
||||
for tree in listing:
|
||||
|
||||
# Iterate over expanded tags
|
||||
for i, tag in enumerate(reversed([*tree.tag])):
|
||||
if tag not in anchors:
|
||||
level = host.level + 1 + i
|
||||
|
||||
# Create anchor link
|
||||
anchors[tag] = AnchorLink(tag.name, slugify(tag), level)
|
||||
if not tag.parent:
|
||||
continue
|
||||
|
||||
# Relate anchor link to parent
|
||||
anchors[tag.parent].children.append(anchors[tag])
|
||||
|
||||
# Filter top-level anchor links and insert them into the page
|
||||
children = [anchors[tag] for tag in anchors if not tag.parent]
|
||||
if listing.config.toc:
|
||||
host.children[at:at + 1] = children
|
||||
else:
|
||||
host.children.pop(at)
|
||||
|
||||
# Return mapping of tags to anchor links
|
||||
return anchors
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def find(listing: Listing) -> tuple[AnchorLink | None, int]:
|
||||
"""
|
||||
Find anchor link for the given listing.
|
||||
|
||||
This function traverses the table of contents of the given page and returns
|
||||
the anchor's parent and index of the anchor with the given identifier. If
|
||||
the anchor is on the root level, and the anchor we're looking for is an
|
||||
injection point, an anchor to host the tags is created and returned.
|
||||
|
||||
Arguments:
|
||||
lising: The listing.
|
||||
|
||||
Returns:
|
||||
The anchor and index.
|
||||
"""
|
||||
page = listing.page
|
||||
|
||||
# Traverse table of contents
|
||||
stack = list(page.toc)
|
||||
while stack:
|
||||
anchor = stack.pop()
|
||||
|
||||
# Traverse children
|
||||
for i, child in enumerate(anchor.children):
|
||||
if child.id.startswith(listing.id):
|
||||
return anchor, i
|
||||
|
||||
# Add child to stack
|
||||
stack.append(child)
|
||||
|
||||
# Check if anchor is on the root level
|
||||
for i, anchor in enumerate(page.toc):
|
||||
if anchor.id.startswith(listing.id):
|
||||
|
||||
# Create anchor link
|
||||
host = AnchorLink(page.title, page.url, 1)
|
||||
host.children = page.toc.items
|
||||
return host, i
|
||||
|
||||
# Anchor could not be found
|
||||
return None, -1
|
||||
160
src/plugins/tags/structure/listing/tree/__init__.py
Normal file
160
src/plugins/tags/structure/listing/tree/__init__.py
Normal file
@@ -0,0 +1,160 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from functools import total_ordering
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@total_ordering
|
||||
class ListingTree:
|
||||
"""
|
||||
A listing tree.
|
||||
|
||||
This is an internal data structure that is used to render listings. It is
|
||||
also the immediate structure that is passed to the template.
|
||||
"""
|
||||
|
||||
def __init__(self, tag: Tag):
|
||||
"""
|
||||
Initialize the listing tree.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
"""
|
||||
self.tag = tag
|
||||
self.content = None
|
||||
self.mappings = []
|
||||
self.children = {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the listing tree.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return _print(self)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Return the hash of the listing tree.
|
||||
|
||||
Returns:
|
||||
The hash.
|
||||
"""
|
||||
return hash(self.tag)
|
||||
|
||||
def __iter__(self) -> Iterator[ListingTree]:
|
||||
"""
|
||||
Iterate over subtrees of the listing tree.
|
||||
|
||||
Yields:
|
||||
The current subtree.
|
||||
"""
|
||||
return iter(self.children.values())
|
||||
|
||||
def __eq__(self, other: ListingTree) -> bool:
|
||||
"""
|
||||
Check if the listing tree is equal to another listing tree.
|
||||
|
||||
Arguments:
|
||||
other: The other listing tree to check.
|
||||
|
||||
Returns:
|
||||
Whether the listing trees are equal.
|
||||
"""
|
||||
assert isinstance(other, ListingTree)
|
||||
return self.tag == other.tag
|
||||
|
||||
def __lt__(self, other: ListingTree) -> bool:
|
||||
"""
|
||||
Check if the listing tree is less than another listing tree.
|
||||
|
||||
Arguments:
|
||||
other: The other listing tree to check.
|
||||
|
||||
Returns:
|
||||
Whether the listing tree is less than the other listing tree.
|
||||
"""
|
||||
assert isinstance(other, ListingTree)
|
||||
return self.tag < other.tag
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
tag: Tag
|
||||
"""
|
||||
The tag.
|
||||
"""
|
||||
|
||||
content: str | None
|
||||
"""
|
||||
The rendered content of the listing tree.
|
||||
|
||||
This attribute holds the result of rendering the `tag.html` template, which
|
||||
is the rendered tag as displayed in the listing. It is essential that this
|
||||
is done for all tags (and nested tags) before rendering the tree, as the
|
||||
rendering process of the listing tree relies on this attribute.
|
||||
"""
|
||||
|
||||
mappings: list[Mapping]
|
||||
"""
|
||||
The mappings associated with the tag.
|
||||
"""
|
||||
|
||||
children: dict[Tag, ListingTree]
|
||||
"""
|
||||
The subtrees of the listing tree.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _print(tree: ListingTree, indent: int = 0) -> str:
|
||||
"""
|
||||
Return a printable representation of a listing tree.
|
||||
|
||||
Arguments:
|
||||
tree: The listing tree.
|
||||
indent: The indentation level.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append(" " * indent + f"ListingTree({repr(tree.tag)})")
|
||||
|
||||
# Print mappings
|
||||
for mapping in tree.mappings:
|
||||
lines.append(" " * (indent + 2) + repr(mapping))
|
||||
|
||||
# Print subtrees
|
||||
for child in tree.children.values():
|
||||
lines.append(_print(child, indent + 2))
|
||||
|
||||
# Concatenate everything
|
||||
return "\n".join(lines)
|
||||
98
src/plugins/tags/structure/mapping/__init__.py
Normal file
98
src/plugins/tags/structure/mapping/__init__.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from mkdocs.structure.nav import Link
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class Mapping:
|
||||
"""
|
||||
A mapping between a page or link and a set of tags.
|
||||
|
||||
We use this class to store the mapping between a page or link and a set of
|
||||
tags. This is necessary as we don't want to store the tags directly on the
|
||||
page or link object, in order not to clutter the internal data structures
|
||||
of MkDocs, keeping the plugin as unobtrusive as possible.
|
||||
|
||||
Links are primarily used when integrating with tags from external projects,
|
||||
as we can't construct a page object for them as we do for local files.
|
||||
"""
|
||||
|
||||
def __init__(self, item: Page | Link, *, tags: Iterable[Tag] | None = None):
|
||||
"""
|
||||
Initialize the mapping.
|
||||
|
||||
Tags can be passed upon initialization, but can also be added later on
|
||||
using the `add` or `update` method. of the `tags` attribute.
|
||||
|
||||
Arguments:
|
||||
item: The page or link.
|
||||
tags: The tags associated with the page or link.
|
||||
"""
|
||||
self.item = item
|
||||
self.tags = set(tags or [])
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the mapping.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return f"Mapping({repr(self.item)}, tags={self.tags})"
|
||||
|
||||
def __and__(self, tags: set[Tag]) -> Iterator[Tag]:
|
||||
"""
|
||||
Iterate over the tags featured in the mapping.
|
||||
|
||||
This method expands each tag in the mapping and checks whether it is
|
||||
equal to one of the tags in the given set. If so, the tag is yielded.
|
||||
|
||||
Arguments:
|
||||
tags: The set of tags.
|
||||
|
||||
Yields:
|
||||
The current tag.
|
||||
"""
|
||||
assert isinstance(tags, set)
|
||||
|
||||
# Iterate over expanded tags
|
||||
for tag in self.tags:
|
||||
if set(tag) & tags:
|
||||
yield tag
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
item: Page | Link
|
||||
"""
|
||||
The page or link.
|
||||
"""
|
||||
|
||||
tags: set[Tag]
|
||||
"""
|
||||
The tags associated with the page or link.
|
||||
"""
|
||||
169
src/plugins/tags/structure/mapping/manager/__init__.py
Normal file
169
src/plugins/tags/structure/mapping/manager/__init__.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from material.plugins.tags.config import TagsConfig
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag.options import TagSet
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class MappingManager:
|
||||
"""
|
||||
A mapping manager.
|
||||
|
||||
The mapping manager is responsible for collecting all tags from the front
|
||||
matter of pages, and for building a tag structure from them, nothing more.
|
||||
"""
|
||||
|
||||
def __init__(self, config: TagsConfig):
|
||||
"""
|
||||
Initialize the mapping manager.
|
||||
|
||||
Arguments:
|
||||
config: The configuration.
|
||||
"""
|
||||
self.config = config
|
||||
self.format = TagSet(allowed = self.config.tags_allowed)
|
||||
self.data = {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the mapping manager.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return _print(self)
|
||||
|
||||
def __iter__(self) -> Iterator[Mapping]:
|
||||
"""
|
||||
Iterate over mappings.
|
||||
|
||||
Yields:
|
||||
The current mapping.
|
||||
"""
|
||||
return iter(self.data.values())
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
config: TagsConfig
|
||||
"""
|
||||
The configuration.
|
||||
"""
|
||||
|
||||
format: TagSet
|
||||
"""
|
||||
The mapping format.
|
||||
|
||||
This is the validator that is used to check if tags are valid, including
|
||||
the tags in the front matter of pages, as well as the tags defined in the
|
||||
configuration. Numbers and booleans are always converted to strings before
|
||||
creating tags, and the allow list is checked as well, if given.
|
||||
"""
|
||||
|
||||
data: dict[str, Mapping]
|
||||
"""
|
||||
The mappings.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def add(self, page: Page, markdown: str) -> Mapping | None:
|
||||
"""
|
||||
Add page.
|
||||
|
||||
This method is called by the tags plugin to retrieve all tags of a page.
|
||||
It extracts all tags from the front matter of the given page, and adds
|
||||
them to the mapping. If no tags are found, no mapping is created and
|
||||
nothing is returned.
|
||||
|
||||
Note that this method is intended to be called with the page during the
|
||||
`on_page_markdown` event, as it reads the front matter of a page. Also,
|
||||
the Markdown must be explicitly passed, as we could otherwise run into
|
||||
inconsistencies when other plugins modify the Markdown.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
markdown: The page's Markdown.
|
||||
|
||||
Returns:
|
||||
The mapping or nothing.
|
||||
"""
|
||||
assert isinstance(markdown, str)
|
||||
|
||||
# Return nothing if page doesn't have tags
|
||||
tags = self.config.tags_name_property
|
||||
if not page.meta.get(tags, []):
|
||||
return
|
||||
|
||||
# Create mapping and associate with page
|
||||
mapping = Mapping(page)
|
||||
self.data[page.url] = mapping
|
||||
|
||||
# Retrieve and validate tags, and add to mapping
|
||||
for tag in self.format.validate(page.meta[tags]):
|
||||
mapping.tags.add(tag)
|
||||
|
||||
# Return mapping
|
||||
return mapping
|
||||
|
||||
def get(self, page: Page) -> Mapping | None:
|
||||
"""
|
||||
Get mapping for page, if any.
|
||||
|
||||
Arguments:
|
||||
page: The page.
|
||||
|
||||
Returns:
|
||||
The mapping or nothing.
|
||||
"""
|
||||
if page.url in self.data:
|
||||
return self.data[page.url]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _print(manager: MappingManager, indent: int = 0) -> str:
|
||||
"""
|
||||
Return a printable representation of a mapping manager.
|
||||
|
||||
Arguments:
|
||||
manager: The mapping manager.
|
||||
indent: The indentation level.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
lines.append(" " * indent + f"MappingManager()")
|
||||
|
||||
# Print mappings
|
||||
for mapping in manager:
|
||||
lines.append(" " * (indent + 2) + repr(mapping))
|
||||
|
||||
# Concatenate everything
|
||||
return "\n".join(lines)
|
||||
211
src/plugins/tags/structure/mapping/storage/__init__.py
Normal file
211
src/plugins/tags/structure/mapping/storage/__init__.py
Normal file
@@ -0,0 +1,211 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from collections.abc import Iterable
|
||||
from material.plugins.tags.config import TagsConfig
|
||||
from material.plugins.tags.structure.mapping import Mapping
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from mkdocs.config.base import ValidationError
|
||||
from mkdocs.structure.nav import Link
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class MappingStorage:
|
||||
"""
|
||||
A mapping storage.
|
||||
|
||||
The mapping storage allows to save and load mappings to and from a JSON
|
||||
file, which allows for sharing tags across multiple MkDocs projects.
|
||||
"""
|
||||
|
||||
def __init__(self, config: TagsConfig):
|
||||
"""
|
||||
Initialize the mapping storage.
|
||||
|
||||
Arguments:
|
||||
config: The configuration.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
config: TagsConfig
|
||||
"""
|
||||
The configuration.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def save(self, path: str, mappings: Iterable[Mapping]) -> None:
|
||||
"""
|
||||
Save mappings to file.
|
||||
|
||||
Arguments:
|
||||
path: The file path.
|
||||
mappings: The mappings.
|
||||
"""
|
||||
path = os.path.abspath(path)
|
||||
os.makedirs(os.path.dirname(path), exist_ok = True)
|
||||
|
||||
# Save serialized mappings to file
|
||||
with open(path, "w", encoding = "utf-8") as f:
|
||||
data = [_mapping_to_json(mapping) for mapping in mappings]
|
||||
json.dump(dict(mappings = data), f)
|
||||
|
||||
def load(self, path: str) -> Iterable[Mapping]:
|
||||
"""
|
||||
Load mappings from file.
|
||||
|
||||
Arguments:
|
||||
path: The file path.
|
||||
|
||||
Yields:
|
||||
The current mapping.
|
||||
"""
|
||||
with open(path, "r", encoding = "utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Ensure root dictionary
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"Expected dictionary, but received: {data}"
|
||||
)
|
||||
|
||||
# Ensure mappings are iterable
|
||||
mappings = data.get("mappings")
|
||||
if not isinstance(mappings, list):
|
||||
raise ValidationError(
|
||||
f"Expected list, but received: {mappings}"
|
||||
)
|
||||
|
||||
# Create and yield mappings
|
||||
for mapping in mappings:
|
||||
yield _mapping_from_json(mapping)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def _mapping_to_json(mapping: Mapping) -> dict:
|
||||
"""
|
||||
Return a serializable representation of a mapping.
|
||||
|
||||
Arguments:
|
||||
mapping: The mapping.
|
||||
|
||||
Returns:
|
||||
Serializable representation.
|
||||
"""
|
||||
return dict(
|
||||
item = _mapping_item_to_json(mapping.item),
|
||||
tags = [str(tag) for tag in sorted(mapping.tags)]
|
||||
)
|
||||
|
||||
def _mapping_item_to_json(item: Page | Link) -> dict:
|
||||
"""
|
||||
Return a serializable representation of a page or link.
|
||||
|
||||
Arguments:
|
||||
item: The page or link.
|
||||
|
||||
Returns:
|
||||
Serializable representation.
|
||||
"""
|
||||
return dict(url = item.url, title = item.title)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _mapping_from_json(data: object) -> Mapping:
|
||||
"""
|
||||
Return a mapping from a serialized representation.
|
||||
|
||||
Arguments:
|
||||
data: Serialized representation.
|
||||
|
||||
Returns:
|
||||
The mapping.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"Expected dictionary, but received: {data}"
|
||||
)
|
||||
|
||||
# Ensure tags are iterable
|
||||
tags = data.get("tags")
|
||||
if not isinstance(tags, list):
|
||||
raise ValidationError(
|
||||
f"Expected list, but received: {tags}"
|
||||
)
|
||||
|
||||
# Ensure tags are valid
|
||||
for tag in tags:
|
||||
if not isinstance(tag, str):
|
||||
raise ValidationError(
|
||||
f"Expected string, but received: {tag}"
|
||||
)
|
||||
|
||||
# Create and return mapping
|
||||
return Mapping(
|
||||
_mapping_item_from_json(data.get("item")),
|
||||
tags = [Tag(tag) for tag in tags]
|
||||
)
|
||||
|
||||
def _mapping_item_from_json(data: object) -> Link:
|
||||
"""
|
||||
Return a link from a serialized representation.
|
||||
|
||||
When loading a mapping, we must always return a link, as the sources of
|
||||
pages might not be available because we're building another project.
|
||||
|
||||
Arguments:
|
||||
data: Serialized representation.
|
||||
|
||||
Returns:
|
||||
The link.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"Expected dictionary, but received: {data}"
|
||||
)
|
||||
|
||||
# Ensure item has URL
|
||||
url = data.get("url")
|
||||
if not isinstance(url, str):
|
||||
raise ValidationError(
|
||||
f"Expected string, but received: {url}"
|
||||
)
|
||||
|
||||
# Ensure item has title
|
||||
title = data.get("title")
|
||||
if not isinstance(title, str):
|
||||
raise ValidationError(
|
||||
f"Expected string, but received: {title}"
|
||||
)
|
||||
|
||||
# Create and return item
|
||||
return Link(title, url)
|
||||
149
src/plugins/tags/structure/tag/__init__.py
Normal file
149
src/plugins/tags/structure/tag/__init__.py
Normal file
@@ -0,0 +1,149 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from functools import total_ordering
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@total_ordering
|
||||
class Tag:
|
||||
"""
|
||||
A tag.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, *, parent: Tag | None = None, hidden = False
|
||||
):
|
||||
"""
|
||||
Initialize the tag.
|
||||
|
||||
Arguments:
|
||||
name: The tag name.
|
||||
parent: The parent tag.
|
||||
hidden: Whether the tag is hidden.
|
||||
"""
|
||||
self.name = name
|
||||
self.parent = parent
|
||||
self.hidden = hidden
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the tag.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return f"Tag('{self.name}')"
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Return a string representation of the tag.
|
||||
|
||||
Returns:
|
||||
String representation.
|
||||
"""
|
||||
return self.name
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Return the hash of the tag.
|
||||
|
||||
Returns:
|
||||
The hash.
|
||||
"""
|
||||
return hash(self.name)
|
||||
|
||||
def __iter__(self) -> Iterator[Tag]:
|
||||
"""
|
||||
Iterate over the tag and its parent tags.
|
||||
|
||||
Note that the first tag returned is the tag itself, followed by its
|
||||
parent tags in ascending order. This allows to iterate over the tag
|
||||
and its parents in a single loop, which is useful for generating
|
||||
tree or breadcrumb structures.
|
||||
|
||||
Yields:
|
||||
The current tag.
|
||||
"""
|
||||
tag = self
|
||||
while tag:
|
||||
yield tag
|
||||
tag = tag.parent
|
||||
|
||||
def __contains__(self, other: Tag) -> bool:
|
||||
"""
|
||||
Check if the tag contains another tag.
|
||||
|
||||
Arguments:
|
||||
other: The other tag to check.
|
||||
|
||||
Returns:
|
||||
Whether the tag contains the other tag.
|
||||
"""
|
||||
assert isinstance(other, Tag)
|
||||
return any(tag == other for tag in self)
|
||||
|
||||
def __eq__(self, other: Tag) -> bool:
|
||||
"""
|
||||
Check if the tag is equal to another tag.
|
||||
|
||||
Arguments:
|
||||
other: The other tag to check.
|
||||
|
||||
Returns:
|
||||
Whether the tags are equal.
|
||||
"""
|
||||
assert isinstance(other, Tag)
|
||||
return self.name == other.name
|
||||
|
||||
def __lt__(self, other: Tag) -> bool:
|
||||
"""
|
||||
Check if the tag is less than another tag.
|
||||
|
||||
Arguments:
|
||||
other: The other tag to check.
|
||||
|
||||
Returns:
|
||||
Whether the tag is less than the other tag.
|
||||
"""
|
||||
assert isinstance(other, Tag)
|
||||
return self.name < other.name
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
name: str
|
||||
"""
|
||||
The tag name.
|
||||
"""
|
||||
|
||||
parent: Tag | None
|
||||
"""
|
||||
The parent tag.
|
||||
"""
|
||||
|
||||
hidden: bool
|
||||
"""
|
||||
Whether the tag is hidden.
|
||||
"""
|
||||
108
src/plugins/tags/structure/tag/options.py
Normal file
108
src/plugins/tags/structure/tag/options.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from mkdocs.config.base import BaseConfigOption, ValidationError
|
||||
from typing import Set
|
||||
|
||||
from . import Tag
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class TagSet(BaseConfigOption[Set[Tag]]):
|
||||
"""
|
||||
Setting for a set of tags.
|
||||
|
||||
This setting describes a set of tags, and is used to validate the actual
|
||||
tags as defined in the front matter of pages, as well as for filters that
|
||||
are used to include or exclude pages from a listing and to check if a tag
|
||||
is allowed to be used.
|
||||
"""
|
||||
|
||||
def __init__(self, *, allowed: set[Tag] = set()):
|
||||
"""
|
||||
Initialize the setting.
|
||||
|
||||
Arguments:
|
||||
allowed: The tags allowed to be used.
|
||||
"""
|
||||
super().__init__()
|
||||
self.allowed = allowed
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
allowed: set[Tag]
|
||||
"""
|
||||
The tags allowed to be used.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def run_validation(self, value: object) -> set[Tag]:
|
||||
"""
|
||||
Validate list of tags.
|
||||
|
||||
If the value is `None`, an empty set is returned. Otherwise, the value
|
||||
is expected to be a list of tags, which is converted to a set of tags.
|
||||
This means that tags are automatically deduplicated. Note that tags are
|
||||
not expanded here, as the set is intended to be checked exactly.
|
||||
|
||||
Arguments:
|
||||
value: The value to validate.
|
||||
|
||||
Returns:
|
||||
A set of tags.
|
||||
"""
|
||||
if value is None:
|
||||
return set()
|
||||
|
||||
# Ensure tags are iterable
|
||||
if not isinstance(value, Iterable) or isinstance(value, str):
|
||||
raise ValidationError(
|
||||
f"Expected iterable tags, but received: {value}"
|
||||
)
|
||||
|
||||
# Ensure tags are valid
|
||||
tags: set[Tag] = set()
|
||||
for index, tag in enumerate(value):
|
||||
if not isinstance(tag, (str, int, float, bool)):
|
||||
raise ValidationError(
|
||||
f"Expected a {str}, {int}, {float} or {bool} "
|
||||
f"but received: {type(tag)} at index {index}"
|
||||
)
|
||||
|
||||
# Coerce tag to string and add to set
|
||||
tags.add(Tag(str(tag)))
|
||||
|
||||
# Ensure tags are in allow list, if any
|
||||
if self.allowed:
|
||||
invalid = tags.difference(self.allowed)
|
||||
if invalid:
|
||||
raise ValidationError(
|
||||
"Tags not in allow list: " +
|
||||
",".join([tag.name for tag in invalid])
|
||||
)
|
||||
|
||||
# Return set of tags
|
||||
return tags
|
||||
80
src/plugins/tags/structure/tag/reference/__init__.py
Normal file
80
src/plugins/tags/structure/tag/reference/__init__.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from material.plugins.tags.structure.tag import Tag
|
||||
from mkdocs.structure.nav import Link
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class TagReference(Tag):
|
||||
"""
|
||||
A tag reference.
|
||||
|
||||
Tag references are a subclass of tags that can have associated links, which
|
||||
is primarily used for linking tags to listings. The first link is used as
|
||||
the canonical link, which by default points to the closest listing that
|
||||
features the tag. This is considered to be the canonical listing.
|
||||
"""
|
||||
|
||||
def __init__(self, tag: Tag, links: list[Link] | None = None):
|
||||
"""
|
||||
Initialize the tag reference.
|
||||
|
||||
Arguments:
|
||||
tag: The tag.
|
||||
links: The links associated with the tag.
|
||||
"""
|
||||
super().__init__(**vars(tag))
|
||||
self.links = links or []
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Return a printable representation of the tag reference.
|
||||
|
||||
Returns:
|
||||
Printable representation.
|
||||
"""
|
||||
return f"TagReference('{self.name}')"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
links: list[Link]
|
||||
"""
|
||||
The links associated with the tag.
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def url(self) -> str | None:
|
||||
"""
|
||||
Return the URL of the tag reference.
|
||||
|
||||
Returns:
|
||||
The URL of the tag reference.
|
||||
"""
|
||||
if self.links:
|
||||
return self.links[0].url
|
||||
else:
|
||||
return None
|
||||
@@ -61,7 +61,7 @@
|
||||
<span class="md-profile__description">
|
||||
<strong>
|
||||
{% if author.url %}
|
||||
<a href="{{ author.url }}">{{ author.name }}</a>
|
||||
<a href="{{ author.url | url }}">{{ author.name }}</a>
|
||||
{% else %}
|
||||
{{ author.name }}
|
||||
{% endif %}
|
||||
@@ -148,6 +148,29 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<!-- Related links -->
|
||||
{% if page.config.links %}
|
||||
<ul class="md-post__meta md-nav__list">
|
||||
<li class="md-nav__item md-nav__item--section">
|
||||
<div class="md-post__title">
|
||||
<span class="md-ellipsis">
|
||||
{{ lang.t("blog.references") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Render related links -->
|
||||
<nav class="md-nav">
|
||||
<ul class="md-nav__list">
|
||||
{% for nav_item in page.config.links %}
|
||||
{% set path = "__ref_" ~ loop.index %}
|
||||
{{ item.render(nav_item, path, 1) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
41
src/templates/fragments/tags/default/listing.html
Normal file
41
src/templates/fragments/tags/default/listing.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!--
|
||||
Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
-->
|
||||
|
||||
{% macro render(listing) %}
|
||||
{{ listing.content }}
|
||||
<ul>
|
||||
{% for mapping in listing.mappings %}
|
||||
<li>
|
||||
<a href="{{ mapping.item.url | url }}">
|
||||
{{ mapping.item.title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% for child in listing %}
|
||||
<li style="list-style-type: none">{{ render(child) }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
|
||||
<!-- ---------------------------------------------------------------------- -->
|
||||
|
||||
{{ render(listing) }}
|
||||
38
src/templates/fragments/tags/default/tag.html
Normal file
38
src/templates/fragments/tags/default/tag.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!--
|
||||
Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
-->
|
||||
|
||||
<!-- Determine classes -->
|
||||
{% set class = "md-tag" %}
|
||||
{% if tag.hidden %}
|
||||
{% set class = class ~ " md-tag-shadow" %}
|
||||
{% endif %}
|
||||
{% if config.extra.tags %}
|
||||
{% set class = class ~ " md-tag-icon" %}
|
||||
{% if tag.name in config.extra.tags %}
|
||||
{% set class = class ~ " md-tag--" ~ config.extra.tags[tag.name] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Render tag -->
|
||||
<span class="{{ class }}">
|
||||
{{- tag.name -}}
|
||||
</span>
|
||||
@@ -21,9 +21,7 @@
|
||||
-->
|
||||
|
||||
<!-- Tags -->
|
||||
{% if "material/tags" in config.plugins and tags %}
|
||||
{% include "partials/tags.html" %}
|
||||
{% endif %}
|
||||
{% include "partials/tags.html" %}
|
||||
|
||||
<!-- Actions -->
|
||||
{% include "partials/actions.html" %}
|
||||
|
||||
@@ -42,17 +42,23 @@
|
||||
{% macro render_content(nav_item, ref = nav_item) %}
|
||||
|
||||
<!-- Navigation link icon -->
|
||||
{% if nav_item.is_page and nav_item.meta.icon %}
|
||||
{% if nav_item.meta and nav_item.meta.icon %}
|
||||
{% include ".icons/" ~ nav_item.meta.icon ~ ".svg" %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Navigation link title -->
|
||||
<span class="md-ellipsis">
|
||||
{{ ref.title }}
|
||||
|
||||
<!-- Navigation link subtitle -->
|
||||
{% if nav_item.meta and nav_item.meta.subtitle %}
|
||||
<br />
|
||||
<small>{{ nav_item.meta.subtitle }}</small>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
<!-- Navigation link status -->
|
||||
{% if nav_item.is_page and nav_item.meta.status %}
|
||||
{% if nav_item.meta and nav_item.meta.status %}
|
||||
{{ render_status(nav_item, nav_item.meta.status) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
@@ -26,27 +26,31 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Tags -->
|
||||
<nav class="md-tags" {{ hidden }}>
|
||||
{% for tag in tags %}
|
||||
{% set icon = "" %}
|
||||
{% if config.extra.tags %}
|
||||
{% set icon = " md-tag-icon" %}
|
||||
{% if tag.type %}
|
||||
{% set icon = icon ~ " md-tag--" ~ tag.type %}
|
||||
{% if tags %}
|
||||
<nav class="md-tags" {{ hidden }}>
|
||||
{% for tag in tags %}
|
||||
|
||||
<!-- Determine classes -->
|
||||
{% set class = "md-tag" %}
|
||||
{% if config.extra.tags %}
|
||||
{% set class = class ~ " md-tag-icon" %}
|
||||
{% if tag.name in config.extra.tags %}
|
||||
{% set class = class ~ " md-tag--" ~ config.extra.tags[tag.name] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Render tag with link -->
|
||||
{% if tag.url %}
|
||||
<a href="{{ tag.url | url }}" class="md-tag{{ icon }}">
|
||||
{{- tag.name -}}
|
||||
</a>
|
||||
<!-- Render tag with link -->
|
||||
{% if tag.url %}
|
||||
<a href="{{ tag.url | url }}" class="{{ class }}">
|
||||
{{- tag.name -}}
|
||||
</a>
|
||||
|
||||
<!-- Render tag without link -->
|
||||
{% else %}
|
||||
<span class="md-tag{{ icon }}">
|
||||
{{- tag.name -}}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<!-- Render tag without link -->
|
||||
{% else %}
|
||||
<span class="{{ class }}">
|
||||
{{- tag.name -}}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
19
src/utilities/__init__.py
Normal file
19
src/utilities/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
124
src/utilities/filter/__init__.py
Normal file
124
src/utilities/filter/__init__.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fnmatch import fnmatch
|
||||
from mkdocs.structure.files import File
|
||||
|
||||
from .config import FilterConfig
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class Filter:
|
||||
"""
|
||||
A filter.
|
||||
"""
|
||||
|
||||
def __init__(self, config: FilterConfig):
|
||||
"""
|
||||
Initialize the filter.
|
||||
|
||||
Arguments:
|
||||
config: The filter configuration.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
def __call__(self, value: str, ref: str | None = None) -> bool:
|
||||
"""
|
||||
Filter a value.
|
||||
|
||||
First, the inclusion patterns are checked. Regardless of whether they
|
||||
are present, the exclusion patterns are checked afterwards. This allows
|
||||
to exclude values that are included by the inclusion patterns, so that
|
||||
exclusion patterns can be used to refine inclusion patterns.
|
||||
|
||||
Arguments:
|
||||
value: The value to filter.
|
||||
ref: The value used for logging.
|
||||
|
||||
Returns:
|
||||
Whether the value should be included.
|
||||
"""
|
||||
ref = ref or value
|
||||
|
||||
# Check if value matches one of the inclusion patterns
|
||||
if self.config.include:
|
||||
for pattern in self.config.include:
|
||||
if fnmatch(value, pattern):
|
||||
break
|
||||
|
||||
# Value is not included
|
||||
else:
|
||||
log.debug(f"Excluding '{ref}' due to inclusion patterns")
|
||||
return False
|
||||
|
||||
# Check if value matches one of the exclusion patterns
|
||||
for pattern in self.config.exclude:
|
||||
if fnmatch(value, pattern):
|
||||
log.debug(f"Excluding '{ref}' due to exclusion patterns")
|
||||
return False
|
||||
|
||||
# Value is not excluded
|
||||
return True
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
config: FilterConfig
|
||||
"""
|
||||
The filter configuration.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class FileFilter(Filter):
|
||||
"""
|
||||
A file filter.
|
||||
"""
|
||||
|
||||
def __call__(self, file: File) -> bool:
|
||||
"""
|
||||
Filter a file by its source path.
|
||||
|
||||
Arguments:
|
||||
file: The file to filter.
|
||||
|
||||
Returns:
|
||||
Whether the file should be included.
|
||||
"""
|
||||
if file.inclusion.is_excluded():
|
||||
return False
|
||||
|
||||
# Filter file by source path
|
||||
return super().__call__(
|
||||
file.src_uri,
|
||||
file.src_path
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Set up logging
|
||||
log = logging.getLogger("mkdocs.material.utilities")
|
||||
47
src/utilities/filter/config.py
Normal file
47
src/utilities/filter/config.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# Copyright (c) 2016-2025 Martin Donath <martin.donath@squidfunk.com>
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from mkdocs.config.base import Config
|
||||
from mkdocs.config.config_options import ListOfItems, Type
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classes
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class FilterConfig(Config):
|
||||
"""
|
||||
A filter configuration.
|
||||
"""
|
||||
|
||||
include = ListOfItems(Type(str), default = [])
|
||||
"""
|
||||
Patterns to include.
|
||||
|
||||
This list contains patterns that are matched against the value to filter.
|
||||
If the value matches at least one pattern, it will be included.
|
||||
"""
|
||||
|
||||
exclude = ListOfItems(Type(str), default = [])
|
||||
"""
|
||||
Patterns to exclude.
|
||||
|
||||
This list contains patterns that are matched against the value to filter.
|
||||
If the value matches at least one pattern, it will be excluded.
|
||||
"""
|
||||
Reference in New Issue
Block a user