Refactored blog plugin view generation

This commit is contained in:
squidfunk 2023-08-30 15:27:40 +02:00
parent fb18b20695
commit 4c6b004fe4
No known key found for this signature in database
GPG Key ID: 5ED40BC4F9C436DF
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
# IN THE SOFTWARE.
from __future__ import annotations
import logging
import os
import posixpath
@ -39,7 +41,7 @@ from shutil import rmtree
from tempfile import mkdtemp
from yaml import SafeLoader
from .author import Author, Authors
from .author import Authors
from .config import BlogConfig
from .structure import Archive, Category, Excerpt, Post, View
from .templates import url_filter
@ -60,7 +62,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
self.is_serve = False
self.is_dirty = False
# Initialize a temporary directory
# Initialize temporary directory
self.temp_dir = mkdtemp()
# Determine whether we're serving the site
@ -87,12 +89,18 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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
# 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
# Remove posts before constructing navigation (run later) - allow other
# plugins to alter the list of files and navigation prior to this plugin
# 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:
@ -106,80 +114,108 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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:
# Adjust destination paths for media files
for file in files.media_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
# We 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)
# 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
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, nav)
self.blog = self._resolve(files, config)
self.blog.posts = sorted(
self._resolve_posts(files, config),
key = lambda post: post.config.date.created,
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
# 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
# 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
# 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:
views = [*self._generate_archive(config, files)]
self.blog.views.extend(views)
title = self._translate(self.config.archive_name, config)
views = [_ for _ in self.blog.views if isinstance(_, Archive)]
# Attach and link views for archive
title = self._translate(self.config.archive_name, config)
self._attach_to(self.blog, Section(title, views), nav)
# Generate and attach views for categories
# 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, 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:
for view in [*self._resolve_views(self.blog)]:
for page in self._generate_pages(view, config, files):
view.pages.append(page)
for view in self._resolve_views(self.blog):
for at in range(1, len(view.pages)):
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
# 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
# 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):
view = page.pages[0] if isinstance(page, View) else page
if view in self._resolve_views(self.blog):
assert isinstance(page, View)
if 0 < page.pages.index(page):
if page.pages.index(page):
main = page.parent
# 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
# 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
# Retrieve parent view or section
@ -344,28 +382,24 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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, nav: Navigation):
def _resolve(self, files: Files, config: MkDocsConfig):
path = os.path.join(self.config.blog_dir, "index.md")
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)
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 [self._resolve_siblings(view, nav), nav.pages]:
items[items.index(page)] = view
# Return view
return view
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
@ -397,10 +431,8 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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):
# Filter posts from pages
for file in files.documentation_pages():
if not file.src_path.startswith(path):
continue
@ -434,7 +466,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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
# 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"
@ -467,8 +499,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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
# Resolve views of the given view in pre-order
def _resolve_views(self, view: View):
yield view
@ -478,11 +509,6 @@ class BlogPlugin(BasePlugin[BlogConfig]):
assert isinstance(next, View)
yield next
# Resolve pages
for page in view.pages:
assert isinstance(page, View)
yield page
# Resolve siblings of a navigation item
def _resolve_siblings(self, item: StructureItem, nav: Navigation):
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
# views, taking the date format provided by the author into account
def _generate_archive(self, config: MkDocsConfig, files: Files):
@ -552,15 +528,19 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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
# Create file for view, if it does not exist
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)
files.append(file)
# Create file in temporary directory
self._save_to_file(file.abs_src_path, f"# {name}")
# Create and yield archive view
yield Archive(name, file, config)
files.append(file)
# Create and yield view - we don't explicitly set the title of
# the view, so authors can override them in the page's content
if not isinstance(file.page, Archive):
yield Archive(None, file, config)
# Assign post to archive
assert isinstance(file.page, Archive)
@ -583,15 +563,19 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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)
if not file:
if not file or self.temp_dir not in file.abs_src_path:
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}")
# Create and yield category view
yield Category(name, file, config)
files.append(file)
# Create and yield view - we don't explicitly set the title of
# the view, so authors can override them in the page's content
if not isinstance(file.page, Category):
yield Category(None, file, config)
# Assign post to category and vice versa
assert isinstance(file.page, Category)
@ -603,39 +587,70 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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 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
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
# 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):
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)
file = self._path_to_file(path, config)
# Replace source file system path and append to files
file.src_uri = view.file.src_uri
file.abs_src_path = view.file.abs_src_path
files.append(file)
# Create file for view, if it does not exist
file = files.get_file_from_path(path)
if not file or self.temp_dir not in file.abs_src_path:
file = self._path_to_file(path, config)
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
])
if not isinstance(file.page, View):
yield View(None, file, config)
# Assign posts and pages to view
next.posts = view.posts
next.pages = view.pages
# Assign pages and posts to view
assert isinstance(file.page, View)
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
# 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):
os.makedirs(os.path.dirname(path), exist_ok = True)
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
# IN THE SOFTWARE.
from __future__ import annotations
import logging
import os
import posixpath
@ -39,7 +41,7 @@ from shutil import rmtree
from tempfile import mkdtemp
from yaml import SafeLoader
from .author import Author, Authors
from .author import Authors
from .config import BlogConfig
from .structure import Archive, Category, Excerpt, Post, View
from .templates import url_filter
@ -60,7 +62,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
self.is_serve = False
self.is_dirty = False
# Initialize a temporary directory
# Initialize temporary directory
self.temp_dir = mkdtemp()
# Determine whether we're serving the site
@ -87,12 +89,18 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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
# 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
# Remove posts before constructing navigation (run later) - allow other
# plugins to alter the list of files and navigation prior to this plugin
# 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:
@ -106,80 +114,108 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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:
# Adjust destination paths for media files
for file in files.media_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
# We 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)
# 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
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, nav)
self.blog = self._resolve(files, config)
self.blog.posts = sorted(
self._resolve_posts(files, config),
key = lambda post: post.config.date.created,
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
# 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
# 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
# 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:
views = [*self._generate_archive(config, files)]
self.blog.views.extend(views)
title = self._translate(self.config.archive_name, config)
views = [_ for _ in self.blog.views if isinstance(_, Archive)]
# Attach and link views for archive
title = self._translate(self.config.archive_name, config)
self._attach_to(self.blog, Section(title, views), nav)
# Generate and attach views for categories
# 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, 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:
for view in [*self._resolve_views(self.blog)]:
for page in self._generate_pages(view, config, files):
view.pages.append(page)
for view in self._resolve_views(self.blog):
for at in range(1, len(view.pages)):
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
# 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
# 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):
view = page.pages[0] if isinstance(page, View) else page
if view in self._resolve_views(self.blog):
assert isinstance(page, View)
if 0 < page.pages.index(page):
if page.pages.index(page):
main = page.parent
# 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
# 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
# Retrieve parent view or section
@ -344,28 +382,24 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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, nav: Navigation):
def _resolve(self, files: Files, config: MkDocsConfig):
path = os.path.join(self.config.blog_dir, "index.md")
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)
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 [self._resolve_siblings(view, nav), nav.pages]:
items[items.index(page)] = view
# Return view
return view
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
@ -397,10 +431,8 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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):
# Filter posts from pages
for file in files.documentation_pages():
if not file.src_path.startswith(path):
continue
@ -434,7 +466,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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
# 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"
@ -467,8 +499,7 @@ class BlogPlugin(BasePlugin[BlogConfig]):
# 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
# Resolve views of the given view in pre-order
def _resolve_views(self, view: View):
yield view
@ -478,11 +509,6 @@ class BlogPlugin(BasePlugin[BlogConfig]):
assert isinstance(next, View)
yield next
# Resolve pages
for page in view.pages:
assert isinstance(page, View)
yield page
# Resolve siblings of a navigation item
def _resolve_siblings(self, item: StructureItem, nav: Navigation):
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
# views, taking the date format provided by the author into account
def _generate_archive(self, config: MkDocsConfig, files: Files):
@ -552,15 +528,19 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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
# Create file for view, if it does not exist
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)
files.append(file)
# Create file in temporary directory
self._save_to_file(file.abs_src_path, f"# {name}")
# Create and yield archive view
yield Archive(name, file, config)
files.append(file)
# Create and yield view - we don't explicitly set the title of
# the view, so authors can override them in the page's content
if not isinstance(file.page, Archive):
yield Archive(None, file, config)
# Assign post to archive
assert isinstance(file.page, Archive)
@ -583,15 +563,19 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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)
if not file:
if not file or self.temp_dir not in file.abs_src_path:
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}")
# Create and yield category view
yield Category(name, file, config)
files.append(file)
# Create and yield view - we don't explicitly set the title of
# the view, so authors can override them in the page's content
if not isinstance(file.page, Category):
yield Category(None, file, config)
# Assign post to category and vice versa
assert isinstance(file.page, Category)
@ -603,39 +587,70 @@ class BlogPlugin(BasePlugin[BlogConfig]):
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 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
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
# 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):
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)
file = self._path_to_file(path, config)
# Replace source file system path and append to files
file.src_uri = view.file.src_uri
file.abs_src_path = view.file.abs_src_path
files.append(file)
# Create file for view, if it does not exist
file = files.get_file_from_path(path)
if not file or self.temp_dir not in file.abs_src_path:
file = self._path_to_file(path, config)
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
])
if not isinstance(file.page, View):
yield View(None, file, config)
# Assign posts and pages to view
next.posts = view.posts
next.pages = view.pages
# Assign pages and posts to view
assert isinstance(file.page, View)
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
# 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):
os.makedirs(os.path.dirname(path), exist_ok = True)
with open(path, "w") as f: