Refactored blog plugin view generation

This commit is contained in:
squidfunk
2023-08-30 15:27:40 +02:00
parent fb18b20695
commit 4c6b004fe4
2 changed files with 368 additions and 338 deletions

View File

@@ -18,6 +18,8 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE. # IN THE SOFTWARE.
from __future__ import annotations
import logging import logging
import os import os
import posixpath import posixpath
@@ -39,7 +41,7 @@ from shutil import rmtree
from tempfile import mkdtemp from tempfile import mkdtemp
from yaml import SafeLoader from yaml import SafeLoader
from .author import Author, Authors from .author import Authors
from .config import BlogConfig from .config import BlogConfig
from .structure import Archive, Category, Excerpt, Post, View from .structure import Archive, Category, Excerpt, Post, View
from .templates import url_filter from .templates import url_filter
@@ -60,7 +62,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
self.is_serve = False self.is_serve = False
self.is_dirty = False self.is_dirty = False
# Initialize a temporary directory # Initialize temporary directory
self.temp_dir = mkdtemp() self.temp_dir = mkdtemp()
# Determine whether we're serving the site # Determine whether we're serving the site
@@ -87,12 +89,18 @@ class BlogPlugin(BasePlugin[BlogConfig]):
self.config.categories_toc = self.config.blog_toc self.config.categories_toc = self.config.blog_toc
# By default, drafts are rendered when the documentation is served, # By default, drafts are rendered when the documentation is served,
# but not when it is built, for a better authoring experience # but not when it is built, for a better user experience
if self.is_serve and self.config.draft_on_serve: if self.is_serve and self.config.draft_on_serve:
self.config.draft = True self.config.draft = True
# Remove posts before constructing navigation (run later) - allow other # Resolve and load posts and generate views (run later) - we want to allow
# plugins to alter the list of files and navigation prior to this plugin # 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) @event_priority(-50)
def on_files(self, files, *, config): def on_files(self, files, *, config):
if not self.config.enabled: if not self.config.enabled:
@@ -106,80 +114,108 @@ class BlogPlugin(BasePlugin[BlogConfig]):
path = self.config.post_dir.format(blog = root) path = self.config.post_dir.format(blog = root)
path = posixpath.normpath(path) path = posixpath.normpath(path)
# Temporarily remove posts and adjust destination paths for assets # Adjust destination paths for media files
for file in files: for file in files.media_files():
if not file.src_uri.startswith(path): if not file.src_uri.startswith(path):
continue continue
# We must exclude all files related to posts from here on, so MkDocs # We need to adjust destination paths for assets to remove the
# 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 # purely functional posts directory prefix when building
if file.is_media_file(): file.dest_uri = file.dest_uri.replace(path, root)
file.dest_uri = file.dest_uri.replace(path, root) file.abs_dest_path = os.path.join(site, file.dest_path)
file.abs_dest_path = os.path.join(site, file.dest_path) file.url = file.url.replace(path, root)
file.url = file.url.replace(path, root)
# Generate entrypoint, if it does not exist yet
self._generate(config, files)
# 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 # Resolve entrypoint and posts sorted by descending date - if the posts
# directory or entrypoint do not exist, they are automatically created # directory or entrypoint do not exist, they are automatically created
self.blog = self._resolve(files, config, nav) self.blog = self._resolve(files, config)
self.blog.posts = sorted( self.blog.posts = sorted(
self._resolve_posts(files, config), self._resolve_posts(files, config),
key = lambda post: post.config.date.created, key = lambda post: post.config.date.created,
reverse = True reverse = True
) )
# Temporarily remove posts from navigation
for post in self.blog.posts:
post.file.inclusion = InclusionLevel.EXCLUDED
# 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)
self.blog.views.extend(views)
# Generate pages for views
if self.config.pagination:
for view in self._resolve_views(self.blog):
for page in self._generate_pages(view, config, files):
page.file.inclusion = InclusionLevel.EXCLUDED
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
# Hack: since MkDocs will always create a page for the entrypoint even
# though we already created it in `on_files`, we must replace the page
# that MkDocs created with the entrypoint we already have on our hands.
# Hopefully, this hack can be removed soon - see https://t.ly/9nehI
page = self.blog.file.page
self._attach_at(page.parent, page, self.blog)
# Update entrypoint in navigation (also part of the hack above)
self.blog.file.page = self.blog
for items in [self._resolve_siblings(self.blog, nav), nav.pages]:
items[items.index(page)] = self.blog
# Attach posts to entrypoint without adding them to the navigation, so # Attach posts to entrypoint without adding them to the navigation, so
# that the entrypoint is considered to be the active page for each post. # 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]) self._attach(self.blog, [None, *reversed(self.blog.posts), None])
for post in self.blog.posts: for post in self.blog.posts:
post.file.inclusion = InclusionLevel.NOT_IN_NAV post.file.inclusion = InclusionLevel.NOT_IN_NAV
# Generate and attach views for archive # Revert temporary exclusion of views from navigation
for view in self._resolve_views(self.blog):
for page in view.pages:
page.file.inclusion = InclusionLevel.INCLUDED
# Attach views for archive
if self.config.archive: if self.config.archive:
views = [*self._generate_archive(config, files)] title = self._translate(self.config.archive_name, config)
self.blog.views.extend(views) views = [_ for _ in self.blog.views if isinstance(_, Archive)]
# Attach and link views for archive # Attach and link views for archive
title = self._translate(self.config.archive_name, config)
self._attach_to(self.blog, Section(title, views), nav) self._attach_to(self.blog, Section(title, views), nav)
# Generate and attach views for categories # Attach views for categories
if self.config.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) title = self._translate(self.config.categories_name, config)
self._attach_to(self.blog, Section(title, views), nav) views = [_ for _ in self.blog.views if isinstance(_, Category)]
# Paginate generated views, if enabled # Attach and link views for categories, if any
if views:
self._attach_to(self.blog, Section(title, views), nav)
# Attach pages for views
if self.config.pagination: if self.config.pagination:
for view in [*self._resolve_views(self.blog)]: for view in self._resolve_views(self.blog):
for page in self._generate_pages(view, config, files): for at in range(1, len(view.pages)):
view.pages.append(page) self._attach_at(view.pages[at - 1], view, view.pages[at])
# Replace source file system path
view.pages[at].file.src_uri = view.file.src_uri
view.pages[at].file.abs_src_path = view.file.abs_src_path
# Prepare post for rendering (run later) - allow other plugins to alter # 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 # the contents or metadata of a post before it is rendered and make sure
@@ -199,9 +235,10 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# We set the contents of the view to its title if pagination should # We set the contents of the view to its title if pagination should
# not keep the content of the original view on paginaged views # not keep the content of the original view on paginaged views
if not self.config.pagination_keep_content: if not self.config.pagination_keep_content:
if page in self._resolve_views(self.blog): view = page.pages[0] if isinstance(page, View) else page
if view in self._resolve_views(self.blog):
assert isinstance(page, View) assert isinstance(page, View)
if 0 < page.pages.index(page): if page.pages.index(page):
main = page.parent main = page.parent
# We need to use the rendered title of the original view # We need to use the rendered title of the original view
@@ -282,7 +319,8 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Skip if page is not a view managed by this instance - this plugin has # 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 # support for multiple instances, which is why this check is necessary
if page not in self._resolve_views(self.blog): view = page.pages[0] if isinstance(page, View) else page
if view not in self._resolve_views(self.blog):
return return
# Retrieve parent view or section # Retrieve parent view or section
@@ -344,28 +382,24 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Resolve entrypoint - the entrypoint of the blog must have been created # 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 # if it did not exist before, and hosts all posts sorted by descending date
def _resolve(self, files: Files, config: MkDocsConfig, nav: Navigation): def _resolve(self, files: Files, config: MkDocsConfig):
path = os.path.join(self.config.blog_dir, "index.md") path = os.path.join(self.config.blog_dir, "index.md")
path = os.path.normpath(path) path = os.path.normpath(path)
# Obtain entrypoint page # 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) file = files.get_file_from_path(path)
page = file.page return View(None, file, config)
# 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 [self._resolve_siblings(view, nav), 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 # 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 # actual post (and not a page), or behavior might be unpredictable
@@ -397,10 +431,8 @@ class BlogPlugin(BasePlugin[BlogConfig]):
if not os.path.isdir(name): if not os.path.isdir(name):
os.makedirs(name, exist_ok = True) os.makedirs(name, exist_ok = True)
# Filter posts from pages - prior to calling this function, the caller # Filter posts from pages
# should've excluded all posts, so they're not listed in the navigation for file in files.documentation_pages():
inclusion = InclusionLevel.is_excluded
for file in files.documentation_pages(inclusion = inclusion):
if not file.src_path.startswith(path): if not file.src_path.startswith(path):
continue continue
@@ -434,7 +466,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
config.load_dict(yaml.load(f, SafeLoader) or {}) config.load_dict(yaml.load(f, SafeLoader) or {})
# The authors file could not be loaded because of a syntax error, # The authors file could not be loaded because of a syntax error,
# which we display to the user with a nice error message # which we display to the author with a nice error message
except Exception as e: except Exception as e:
raise PluginError( raise PluginError(
f"Error reading authors file '{path}' in '{docs}':\n" f"Error reading authors file '{path}' in '{docs}':\n"
@@ -467,8 +499,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Return authors # Return authors
return config.authors return config.authors
# Resolve views and pages of the given view that were generated by this # Resolve views of the given view in pre-order
# plugin when building the site and yield them in pre-order
def _resolve_views(self, view: View): def _resolve_views(self, view: View):
yield view yield view
@@ -478,11 +509,6 @@ class BlogPlugin(BasePlugin[BlogConfig]):
assert isinstance(next, View) assert isinstance(next, View)
yield next yield next
# Resolve pages
for page in view.pages:
assert isinstance(page, View)
yield page
# Resolve siblings of a navigation item # Resolve siblings of a navigation item
def _resolve_siblings(self, item: StructureItem, nav: Navigation): def _resolve_siblings(self, item: StructureItem, nav: Navigation):
if isinstance(item.parent, Section): if isinstance(item.parent, Section):
@@ -492,56 +518,6 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# 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 as a sibling to the given view, make sure it's 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])
# -------------------------------------------------------------------------
# Generate entrypoint - the entrypoint must always be present, and thus is
# created before the navigation is constructed if it does not exist yet
def _generate(self, config: MkDocsConfig, files: Files):
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
# added to the docs directory, not to the temporary directory
docs = os.path.relpath(config.docs_dir)
file = os.path.join(docs, path)
if not os.path.isfile(file):
file = self._path_to_file(path, config, temp = False)
self._save_to_file(file.abs_src_path, "# Blog\n\n")
# Append entrypoint to files
files.append(file)
# Generate views for archive - analyze posts and generate the necessary # Generate views for archive - analyze posts and generate the necessary
# views, taking the date format provided by the author into account # views, taking the date format provided by the author into account
def _generate_archive(self, config: MkDocsConfig, files: Files): def _generate_archive(self, config: MkDocsConfig, files: Files):
@@ -552,15 +528,19 @@ class BlogPlugin(BasePlugin[BlogConfig]):
name = self._format_date_for_archive(date, config) name = self._format_date_for_archive(date, config)
path = self._format_path_for_archive(post, config) path = self._format_path_for_archive(post, config)
# Create view for archive if it doesn't exist # Create file for view, if it does not exist
file = files.get_file_from_path(path) file = files.get_file_from_path(path)
if not file: if not file or self.temp_dir not in file.abs_src_path:
file = self._path_to_file(path, config) 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}") self._save_to_file(file.abs_src_path, f"# {name}")
# Create and yield archive view # Create and yield view - we don't explicitly set the title of
yield Archive(name, file, config) # the view, so authors can override them in the page's content
files.append(file) if not isinstance(file.page, Archive):
yield Archive(None, file, config)
# Assign post to archive # Assign post to archive
assert isinstance(file.page, Archive) assert isinstance(file.page, Archive)
@@ -583,15 +563,19 @@ class BlogPlugin(BasePlugin[BlogConfig]):
f"'{docs}': category '{name}' not in allow list" f"'{docs}': category '{name}' not in allow list"
) )
# Create view for category if it doesn't exist # Create file for view, if it does not exist
file = files.get_file_from_path(path) file = files.get_file_from_path(path)
if not file: if not file or self.temp_dir not in file.abs_src_path:
file = self._path_to_file(path, config) 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}") self._save_to_file(file.abs_src_path, f"# {name}")
# Create and yield category view # Create and yield view - we don't explicitly set the title of
yield Category(name, file, config) # the view, so authors can override them in the page's content
files.append(file) if not isinstance(file.page, Category):
yield Category(None, file, config)
# Assign post to category and vice versa # Assign post to category and vice versa
assert isinstance(file.page, Category) assert isinstance(file.page, Category)
@@ -603,39 +587,70 @@ class BlogPlugin(BasePlugin[BlogConfig]):
def _generate_pages(self, view: View, config: MkDocsConfig, files: Files): def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
yield view yield view
# Extract settings for pagination # Compute base path for pagination - if the given view is an index file,
step = self.config.pagination_per_page # we need to pop the file name from the base so it's not part of the URL
prev = view base, _ = posixpath.splitext(view.file.src_uri)
if view.file.name == "index":
base = posixpath.dirname(base)
# Compute pagination boundaries and create pages - pages are internally # Compute pagination boundaries and create pages - pages are internally
# handled as copies of a view, as they map to the same source location # handled as copies of a view, as they map to the same source location
step = self.config.pagination_per_page
for at in range(step, len(view.posts), step): for at in range(step, len(view.posts), step):
base, _ = posixpath.splitext(view.file.src_uri)
# Compute path and create a file for pagination
path = self._format_path_for_pagination(base, 1 + at // step) path = self._format_path_for_pagination(base, 1 + at // step)
file = self._path_to_file(path, config)
# Replace source file system path and append to files # Create file for view, if it does not exist
file.src_uri = view.file.src_uri file = files.get_file_from_path(path)
file.abs_src_path = view.file.abs_src_path if not file or self.temp_dir not in file.abs_src_path:
files.append(file) file = self._path_to_file(path, config)
files.append(file)
# Create view and attach to previous page # Create view and attach to previous page
next = View(view.title, file, config) if not isinstance(file.page, View):
self._attach(prev, [ yield View(None, file, config)
view.previous_page,
next,
view.next_page
])
# Assign posts and pages to view # Assign pages and posts to view
next.posts = view.posts assert isinstance(file.page, View)
next.pages = view.pages file.page.pages = view.pages
file.page.posts = view.posts
# Continue with next page # -------------------------------------------------------------------------
prev = next
yield next # 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 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 it's 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])
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -814,7 +829,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Return file # Return file
return file return file
# Write the content to the file located at the given path # Create a file with the given content on disk
def _save_to_file(self, path: str, content: str): def _save_to_file(self, path: str, content: str):
os.makedirs(os.path.dirname(path), exist_ok = True) os.makedirs(os.path.dirname(path), exist_ok = True)
with open(path, "w") as f: with open(path, "w") as f:

View File

@@ -18,6 +18,8 @@
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE. # IN THE SOFTWARE.
from __future__ import annotations
import logging import logging
import os import os
import posixpath import posixpath
@@ -39,7 +41,7 @@ from shutil import rmtree
from tempfile import mkdtemp from tempfile import mkdtemp
from yaml import SafeLoader from yaml import SafeLoader
from .author import Author, Authors from .author import Authors
from .config import BlogConfig from .config import BlogConfig
from .structure import Archive, Category, Excerpt, Post, View from .structure import Archive, Category, Excerpt, Post, View
from .templates import url_filter from .templates import url_filter
@@ -60,7 +62,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
self.is_serve = False self.is_serve = False
self.is_dirty = False self.is_dirty = False
# Initialize a temporary directory # Initialize temporary directory
self.temp_dir = mkdtemp() self.temp_dir = mkdtemp()
# Determine whether we're serving the site # Determine whether we're serving the site
@@ -87,12 +89,18 @@ class BlogPlugin(BasePlugin[BlogConfig]):
self.config.categories_toc = self.config.blog_toc self.config.categories_toc = self.config.blog_toc
# By default, drafts are rendered when the documentation is served, # By default, drafts are rendered when the documentation is served,
# but not when it is built, for a better authoring experience # but not when it is built, for a better user experience
if self.is_serve and self.config.draft_on_serve: if self.is_serve and self.config.draft_on_serve:
self.config.draft = True self.config.draft = True
# Remove posts before constructing navigation (run later) - allow other # Resolve and load posts and generate views (run later) - we want to allow
# plugins to alter the list of files and navigation prior to this plugin # 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) @event_priority(-50)
def on_files(self, files, *, config): def on_files(self, files, *, config):
if not self.config.enabled: if not self.config.enabled:
@@ -106,80 +114,108 @@ class BlogPlugin(BasePlugin[BlogConfig]):
path = self.config.post_dir.format(blog = root) path = self.config.post_dir.format(blog = root)
path = posixpath.normpath(path) path = posixpath.normpath(path)
# Temporarily remove posts and adjust destination paths for assets # Adjust destination paths for media files
for file in files: for file in files.media_files():
if not file.src_uri.startswith(path): if not file.src_uri.startswith(path):
continue continue
# We must exclude all files related to posts from here on, so MkDocs # We need to adjust destination paths for assets to remove the
# 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 # purely functional posts directory prefix when building
if file.is_media_file(): file.dest_uri = file.dest_uri.replace(path, root)
file.dest_uri = file.dest_uri.replace(path, root) file.abs_dest_path = os.path.join(site, file.dest_path)
file.abs_dest_path = os.path.join(site, file.dest_path) file.url = file.url.replace(path, root)
file.url = file.url.replace(path, root)
# Generate entrypoint, if it does not exist yet
self._generate(config, files)
# 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 # Resolve entrypoint and posts sorted by descending date - if the posts
# directory or entrypoint do not exist, they are automatically created # directory or entrypoint do not exist, they are automatically created
self.blog = self._resolve(files, config, nav) self.blog = self._resolve(files, config)
self.blog.posts = sorted( self.blog.posts = sorted(
self._resolve_posts(files, config), self._resolve_posts(files, config),
key = lambda post: post.config.date.created, key = lambda post: post.config.date.created,
reverse = True reverse = True
) )
# Temporarily remove posts from navigation
for post in self.blog.posts:
post.file.inclusion = InclusionLevel.EXCLUDED
# 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)
self.blog.views.extend(views)
# Generate pages for views
if self.config.pagination:
for view in self._resolve_views(self.blog):
for page in self._generate_pages(view, config, files):
page.file.inclusion = InclusionLevel.EXCLUDED
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
# Hack: since MkDocs will always create a page for the entrypoint even
# though we already created it in `on_files`, we must replace the page
# that MkDocs created with the entrypoint we already have on our hands.
# Hopefully, this hack can be removed soon - see https://t.ly/9nehI
page = self.blog.file.page
self._attach_at(page.parent, page, self.blog)
# Update entrypoint in navigation (also part of the hack above)
self.blog.file.page = self.blog
for items in [self._resolve_siblings(self.blog, nav), nav.pages]:
items[items.index(page)] = self.blog
# Attach posts to entrypoint without adding them to the navigation, so # Attach posts to entrypoint without adding them to the navigation, so
# that the entrypoint is considered to be the active page for each post. # 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]) self._attach(self.blog, [None, *reversed(self.blog.posts), None])
for post in self.blog.posts: for post in self.blog.posts:
post.file.inclusion = InclusionLevel.NOT_IN_NAV post.file.inclusion = InclusionLevel.NOT_IN_NAV
# Generate and attach views for archive # Revert temporary exclusion of views from navigation
for view in self._resolve_views(self.blog):
for page in view.pages:
page.file.inclusion = InclusionLevel.INCLUDED
# Attach views for archive
if self.config.archive: if self.config.archive:
views = [*self._generate_archive(config, files)] title = self._translate(self.config.archive_name, config)
self.blog.views.extend(views) views = [_ for _ in self.blog.views if isinstance(_, Archive)]
# Attach and link views for archive # Attach and link views for archive
title = self._translate(self.config.archive_name, config)
self._attach_to(self.blog, Section(title, views), nav) self._attach_to(self.blog, Section(title, views), nav)
# Generate and attach views for categories # Attach views for categories
if self.config.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) title = self._translate(self.config.categories_name, config)
self._attach_to(self.blog, Section(title, views), nav) views = [_ for _ in self.blog.views if isinstance(_, Category)]
# Paginate generated views, if enabled # Attach and link views for categories, if any
if views:
self._attach_to(self.blog, Section(title, views), nav)
# Attach pages for views
if self.config.pagination: if self.config.pagination:
for view in [*self._resolve_views(self.blog)]: for view in self._resolve_views(self.blog):
for page in self._generate_pages(view, config, files): for at in range(1, len(view.pages)):
view.pages.append(page) self._attach_at(view.pages[at - 1], view, view.pages[at])
# Replace source file system path
view.pages[at].file.src_uri = view.file.src_uri
view.pages[at].file.abs_src_path = view.file.abs_src_path
# Prepare post for rendering (run later) - allow other plugins to alter # 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 # the contents or metadata of a post before it is rendered and make sure
@@ -199,9 +235,10 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# We set the contents of the view to its title if pagination should # We set the contents of the view to its title if pagination should
# not keep the content of the original view on paginaged views # not keep the content of the original view on paginaged views
if not self.config.pagination_keep_content: if not self.config.pagination_keep_content:
if page in self._resolve_views(self.blog): view = page.pages[0] if isinstance(page, View) else page
if view in self._resolve_views(self.blog):
assert isinstance(page, View) assert isinstance(page, View)
if 0 < page.pages.index(page): if page.pages.index(page):
main = page.parent main = page.parent
# We need to use the rendered title of the original view # We need to use the rendered title of the original view
@@ -282,7 +319,8 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Skip if page is not a view managed by this instance - this plugin has # 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 # support for multiple instances, which is why this check is necessary
if page not in self._resolve_views(self.blog): view = page.pages[0] if isinstance(page, View) else page
if view not in self._resolve_views(self.blog):
return return
# Retrieve parent view or section # Retrieve parent view or section
@@ -344,28 +382,24 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Resolve entrypoint - the entrypoint of the blog must have been created # 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 # if it did not exist before, and hosts all posts sorted by descending date
def _resolve(self, files: Files, config: MkDocsConfig, nav: Navigation): def _resolve(self, files: Files, config: MkDocsConfig):
path = os.path.join(self.config.blog_dir, "index.md") path = os.path.join(self.config.blog_dir, "index.md")
path = os.path.normpath(path) path = os.path.normpath(path)
# Obtain entrypoint page # 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) file = files.get_file_from_path(path)
page = file.page return View(None, file, config)
# 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 [self._resolve_siblings(view, nav), 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 # 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 # actual post (and not a page), or behavior might be unpredictable
@@ -397,10 +431,8 @@ class BlogPlugin(BasePlugin[BlogConfig]):
if not os.path.isdir(name): if not os.path.isdir(name):
os.makedirs(name, exist_ok = True) os.makedirs(name, exist_ok = True)
# Filter posts from pages - prior to calling this function, the caller # Filter posts from pages
# should've excluded all posts, so they're not listed in the navigation for file in files.documentation_pages():
inclusion = InclusionLevel.is_excluded
for file in files.documentation_pages(inclusion = inclusion):
if not file.src_path.startswith(path): if not file.src_path.startswith(path):
continue continue
@@ -434,7 +466,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
config.load_dict(yaml.load(f, SafeLoader) or {}) config.load_dict(yaml.load(f, SafeLoader) or {})
# The authors file could not be loaded because of a syntax error, # The authors file could not be loaded because of a syntax error,
# which we display to the user with a nice error message # which we display to the author with a nice error message
except Exception as e: except Exception as e:
raise PluginError( raise PluginError(
f"Error reading authors file '{path}' in '{docs}':\n" f"Error reading authors file '{path}' in '{docs}':\n"
@@ -467,8 +499,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Return authors # Return authors
return config.authors return config.authors
# Resolve views and pages of the given view that were generated by this # Resolve views of the given view in pre-order
# plugin when building the site and yield them in pre-order
def _resolve_views(self, view: View): def _resolve_views(self, view: View):
yield view yield view
@@ -478,11 +509,6 @@ class BlogPlugin(BasePlugin[BlogConfig]):
assert isinstance(next, View) assert isinstance(next, View)
yield next yield next
# Resolve pages
for page in view.pages:
assert isinstance(page, View)
yield page
# Resolve siblings of a navigation item # Resolve siblings of a navigation item
def _resolve_siblings(self, item: StructureItem, nav: Navigation): def _resolve_siblings(self, item: StructureItem, nav: Navigation):
if isinstance(item.parent, Section): if isinstance(item.parent, Section):
@@ -492,56 +518,6 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# 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 as a sibling to the given view, make sure it's 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])
# -------------------------------------------------------------------------
# Generate entrypoint - the entrypoint must always be present, and thus is
# created before the navigation is constructed if it does not exist yet
def _generate(self, config: MkDocsConfig, files: Files):
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
# added to the docs directory, not to the temporary directory
docs = os.path.relpath(config.docs_dir)
file = os.path.join(docs, path)
if not os.path.isfile(file):
file = self._path_to_file(path, config, temp = False)
self._save_to_file(file.abs_src_path, "# Blog\n\n")
# Append entrypoint to files
files.append(file)
# Generate views for archive - analyze posts and generate the necessary # Generate views for archive - analyze posts and generate the necessary
# views, taking the date format provided by the author into account # views, taking the date format provided by the author into account
def _generate_archive(self, config: MkDocsConfig, files: Files): def _generate_archive(self, config: MkDocsConfig, files: Files):
@@ -552,15 +528,19 @@ class BlogPlugin(BasePlugin[BlogConfig]):
name = self._format_date_for_archive(date, config) name = self._format_date_for_archive(date, config)
path = self._format_path_for_archive(post, config) path = self._format_path_for_archive(post, config)
# Create view for archive if it doesn't exist # Create file for view, if it does not exist
file = files.get_file_from_path(path) file = files.get_file_from_path(path)
if not file: if not file or self.temp_dir not in file.abs_src_path:
file = self._path_to_file(path, config) 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}") self._save_to_file(file.abs_src_path, f"# {name}")
# Create and yield archive view # Create and yield view - we don't explicitly set the title of
yield Archive(name, file, config) # the view, so authors can override them in the page's content
files.append(file) if not isinstance(file.page, Archive):
yield Archive(None, file, config)
# Assign post to archive # Assign post to archive
assert isinstance(file.page, Archive) assert isinstance(file.page, Archive)
@@ -583,15 +563,19 @@ class BlogPlugin(BasePlugin[BlogConfig]):
f"'{docs}': category '{name}' not in allow list" f"'{docs}': category '{name}' not in allow list"
) )
# Create view for category if it doesn't exist # Create file for view, if it does not exist
file = files.get_file_from_path(path) file = files.get_file_from_path(path)
if not file: if not file or self.temp_dir not in file.abs_src_path:
file = self._path_to_file(path, config) 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}") self._save_to_file(file.abs_src_path, f"# {name}")
# Create and yield category view # Create and yield view - we don't explicitly set the title of
yield Category(name, file, config) # the view, so authors can override them in the page's content
files.append(file) if not isinstance(file.page, Category):
yield Category(None, file, config)
# Assign post to category and vice versa # Assign post to category and vice versa
assert isinstance(file.page, Category) assert isinstance(file.page, Category)
@@ -603,39 +587,70 @@ class BlogPlugin(BasePlugin[BlogConfig]):
def _generate_pages(self, view: View, config: MkDocsConfig, files: Files): def _generate_pages(self, view: View, config: MkDocsConfig, files: Files):
yield view yield view
# Extract settings for pagination # Compute base path for pagination - if the given view is an index file,
step = self.config.pagination_per_page # we need to pop the file name from the base so it's not part of the URL
prev = view base, _ = posixpath.splitext(view.file.src_uri)
if view.file.name == "index":
base = posixpath.dirname(base)
# Compute pagination boundaries and create pages - pages are internally # Compute pagination boundaries and create pages - pages are internally
# handled as copies of a view, as they map to the same source location # handled as copies of a view, as they map to the same source location
step = self.config.pagination_per_page
for at in range(step, len(view.posts), step): for at in range(step, len(view.posts), step):
base, _ = posixpath.splitext(view.file.src_uri)
# Compute path and create a file for pagination
path = self._format_path_for_pagination(base, 1 + at // step) path = self._format_path_for_pagination(base, 1 + at // step)
file = self._path_to_file(path, config)
# Replace source file system path and append to files # Create file for view, if it does not exist
file.src_uri = view.file.src_uri file = files.get_file_from_path(path)
file.abs_src_path = view.file.abs_src_path if not file or self.temp_dir not in file.abs_src_path:
files.append(file) file = self._path_to_file(path, config)
files.append(file)
# Create view and attach to previous page # Create view and attach to previous page
next = View(view.title, file, config) if not isinstance(file.page, View):
self._attach(prev, [ yield View(None, file, config)
view.previous_page,
next,
view.next_page
])
# Assign posts and pages to view # Assign pages and posts to view
next.posts = view.posts assert isinstance(file.page, View)
next.pages = view.pages file.page.pages = view.pages
file.page.posts = view.posts
# Continue with next page # -------------------------------------------------------------------------
prev = next
yield next # 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 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 it's 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])
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -814,7 +829,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# Return file # Return file
return file return file
# Write the content to the file located at the given path # Create a file with the given content on disk
def _save_to_file(self, path: str, content: str): def _save_to_file(self, path: str, content: str):
os.makedirs(os.path.dirname(path), exist_ok = True) os.makedirs(os.path.dirname(path), exist_ok = True)
with open(path, "w") as f: with open(path, "w") as f: