# Copyright (c) 2016-2025 Martin Donath # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to # deal in the Software without restriction, including without limitation the # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or # sell copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # IN THE SOFTWARE. from __future__ import annotations import logging import os import posixpath import 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 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 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 paginate import Page as Pagination from shutil import rmtree from tempfile import mkdtemp from urllib.parse import urlparse from yaml import SafeLoader from . import view_name from .author import Author, Authors from .config import BlogConfig from .readtime import readtime from .structure import ( Archive, Category, Profile, Excerpt, Post, View, Reference ) # ----------------------------------------------------------------------------- # 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 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) # By default, drafts are rendered when the documentation is served, # but not when it is built, for a better user experience if self.is_serve and self.config.draft_on_serve: self.config.draft = True # Resolve and load posts and generate views (run later) - we want to allow # other plugins to add generated posts or views, so we run this plugin as # late as possible. We also need to remove the posts from the navigation # before navigation is constructed, as the entrypoint should be considered # to be the active page for each post. The URLs of posts are computed before # Markdown processing, so that when linking to and from posts, behavior is # 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_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 and normalize path to posts directory path = self.config.post_dir.format(blog = root) path = posixpath.normpath(path) # Adjust destination paths for media files for file in files.media_files(): if not file.src_uri.startswith(path): continue # We need to adjust destination paths for assets to remove the # purely functional posts directory prefix when building 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 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) self.blog.posts = sorted( self._resolve_posts(files, config), key = lambda post: ( post.config.pin, post.config.date.created ), reverse = True ) # Generate views for archive if self.config.archive: views = self._generate_archive(config, files) self.blog.views.extend(views) # Generate views for categories if self.config.categories: views = self._generate_categories(config, files) # We always sort the list of categories by name first, so that any # custom sorting function that returns the same value for two items # returns them in a predictable and logical order, because sorting # in Python is stable, i.e., order of equal items is preserved self.blog.views.extend(sorted( sorted(views, key = view_name), key = self.config.categories_sort_by, reverse = self.config.categories_sort_reverse )) # Generate views for profiles if self.config.authors_profiles: views = self._generate_profiles(config, files) self.blog.views.extend(views) # Generate pages for views for view in self._resolve_views(self.blog): if self._config_pagination(view): for page in self._generate_pages(view, config, files): view.pages.append(page) # Ensure that entrypoint is always included in navigation self.blog.file.inclusion = InclusionLevel.INCLUDED # Attach posts and views to navigation (run later) - again, we allow other # plugins to alter the navigation before we start to attach posts and views # generated by this plugin at the correct locations in the navigation. Also, # we make sure to correct links to the parent and siblings of each page. @event_priority(-50) def on_nav(self, nav, *, config, files): if not self.config.enabled: return # If we're not building a standalone blog, the entrypoint will always # have a parent when it is included in the navigation. The parent is # essential to correctly resolve the location where the archive and # category views are attached. If the entrypoint doesn't have a parent, # we know that the author did not include it in the navigation, so we # explicitly mark it as not included. if not self.blog.parent and self.config.blog_dir != ".": self.blog.file.inclusion = InclusionLevel.NOT_IN_NAV # Attach posts to entrypoint without adding them to the navigation, so # that the entrypoint is considered to be the active page for each post self._attach(self.blog, [None, *reversed(self.blog.posts), None]) for post in self.blog.posts: post.file.inclusion = InclusionLevel.NOT_IN_NAV # Revert temporary exclusion of views from navigation for view in self._resolve_views(self.blog): view.file.inclusion = self.blog.file.inclusion for page in view.pages: page.file.inclusion = self.blog.file.inclusion # Attach views for archive if self.config.archive: title = self._translate(self.config.archive_name, config) views = [_ for _ in self.blog.views if isinstance(_, Archive)] # Attach and link views for archive if self.blog.file.inclusion.is_in_nav(): self._attach_to(self.blog, Section(title, views), nav) # Attach views for categories if self.config.categories: title = self._translate(self.config.categories_name, config) views = [_ for _ in self.blog.views if isinstance(_, Category)] # Attach and link views for categories, if any if self.blog.file.inclusion.is_in_nav() and views: self._attach_to(self.blog, Section(title, views), nav) # Attach views for profiles if self.config.authors_profiles: title = self._translate(self.config.authors_profiles_name, config) views = [_ for _ in self.blog.views if isinstance(_, Profile)] # Attach and link views for categories, if any if self.blog.file.inclusion.is_in_nav() and views: self._attach_to(self.blog, Section(title, views), nav) # Attach pages for views for view in self._resolve_views(self.blog): if self._config_pagination(view): for at in range(1, len(view.pages)): self._attach_at(view.parent, view, view.pages[at]) # 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(page): return # We set the contents of the view to its title if pagination should # not keep the content of the original view on paginated views if not self.config.pagination_keep_content: view = self._resolve_original(page) if view in self._resolve_views(self.blog): # If the current view is paginated, use the rendered title # of the original view in case the author set the title in # the page's contents, or it would be overridden with the # one set in mkdocs.yml, leading to inconsistent headings assert isinstance(view, View) if view != page: name = view._title_from_render or view.title return f"# {name}" # Nothing more to be done for views return # Extract and assign authors to post, if enabled if self.config.authors: for id in page.config.authors: if id not in self.authors: raise PluginError(f"Couldn't find author '{id}'") # Append to list of authors page.authors.append(self.authors[id]) # 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: if self.config.post_excerpt == "required": docs = os.path.relpath(config.docs_dir) path = os.path.relpath(page.file.abs_src_path, docs) raise PluginError( f"Couldn't find '{separator}' in post '{path}' in '{docs}'" ) # 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] # Process posts def on_page_content(self, html, *, 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: return # Compute readtime of post, if enabled and not explicitly set if self.config.post_readtime: words_per_minute = self.config.post_readtime_words_per_minute if not page.config.readtime: page.config.readtime = readtime(html, words_per_minute) # Register template filters for plugin def on_env(self, env, *, config, files): 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 vie @pass_context def url_filter_with_pagination(context: Context, url: str | None): page = context["page"] # If the current page is a view, check if the URL links to the page # itself, and replace it with the URL of the main view if isinstance(page, View): view = self._resolve_original(page) if page.url == url: url = view.url # Forward to original template filter return url_filter(context, url) # Register custom template filters env.filters["date"] = date_filter env.filters["url"] = url_filter_with_pagination # 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 view = self._resolve_original(page) if view not in self._resolve_views(self.blog): return # Render excerpts and prepare 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.created > datetime.now(timezone.utc) # Post might be a draft return bool(post.config.draft) # ------------------------------------------------------------------------- # Resolve entrypoint - the entrypoint of the blog must have been created # if it did not exist before, and hosts all posts sorted by descending date def _resolve(self, files: Files, config: MkDocsConfig): path = os.path.join(self.config.blog_dir, "index.md") path = os.path.normpath(path) # Create entrypoint, if it does not exist - note that the entrypoint is # created in the docs directory, not in the temporary directory docs = os.path.relpath(config.docs_dir) name = os.path.join(docs, path) if not os.path.isfile(name): file = self._path_to_file(path, config, temp = False) files.append(file) # Create file in docs directory self._save_to_file(file.abs_src_path, "# Blog\n\n") # Create and return entrypoint file = files.get_file_from_path(path) return View(None, file, config) # 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 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 for file in files.documentation_pages(): if not file.src_path.startswith(path): continue # Temporarily remove post from navigation file.inclusion = InclusionLevel.EXCLUDED # 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) # Resolve path relative to docs directory docs = os.path.relpath(config.docs_dir) file = os.path.join(docs, path) # If the authors file does not exist, return here config: Authors = Authors() if not os.path.isfile(file): return config.authors # Open file and parse as YAML with open(file, encoding = "utf-8-sig") as f: config.config_file_path = 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 author 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() 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 of the given view 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 siblings of a navigation item def _resolve_siblings(self, item: StructureItem, nav: Navigation): if isinstance(item.parent, Section): return item.parent.children else: return nav.items # Resolve original page or view (e.g. for paginated views) def _resolve_original(self, page: Page): if isinstance(page, View) and page.pages: return page.pages[0] else: return page # ------------------------------------------------------------------------- # 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 file for view, if it does not exist file = files.get_file_from_path(path) if not file: file = self._path_to_file(path, config) files.append(file) # Create file in temporary directory self._save_to_file(file.abs_src_path, f"# {name}") # Temporarily remove view from navigation file.inclusion = InclusionLevel.EXCLUDED # Create and yield view if not isinstance(file.page, Archive): 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 file for view, if it does not exist file = files.get_file_from_path(path) if not file: file = self._path_to_file(path, config) files.append(file) # Create file in temporary directory self._save_to_file(file.abs_src_path, f"# {name}") # Temporarily remove view from navigation file.inclusion = InclusionLevel.EXCLUDED # Create and yield view if not isinstance(file.page, Category): 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 views for profiles - analyze posts and generate the necessary # views to provide a profile page for each author listing all posts def _generate_profiles(self, config: MkDocsConfig, files: Files): for post in self.blog.posts: for id in post.config.authors: author = self.authors[id] path = self._format_path_for_profile(id, author) # Create file for view, if it does not exist file = files.get_file_from_path(path) if not file: file = self._path_to_file(path, config) files.append(file) # Create file in temporary directory self._save_to_file(file.abs_src_path, f"# {author.name}") # Temporarily remove view from navigation and assign profile # URL to author, if not explicitly set file.inclusion = InclusionLevel.EXCLUDED if not author.url: author.url = file.url # Create and yield view if not isinstance(file.page, Profile): yield Profile(author.name, file, config) # Assign post to profile assert isinstance(file.page, Profile) file.page.posts.append(post) # 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 # Compute pagination boundaries and create pages - pages are internally # handled as copies of a view, as they map to the same source location step = self._config_pagination_per_page(view) for at in range(step, len(view.posts), step): path = self._format_path_for_pagination(view, 1 + at // step) # Create file for view, if it does not exist file = files.get_file_from_path(path) if not file: file = self._path_to_file(path, config) files.append(file) # Copy file to temporary directory copy_file(view.file.abs_src_path, file.abs_src_path) # Temporarily remove view from navigation file.inclusion = InclusionLevel.EXCLUDED # Create and yield view if not isinstance(file.page, View): yield view.__class__(None, file, config) # Assign pages and posts to view assert isinstance(file.page, View) 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 # 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 # If the page is a view, we know that we generated it and need to # link its siblings back to the view if isinstance(page, View): view = self._resolve_original(page) if tail: tail.next_page = view if head: head.previous_page = view # Attach a page to the given parent and link it to the previous and next # page of the given host - this is exclusively used for paginated views def _attach_at(self, parent: StructureItem, host: Page, page: Page): self._attach(parent, [host.previous_page, page, host.next_page]) # Attach a section as a sibling to the given view, make sure its pages are # part of the navigation, and ensure all pages are linked correctly def _attach_to(self, view: View, section: Section, nav: Navigation): section.parent = view.parent # Resolve siblings, which are the children of the parent section, or # the top-level list of navigation items if the view is at the root of # the project, and append the given section to it. It's currently not # possible to chose the position of a section. items = self._resolve_siblings(view, nav) 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]) # ------------------------------------------------------------------------- # 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(view): at = view.pages.index(view) # Compute pagination boundaries step = self._config_pagination_per_page(view) 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 if post.excerpt ] # 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) # 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 toc = self._config_toc(view) 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 ) # ------------------------------------------------------------------------- # Retrieve configuration value or return default def _config(self, key: str, default: any): return default if self.config[key] is None else self.config[key] # Retrieve configuration value for table of contents def _config_toc(self, view: View): default = self.config.blog_toc if isinstance(view, Archive): return self._config("archive_toc", default) if isinstance(view, Category): return self._config("categories_toc", default) if isinstance(view, Profile): return self._config("authors_profiles_toc", default) else: return default # Retrieve configuration value for pagination def _config_pagination(self, view: View): default = self.config.pagination if isinstance(view, Archive): return self._config("archive_pagination", default) if isinstance(view, Category): return self._config("categories_pagination", default) if isinstance(view, Profile): return self._config("authors_profiles_pagination", default) else: return default # Retrieve configuration value for pagination per page def _config_pagination_per_page(self, view: View): default = self.config.pagination_per_page if isinstance(view, Archive): return self._config("archive_pagination_per_page", default) if isinstance(view, Category): return self._config("categories_pagination_per_page", default) if isinstance(view, Profile): return self._config("authors_profiles_pagination_per_page", default) else: return default # ------------------------------------------------------------------------- # 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 profile def _format_path_for_profile(self, id: str, author: Author): path = self.config.authors_profiles_url_format.format( slug = author.slug or id, name = author.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, view: View, page: int): path = self.config.pagination_url_format.format( page = page ) # Compute base path for pagination - if the given view is an index file, # we need to pop the file name from the base so it's not part of the URL # and we need to append `index` to the path, so the paginated view is # also an index page - see https://t.ly/71MKF base, _ = posixpath.splitext(view.file.src_uri) if view.is_index: base = posixpath.dirname(base) path = posixpath.join(path, "index") # Normalize path and strip slashes at the beginning and end path = posixpath.normpath(path.strip("/")) return posixpath.join(base, f"{path}.md") # ------------------------------------------------------------------------- # Format date - if the given format string refers to a predefined format, # we format the date without a time component in order to keep sane default # behavior, since authors will not expect time to be relevant for most posts # as by our assumptions - see https://t.ly/Yi7ZC def _format_date(self, date: datetime, format: str, config: MkDocsConfig): locale: str = config.theme["language"].replace("-", "_") if format in ["full", "long", "medium", "short"]: return format_date(date, format = format, locale = locale) else: return format_datetime(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 # Create a file with the given content on disk def _save_to_file(self, path: str, content: str): os.makedirs(os.path.dirname(path), exist_ok = True) with open(path, "w", encoding = "utf-8") 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) # ----------------------------------------------------------------------------- # 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 # ----------------------------------------------------------------------------- # Set up logging log = logging.getLogger("mkdocs.material.blog")