summaryrefslogtreecommitdiffstatshomepage
path: root/src/plugins/blog/structure
diff options
context:
space:
mode:
author简律纯 <i@jyunko.cn>2023-10-07 06:48:07 +0800
committer简律纯 <i@jyunko.cn>2023-10-07 06:48:07 +0800
commit991fd7a6d67ee017c57beaaa21fc31c4bee7944d (patch)
treee895202203fcaa50b0052f60ef6fc7d6d2928cf9 /src/plugins/blog/structure
parentd62900046bb6f754a8e6e7e670a66a90134055d9 (diff)
downloadinfini-991fd7a6d67ee017c57beaaa21fc31c4bee7944d.tar.gz
infini-991fd7a6d67ee017c57beaaa21fc31c4bee7944d.zip
feat(version): versions
Diffstat (limited to 'src/plugins/blog/structure')
-rw-r--r--src/plugins/blog/structure/__init__.py292
-rw-r--r--src/plugins/blog/structure/config.py37
-rw-r--r--src/plugins/blog/structure/markdown.py58
-rw-r--r--src/plugins/blog/structure/options.py87
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