mirror of
https://github.com/squidfunk/mkdocs-material.git
synced 2024-06-14 11:52:32 +03:00
Refactored blog plugin view generation
This commit is contained in:
parent
fb18b20695
commit
4c6b004fe4
@ -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:
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user