diff options
Diffstat (limited to 'src/plugins/blog/structure')
| -rw-r--r-- | src/plugins/blog/structure/__init__.py | 292 | ||||
| -rw-r--r-- | src/plugins/blog/structure/config.py | 37 | ||||
| -rw-r--r-- | src/plugins/blog/structure/markdown.py | 58 | ||||
| -rw-r--r-- | src/plugins/blog/structure/options.py | 87 |
4 files changed, 474 insertions, 0 deletions
diff --git a/src/plugins/blog/structure/__init__.py b/src/plugins/blog/structure/__init__.py new file mode 100644 index 00000000..2fc541fe --- /dev/null +++ b/src/plugins/blog/structure/__init__.py @@ -0,0 +1,292 @@ +# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import logging +import os +import yaml + +from copy import copy +from markdown import Markdown +from material.plugins.blog.author import Author +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.exceptions import PluginError +from mkdocs.structure.files import File, Files +from mkdocs.structure.nav import Section +from mkdocs.structure.pages import Page, _RelativePathTreeprocessor +from mkdocs.structure.toc import get_toc +from mkdocs.utils.meta import YAML_RE +from re import Match +from yaml import SafeLoader + +from .config import PostConfig +from .markdown import ExcerptTreeprocessor + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + +# Post +class Post(Page): + + # Initialize post - posts are never listed in the navigation, which is why + # they will never include a title that was manually set, so we can omit it + def __init__(self, file: File, config: MkDocsConfig): + super().__init__(None, file, config) + + # Resolve path relative to docs directory + docs = os.path.relpath(config.docs_dir) + path = os.path.relpath(file.abs_src_path, docs) + + # Read contents and metadata immediately + with open(file.abs_src_path, encoding = "utf-8") as f: + self.markdown = f.read() + + # Sadly, MkDocs swallows any exceptions that occur during parsing. + # As we want to provide the best possible authoring experience, we + # need to catch errors early and display them nicely. We decided to + # drop support for MkDocs' MultiMarkdown syntax, because it is not + # correctly implemented anyway. When using MultiMarkdown syntax, all + # date formats are returned as strings and list are not properly + # supported. Thus, we just use the relevants parts of `get_data`. + match: Match = YAML_RE.match(self.markdown) + if not match: + raise PluginError( + f"Error reading metadata of post '{path}' in '{docs}':\n" + f"Expected metadata to be defined but found nothing" + ) + + # Extract metadata and parse as YAML + try: + self.meta = yaml.load(match.group(1), SafeLoader) or {} + self.markdown = self.markdown[match.end():].lstrip("\n") + + # The post's metadata could not be parsed because of a syntax error, + # which we display to the user with a nice error message + except Exception as e: + raise PluginError( + f"Error reading metadata of post '{path}' in '{docs}':\n" + f"{e}" + ) + + # Initialize post configuration, but remove all keys that this plugin + # doesn't care about, or they will be reported as invalid configuration + self.config: PostConfig = PostConfig(file.abs_src_path) + self.config.load_dict({ + key: self.meta[key] for key in ( + set(self.meta.keys()) & + set(self.config.keys()) + ) + }) + + # Validate configuration and throw if errors occurred + errors, warnings = self.config.validate() + for _, w in warnings: + log.warning(w) + for k, e in errors: + raise PluginError( + f"Error reading metadata '{k}' of post '{path}' in '{docs}':\n" + f"{e}" + ) + + # Excerpts are subsets of posts that are used in pages like archive and + # category views. They are not rendered as standalone pages, but are + # rendered in the context of a view. Each post has a dedicated excerpt + # instance which is reused when rendering views. + self.excerpt: Excerpt = None + + # Initialize authors and actegories + self.authors: list[Author] = [] + self.categories: list[Category] = [] + + # Ensure template is set or use default + self.meta.setdefault("template", "blog-post.html") + + # Ensure template hides navigation + self.meta["hide"] = self.meta.get("hide", []) + if "navigation" not in self.meta["hide"]: + self.meta["hide"].append("navigation") + + # The contents and metadata were already read in the constructor (and not + # in `read_source` as for pages), so this function must be set to a no-op + def read_source(self, config: MkDocsConfig): + pass + +# ----------------------------------------------------------------------------- + +# Excerpt +class Excerpt(Page): + + # Initialize an excerpt for the given post - we create the Markdown parser + # when intitializing the excerpt in order to improve rendering performance + # for excerpts, as they are reused across several different views, because + # posts might be referenced from multiple different locations + def __init__(self, post: Post, config: MkDocsConfig, files: Files): + self.file = copy(post.file) + self.post = post + + # Set canonical URL, or we can't print excerpts when debugging the + # blog plugin, as the `abs_url` property would be missing + self._set_canonical_url(config.site_url) + + # Initialize configuration and metadata + self.config = post.config + self.meta = post.meta + + # Initialize authors and categories - note that views usually contain + # subsets of those lists, which is why we need to manage them here + self.authors: list[Author] = [] + self.categories: list[Category] = [] + + # Initialize parser - note that we need to patch the configuration, + # more specifically the table of contents extension + config = _patch(config) + self.md = Markdown( + extensions = config.markdown_extensions, + extension_configs = config.mdx_configs, + ) + + # Register excerpt tree processor - this processor resolves anchors to + # posts from within views, so they point to the correct location + self.md.treeprocessors.register( + ExcerptTreeprocessor(post), + "excerpt", + 0 + ) + + # Register relative path tree processor - this processor resolves links + # to other pages and assets, and is used by MkDocs itself + self.md.treeprocessors.register( + _RelativePathTreeprocessor(self.file, files, config), + "relpath", + 1 + ) + + # Render an excerpt of the post on the given page - note that this is not + # thread-safe because excerpts are shared across views, as it cuts down on + # the cost of initialization. However, if in the future, we decide to render + # posts and views concurrently, we must change this behavior. + def render(self, page: Page, separator: str): + self.file.url = page.url + + # Retrieve excerpt tree processor and set page as base + at = self.md.treeprocessors.get_index_for_name("excerpt") + processor: ExcerptTreeprocessor = self.md.treeprocessors[at] + processor.base = page + + # Ensure that the excerpt includes a title in its content, since the + # title is linked to the post when rendering - see https://t.ly/5Gg2F + self.markdown = self.post.markdown + if not self.post._title_from_render: + self.markdown = "\n\n".join([f"# {self.post.title}", self.markdown]) + + # Convert Markdown to HTML and extract excerpt + self.content = self.md.convert(self.markdown) + self.content, *_ = self.content.split(separator, 1) + + # Extract table of contents and reset post URL - if we wouldn't reset + # the excerpt URL, linking to the excerpt from the view would not work + self.toc = get_toc(getattr(self.md, "toc_tokens", [])) + self.file.url = self.post.url + +# ----------------------------------------------------------------------------- + +# View +class View(Page): + + # Initialize view + def __init__(self, title: str | None, file: File, config: MkDocsConfig): + super().__init__(title, file, config) + self.parent: View | Section + + # Initialize posts and views + self.posts: list[Post] = [] + self.views: list[View] = [] + + # Initialize pages for pagination + self.pages: list[View] = [] + + # Set necessary metadata + def read_source(self, config: MkDocsConfig): + super().read_source(config) + + # Ensure template is set or use default + self.meta.setdefault("template", "blog.html") + +# ----------------------------------------------------------------------------- + +# Archive view +class Archive(View): + pass + +# ----------------------------------------------------------------------------- + +# Category view +class Category(View): + pass + +# ----------------------------------------------------------------------------- +# Helper functions +# ----------------------------------------------------------------------------- + +# Patch configuration +def _patch(config: MkDocsConfig): + config = copy(config) + + # Copy parts of configuration that needs to be patched + config.validation = copy(config.validation) + config.validation.links = copy(config.validation.links) + config.markdown_extensions = copy(config.markdown_extensions) + config.mdx_configs = copy(config.mdx_configs) + + # Make sure that the author did not add another instance of the table of + # contents extension to the configuration, as this leads to weird behavior + if "markdown.extensions.toc" in config.markdown_extensions: + config.markdown_extensions.remove("markdown.extensions.toc") + + # In order to render excerpts for posts, we need to make sure that the + # table of contents extension is appropriately configured + config.mdx_configs["toc"] = { + **config.mdx_configs.get("toc", {}), + **{ + "anchorlink": True, # Render headline as clickable + "baselevel": 2, # Render h1 as h2 and so forth + "permalink": False, # Remove permalinks + "toc_depth": 2 # Remove everything below h2 + } + } + + # Additionally, we disable link validation when rendering excerpts, because + # invalid links have already been reported when rendering the page + links = config.validation.links + links.not_found = logging.DEBUG + links.absolute_links = logging.DEBUG + links.unrecognized_links = logging.DEBUG + + # Return patched configuration + return config + +# ----------------------------------------------------------------------------- +# Data +# ----------------------------------------------------------------------------- + +# Set up logging +log = logging.getLogger("mkdocs.material.blog") diff --git a/src/plugins/blog/structure/config.py b/src/plugins/blog/structure/config.py new file mode 100644 index 00000000..129491b9 --- /dev/null +++ b/src/plugins/blog/structure/config.py @@ -0,0 +1,37 @@ +# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from mkdocs.config.base import Config +from mkdocs.config.config_options import ListOfItems, Optional, Type + +from .options import PostDate + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + +# Post configuration +class PostConfig(Config): + authors = ListOfItems(Type(str), default = []) + categories = ListOfItems(Type(str), default = []) + date = PostDate() + draft = Optional(Type(bool)) + readtime = Optional(Type(int)) + slug = Optional(Type(str)) diff --git a/src/plugins/blog/structure/markdown.py b/src/plugins/blog/structure/markdown.py new file mode 100644 index 00000000..64ade554 --- /dev/null +++ b/src/plugins/blog/structure/markdown.py @@ -0,0 +1,58 @@ +# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from markdown.treeprocessors import Treeprocessor +from mkdocs.structure.pages import Page +from mkdocs.utils import get_relative_url +from xml.etree.ElementTree import Element + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + +# Excerpt tree processor +class ExcerptTreeprocessor(Treeprocessor): + + # Initialize excerpt tree processor + def __init__(self, page: Page, base: Page = None): + self.page = page + self.base = base + + # Transform HTML after Markdown processing + def run(self, root: Element): + main = True + + # We're only interested in anchors, which is why we continue when the + # link does not start with an anchor tag + for el in root.iter("a"): + anchor = el.get("href") + if not anchor.startswith("#"): + continue + + # The main headline should link to the post page, not to a specific + # anchor, which is why we remove the anchor in that case + path = get_relative_url(self.page.url, self.base.url) + if main: + el.set("href", path) + else: + el.set("href", path + anchor) + + # Main headline has been seen + main = False diff --git a/src/plugins/blog/structure/options.py b/src/plugins/blog/structure/options.py new file mode 100644 index 00000000..281dec9f --- /dev/null +++ b/src/plugins/blog/structure/options.py @@ -0,0 +1,87 @@ +# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from datetime import date, datetime, time +from mkdocs.config.base import BaseConfigOption, Config, ValidationError +from typing import Dict + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + +# Date dictionary +class DateDict(Dict[str, datetime]): + + # Initialize date dictionary + def __init__(self, data: dict): + super().__init__(data) + + # Ensure presence of `date.created` + self.created: datetime = data["created"] + + # Allow attribute access + def __getattr__(self, name: str): + if name in self: + return self[name] + +# ----------------------------------------------------------------------------- + +# Post date option +class PostDate(BaseConfigOption[DateDict]): + + # Initialize post dates + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Normalize the supported types for post dates to datetime + def pre_validation(self, config: Config, key_name: str): + + # If the date points to a scalar value, convert it to a dictionary, + # since we want to allow the user to specify custom and arbitrary date + # values for posts. Currently, only the `created` date is mandatory, + # because it's needed to sort posts for views. + if not isinstance(config[key_name], dict): + config[key_name] = { "created": config[key_name] } + + # Convert all date values to datetime + for key, value in config[key_name].items(): + if isinstance(value, date): + config[key_name][key] = datetime.combine(value, time()) + + # Initialize date dictionary + config[key_name] = DateDict(config[key_name]) + + # Ensure each date value is of type datetime + def run_validation(self, value: DateDict): + for key in value: + if not isinstance(value[key], datetime): + raise ValidationError( + f"Expected type: {date} or {datetime} " + f"but received: {type(value[key])}" + ) + + # Ensure presence of `date.created` + if not value.created: + raise ValidationError( + "Expected 'created' date when using dictionary syntax" + ) + + # Return date dictionary + return value |
