mkdocs-material/material/plugins/blog/plugin.py

814 lines
33 KiB
Python

# 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.
import logging
import os
import posixpath
import readtime
import yaml
from babel.dates import format_date
from datetime import datetime
from mkdocs.config.defaults import MkDocsConfig
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.pages import Page
from mkdocs.utils import get_relative_url
from paginate import Page as Pagination
from shutil import rmtree
from tempfile import mkdtemp
from yaml import SafeLoader
from .author import Author, Authors
from .config import BlogConfig
from .structure import Archive, Category, Excerpt, Post, View
from .templates import url_filter
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
# Blog plugin
class BlogPlugin(BasePlugin[BlogConfig]):
supports_multiple_instances = True
# Initialize plugin
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize incremental builds
self.is_serve = False
self.is_dirty = False
# Initialize a temporary directory
self.temp_dir = mkdtemp()
# Determine whether we're serving the site
def on_startup(self, *, command, dirty):
self.is_serve = command == "serve"
self.is_dirty = dirty
# Initialize authors and set defaults
def on_config(self, config):
if not self.config.enabled:
return
# Initialize entrypoint
self.blog: View
# Initialize and resolve authors, if enabled
if self.config.authors:
self.authors = self._resolve_authors(config)
# Initialize table of contents settings
if not isinstance(self.config.archive_toc, bool):
self.config.archive_toc = self.config.blog_toc
if not isinstance(self.config.categories_toc, bool):
self.config.categories_toc = self.config.blog_toc
# By default, drafts are rendered when the documentation is served,
# but not when it is built, for a better authoring experience
if self.is_serve and self.config.draft_on_serve:
self.config.draft = True
# Remove posts before constructing navigation (run later) - allow other
# plugins to alter the list of files and navigation prior to this plugin
@event_priority(-50)
def on_files(self, files, *, config):
if not self.config.enabled:
return
# Resolve path to entrypoint and site directory
root = posixpath.normpath(self.config.blog_dir)
site = config.site_dir
# Compute path to posts directory
path = self.config.post_dir.format(blog = root)
path = posixpath.normpath(path)
# Temporarily remove posts and adjust destination paths for assets
for file in files:
if not file.src_uri.startswith(path):
continue
# We must exclude all files related to posts from here on, so MkDocs
# will not attach the posts to the navigation when auto-populating.
# We add them back in `on_nav`, so MkDocs processes them, unless
# excluded by being tagged as a draft or through other means.
if file.is_documentation_page():
file.inclusion = InclusionLevel.EXCLUDED
# We also need to adjust destination paths for assets to remove the
# purely functional posts directory prefix when building
if file.is_media_file():
file.dest_uri = file.dest_uri.replace(path, root)
file.abs_dest_path = os.path.join(site, file.dest_path)
file.url = file.url.replace(path, root)
# Resolve and load posts and generate indexes (run later) - we resolve all
# posts after the navigation is constructed in order to allow other plugins
# to alter the navigation (e.g. awesome-pages) before we start to add pages
# generated by this plugin. Post URLs must be computed before any Markdown
# processing, so that when linking to and from posts, MkDocs behaves exactly
# the same as with regular documentation pages. We create all pages related
# to posts as part of this plugin, so we control the entire process.
@event_priority(-50)
def on_nav(self, nav, *, config, files):
if not self.config.enabled:
return
# Resolve entrypoint and posts sorted by descending date - if the posts
# directory or entrypoint do not exist, they are automatically created
self.blog = self._resolve(files, config, nav)
self.blog.posts = sorted(
self._resolve_posts(files, config),
key = lambda post: post.config.date.created,
reverse = True
)
# Attach posts to entrypoint without adding them to the navigation, so
# that the entrypoint is considered to be the active page for each post.
# Hack: MkDocs has a bug where pages that are marked to be not in the
# navigation are auto-populated nonetheless - see https://t.ly/7aYnO
self._attach(self.blog, [None, *reversed(self.blog.posts), None])
for post in self.blog.posts:
post.file.inclusion = InclusionLevel.NOT_IN_NAV
# Generate and attach views for archive
if self.config.archive:
views = [*self._generate_archive(config, files)]
self.blog.views.extend(views)
# Attach and link views for archive
title = self._translate(self.config.archive_name, config)
self._attach_to(self.blog.parent, Section(title, views), nav)
# Generate and attach views for categories
if self.config.categories:
views = [*self._generate_categories(config, files)]
self.blog.views.extend(views)
# Attach and link views for categories
title = self._translate(self.config.categories_name, config)
self._attach_to(self.blog.parent, Section(title, views), nav)
# Paginate generated views, if enabled
if self.config.pagination:
for view in [*self._resolve_views(self.blog)]:
for page in self._generate_pages(view, config, files):
view.pages.append(page)
# Prepare post for rendering (run later) - allow other plugins to alter
# the contents or metadata of a post before it is rendered and make sure
# that the post includes a separator, which is essential for rendering
# excerpts that should be included in views
@event_priority(-50)
def on_page_markdown(self, markdown, *, page, config, files):
if not self.config.enabled:
return
# Skip if page is not a post managed by this instance - this plugin has
# support for multiple instances, which is why this check is necessary
if page not in self.blog.posts:
if not self.config.pagination:
return
# We set the contents of the view to its title if pagination should
# not keep the content of the original view on paginaged views
if not self.config.pagination_keep_content:
if page in self._resolve_views(self.blog):
assert isinstance(page, View)
if 0 < page.pages.index(page):
return f"# {page.title}"
# Nothing more to be done for views
return
# Extract and assign authors to post, if enabled
if self.config.authors:
for name in page.config.authors:
if name not in self.authors:
raise PluginError(f"Couldn't find author '{name}'")
# Append to list of authors
page.authors.append(self.authors[name])
# Compute readtime of post, if enabled and not explicitly set
if self.config.post_readtime:
rate = self.config.post_readtime_words_per_minute
# There's a bug in the readtime library which causes it to fail if
# the input string contains emojis - see https://t.ly/qEoHq
if not page.config.readtime:
data = markdown.encode("unicode_escape")
read = readtime.of_markdown(data, rate)
page.config.readtime = read.minutes
# Extract settings for excerpts
separator = self.config.post_excerpt_separator
max_authors = self.config.post_excerpt_max_authors
max_categories = self.config.post_excerpt_max_categories
# Ensure presence of separator and throw, if its absent and required -
# we append the separator to the end of the contents of the post, if it
# is not already present, so we can remove footnotes or other content
# from the excerpt without affecting the content of the excerpt
if separator not in page.markdown:
path = page.file.src_uri
if self.config.post_excerpt == "required":
raise PluginError(
f"Couldn't find '{separator}' separator in '{path}'"
)
else:
page.markdown += f"\n\n{separator}"
# Create excerpt for post and inherit authors and categories - excerpts
# can contain a subset of the authors and categories of the post
page.excerpt = Excerpt(page, config, files)
page.excerpt.authors = page.authors[:max_authors]
page.excerpt.categories = page.categories[:max_categories]
# Register template filters for plugin
def on_env(self, env, *, config, files):
if not self.config.enabled:
return
# Filter for formatting dates related to posts
def date_filter(date: datetime):
return self._format_date_for_post(date, config)
# Register custom template filters
env.filters["date"] = date_filter
env.filters["url"] = url_filter
# Prepare view for rendering (run latest) - views are rendered last, as we
# need to mutate the navigation to account for pagination. The main problem
# is that we need to replace the view in the navigation, because otherwise
# the view would not be considered active.
@event_priority(-100)
def on_page_context(self, context, *, page, config, nav):
if not self.config.enabled:
return
# Skip if page is not a view managed by this instance - this plugin has
# support for multiple instances, which is why this check is necessary
if page not in self._resolve_views(self.blog):
return
# Retrieve parent view or section
assert isinstance(page, View)
main = page.parent
# If this page is a view, and the parent page is a view as well, we got
# a paginated view and need to update the parent view in the navigation.
# Paginated views are always rendered last, which is why we can safely
# mutate the navigation at this point
if isinstance(main, View):
assert isinstance(main.parent, Section)
# Replace view in navigation and rewire view - the current view in
# the navigation becomes the main view, thus the entire chain moves
# one level up. It's essential that the rendering order is linear,
# or else we might end up with a broken navigation.
at = main.parent.children.index(main)
main.parent.children[at] = page
page.parent = main.parent
# Render excerpts and perpare pagination
posts, pagination = self._render(page)
# Render pagination links
def pager(args: object):
return pagination.pager(
format = self.config.pagination_format,
show_if_single_page = self.config.pagination_if_single_page,
**args
)
# Assign posts and pagination to context
context["posts"] = posts
context["pagination"] = pager if pagination else None
# Remove temporary directory on shutdown
def on_shutdown(self):
rmtree(self.temp_dir)
# -------------------------------------------------------------------------
# Check if the given post is excluded
def _is_excluded(self, post: Post):
if self.config.draft:
return False
# If a post was not explicitly marked or unmarked as draft, and the
# date should be taken into account, we automatically mark it as draft
# if the publishing date is in the future. This, of course, is opt-in
# and must be explicitly enabled by the author.
if not isinstance(post.config.draft, bool):
if self.config.draft_if_future_date:
return post.config.date > datetime.now()
# Post might be a draft
return bool(post.config.draft)
# -------------------------------------------------------------------------
# Resolve entrypoint - the entrypoint of the blog hosts all posts, sorted
# by descending date. The entrypoint must always be present, even if there
# are no posts, and is automatically created if it does not exist yet. Note
# that posts might be paginated, but this is configurable by the author.
def _resolve(self, files: Files, config: MkDocsConfig, nav: Navigation):
path = os.path.join(self.config.blog_dir, "index.md")
path = os.path.normpath(path)
# Create entrypoint, if it does not exist
docs = os.path.relpath(config.docs_dir)
file = os.path.join(docs, path)
if not os.path.isfile(file):
self._save_to_file(file, "# Blog\n\n")
# Append entrypoint to files - note that the entrypoint is added to
# the docs directory, so we need to set the temporary flag to false
files.append(self._path_to_file(path, config, temp = False))
# Obtain entrypoint page
file = files.get_file_from_path(path)
page = file.page
# Create entrypoint view and attach to parent
view = View(page.title, file, config)
self._attach(page.parent, [
page.previous_page,
view,
page.next_page
])
# Update entrypoint in navigation
for items in [view.parent.children, nav.pages]:
items[items.index(page)] = view
# Return view
return view
# Resolve post - the caller must make sure that the given file points to an
# actual post (and not a page), or behavior might be unpredictable
def _resolve_post(self, file: File, config: MkDocsConfig):
post = Post(file, config)
# Compute path and create a temporary file for path resolution
path = self._format_path_for_post(post, config)
temp = self._path_to_file(path, config, temp = False)
# Replace post destination file system path and URL
file.dest_uri = temp.dest_uri
file.abs_dest_path = temp.abs_dest_path
file.url = temp.url
# Replace canonical URL and return post
post._set_canonical_url(config.site_url)
return post
# Resolve posts from directory - traverse all documentation pages and filter
# and yield those that are located in the posts directory
def _resolve_posts(self, files: Files, config: MkDocsConfig):
path = self.config.post_dir.format(blog = self.config.blog_dir)
path = os.path.normpath(path)
# Create posts directory, if it does not exist
docs = os.path.relpath(config.docs_dir)
name = os.path.join(docs, path)
if not os.path.isdir(name):
os.makedirs(name, exist_ok = True)
# Filter posts from pages - prior to calling this function, the caller
# should've excluded all posts, so they're not listed in the navigation
inclusion = InclusionLevel.is_excluded
for file in files.documentation_pages(inclusion = inclusion):
if not file.src_path.startswith(path):
continue
# Resolve post - in order to determine whether a post should be
# excluded, we must load it and analyze its metadata. All posts
# marked as drafts are excluded, except for when the author has
# configured drafts to be included in the navigation.
post = self._resolve_post(file, config)
if not self._is_excluded(post):
yield post
# Resolve authors - check if there's an authors file at the configured
# location, and if one was found, load and validate it
def _resolve_authors(self, config: MkDocsConfig):
path = self.config.authors_file.format(blog = self.config.blog_dir)
path = os.path.normpath(path)
# If the authors file does not exist, return an empty dictionary
docs = os.path.relpath(config.docs_dir)
file = os.path.join(docs, path)
if not os.path.isfile(file):
authors: dict[str, Author] = dict()
return authors
# Open file and parse as YAML
with open(file, encoding = "utf-8") as f:
config: Authors = Authors(os.path.abspath(file))
try:
config.load_dict(yaml.load(f, SafeLoader) or {})
# The authors file could not be loaded 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 authors file '{path}' in '{docs}':\n"
f"{e}"
)
# Validate authors and throw if errors occurred
errors, warnings = config.validate()
if not config.authors and warnings:
log.warning(
f"Action required: the format of the authors file changed.\n"
f"All authors must now be located under the 'authors' key.\n"
f"Please adjust '{file}' to match:\n"
f"\n"
f"authors:\n"
f" squidfunk:\n"
f" avatar: https://avatars.githubusercontent.com/u/932156\n"
f" description: Creator\n"
f" name: Martin Donath\n"
f"\n"
)
for _, w in warnings:
log.warning(w)
for _, e in errors:
raise PluginError(
f"Error reading authors file '{path}' in '{docs}':\n"
f"{e}"
)
# Return authors
return config.authors
# Resolve views and pages of the given view that were generated by this
# plugin when building the site and yield them in pre-order
def _resolve_views(self, view: View):
yield view
# Resolve views recursively
for page in view.views:
for next in self._resolve_views(page):
assert isinstance(next, View)
yield next
# Resolve pages
for page in view.pages:
assert isinstance(page, View)
yield page
# -------------------------------------------------------------------------
# Attach a list of pages to each other and to the given parent item without
# explicitly adding them to the navigation, which can be done by the caller
def _attach(self, parent: StructureItem, pages: list[Page]):
for tail, page, head in zip(pages, pages[1:], pages[2:]):
# Link page to parent and siblings
page.parent = parent
page.previous_page = tail
page.next_page = head
# Attach a section to the given parent section, make sure it's pages are
# part of the navigation, and ensure all pages are linked correctly
def _attach_to(self, parent: Section, section: Section, nav: Navigation):
section.parent = parent
# Determine the parent section to attach the section to, which might be
# the top-level navigation, if no parent section was given. Note, that
# it's currently not possible to chose the position of a section, but
# we might add support for this in the future.
items = parent.children if parent else nav.items
items.append(section)
# Find last sibling that is a page, skipping sections, as we need to
# append the given section after all other pages
tail = next(item for item in reversed(items) if isinstance(item, Page))
head = tail.next_page
# Attach section to navigation and pages to each other
nav.pages.extend(section.children)
self._attach(section, [tail, *section.children, head])
# -------------------------------------------------------------------------
# Generate views for archive - analyze posts and generate the necessary
# views, taking the date format provided by the author into account
def _generate_archive(self, config: MkDocsConfig, files: Files):
for post in self.blog.posts:
date = post.config.date.created
# Compute name and path of archive view
name = self._format_date_for_archive(date, config)
path = self._format_path_for_archive(post, config)
# Create view for archive if it doesn't exist
file = files.get_file_from_path(path)
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Create and yield archive view
self._save_to_file(file.abs_src_path, f"# {name}")
yield Archive(name, file, config)
# Assign post to archive
assert isinstance(file.page, Archive)
file.page.posts.append(post)
# Generate views for categories - analyze posts and generate the necessary
# views, taking the allowed categories as set by the author into account
def _generate_categories(self, config: MkDocsConfig, files: Files):
for post in self.blog.posts:
for name in post.config.categories:
path = self._format_path_for_category(name)
# Ensure category is in non-empty allow list
categories = self.config.categories_allowed or [name]
if name not in categories:
docs = os.path.relpath(config.docs_dir)
path = os.path.relpath(post.file.abs_src_path, docs)
raise PluginError(
f"Error reading categories of post '{path}' in "
f"'{docs}': category '{name}' not in allow list"
)
# Create view for category if it doesn't exist
file = files.get_file_from_path(path)
if not file:
file = self._path_to_file(path, config)
files.append(file)
# Create and yield archive view
self._save_to_file(file.abs_src_path, f"# {name}")
yield Category(name, file, config)
# Assign post to category and vice versa
assert isinstance(file.page, Category)
file.page.posts.append(post)
post.categories.append(file.page)
# Generate pages for pagination - analyze view and generate the necessary
# pages, creating a chain of views for simple rendering and replacement
def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
yield view
# Extract settings for pagination
step = self.config.pagination_per_page
prev = view
# Compute pagination boundaries and create pages
for at in range(step, len(view.posts), step):
path = self._format_path_for_pagination(view.url, 1 + at // step)
file = self._path_to_file(path, config)
# Replace post source file system path and apend to files
file.src_uri = view.file.src_uri
file.abs_src_path = view.file.abs_src_path
files.append(file)
# Create view and attach to previous page
next = View(view.title, file, config)
self._attach(prev, [
view.previous_page,
next,
view.next_page
])
# Assign posts and pages to view
next.posts = view.posts
next.pages = view.pages
# Continue with next page
prev = next
yield next
# -------------------------------------------------------------------------
# Render excerpts and pagination for the given view
def _render(self, view: View):
posts, pagination = view.posts, None
# Create pagination, if enabled
if self.config.pagination:
at = view.pages.index(view)
# Compute pagination boundaries
step = self.config.pagination_per_page
p, q = at * step, at * step + step
# Extract posts in pagination boundaries
posts = view.posts[p:q]
pagination = self._render_pagination(view, (p, q))
# Render excerpts for selected posts
posts = [
self._render_post(post.excerpt, view)
for post in posts
]
# Return posts and pagination
return posts, pagination
# Render excerpt in the context of the given view
def _render_post(self, excerpt: Excerpt, view: View):
excerpt.render(view, self.config.post_excerpt_separator)
# Determine whether to add posts to the table of contents of the view -
# note that those settings can be changed individually for each type of
# view, which is why we need to check the type of view and the table of
# contents setting for that type of view
toc = self.config.blog_toc
if isinstance(view, Archive):
toc = self.config.archive_toc
if isinstance(view, Category):
toc = self.config.categories_toc
# Attach top-level table of contents item to view if it should be added
# and both, the view and excerpt contain table of contents items
if toc and excerpt.toc.items and view.toc.items:
view.toc.items[0].children.append(excerpt.toc.items[0])
# Return excerpt
return excerpt
# Create pagination for the given view and range
def _render_pagination(self, view: View, range: tuple[int, int]):
p, q = range
# Create URL from the given page to another page
def url_maker(n: int):
return get_relative_url(view.pages[n - 1].url, view.url)
# Return pagination
return Pagination(
view.posts, page = q // (q - p),
items_per_page = q - p,
url_maker = url_maker
)
# -------------------------------------------------------------------------
# Format path for post
def _format_path_for_post(self, post: Post, config: MkDocsConfig):
categories = post.config.categories[:self.config.post_url_max_categories]
categories = [self._slugify_category(name) for name in categories]
# Replace placeholders in format string
date = post.config.date.created
path = self.config.post_url_format.format(
categories = "/".join(categories),
date = self._format_date_for_post_url(date, config),
file = post.file.name,
slug = post.config.slug or self._slugify_post(post)
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for archive
def _format_path_for_archive(self, post: Post, config: MkDocsConfig):
date = post.config.date.created
path = self.config.archive_url_format.format(
date = self._format_date_for_archive_url(date, config)
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for category
def _format_path_for_category(self, name: str):
path = self.config.categories_url_format.format(
slug = self._slugify_category(name)
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(self.config.blog_dir, f"{path}.md")
# Format path for pagination
def _format_path_for_pagination(self, base: str, page: int):
path = self.config.pagination_url_format.format(
page = page
)
# Normalize path and strip slashes at the beginning and end
path = posixpath.normpath(path.strip("/"))
return posixpath.join(base, f"{path}.md")
# -------------------------------------------------------------------------
# Format date
def _format_date(self, date: datetime, format: str, config: MkDocsConfig):
locale = config.theme["language"]
return format_date(date, format = format, locale = locale)
# Format date for post
def _format_date_for_post(self, date: datetime, config: MkDocsConfig):
format = self.config.post_date_format
return self._format_date(date, format, config)
# Format date for post URL
def _format_date_for_post_url(self, date: datetime, config: MkDocsConfig):
format = self.config.post_url_date_format
return self._format_date(date, format, config)
# Format date for archive
def _format_date_for_archive(self, date: datetime, config: MkDocsConfig):
format = self.config.archive_date_format
return self._format_date(date, format, config)
# Format date for archive URL
def _format_date_for_archive_url(self, date: datetime, config: MkDocsConfig):
format = self.config.archive_url_date_format
return self._format_date(date, format, config)
# -------------------------------------------------------------------------
# Slugify post title
def _slugify_post(self, post: Post):
separator = self.config.post_slugify_separator
return self.config.post_slugify(post.title, separator)
# Slugify category
def _slugify_category(self, name: str):
separator = self.config.categories_slugify_separator
return self.config.categories_slugify(name, separator)
# -------------------------------------------------------------------------
# Create a file for the given path, which must point to a valid source file,
# either inside the temporary directory or the docs directory
def _path_to_file(self, path: str, config: MkDocsConfig, *, temp = True):
assert path.endswith(".md")
file = File(
path,
config.docs_dir if not temp else self.temp_dir,
config.site_dir,
config.use_directory_urls
)
# Hack: mark file as generated, so other plugins don't think it's part
# of the file system. This is more or less a new quasi-standard that
# still needs to be adopted by MkDocs, and was introduced by the
# git-revision-date-localized-plugin - see https://bit.ly/3ZUmdBx
if temp:
file.generated_by = "material/blog"
# Return file
return file
# Write the content to the file located at the given path
def _save_to_file(self, path: str, content: str):
os.makedirs(os.path.dirname(path), exist_ok = True)
with open(path, "w") as f:
f.write(content)
# -------------------------------------------------------------------------
# Translate the placeholder referenced by the given key
def _translate(self, key: str, config: MkDocsConfig) -> str:
env = config.theme.get_env()
template = env.get_template(
"partials/language.html", globals = { "config": config }
)
# Translate placeholder
return template.module.t(key)
# -----------------------------------------------------------------------------
# Data
# -----------------------------------------------------------------------------
# Set up logging
log = logging.getLogger("mkdocs.material.blog")