From 4c6b004fe462102c521dc5f6e45f5c8dbe68ace5 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Wed, 30 Aug 2023 15:27:40 +0200 Subject: [PATCH] Refactored blog plugin view generation --- material/plugins/blog/plugin.py | 353 +++++++++++++++++--------------- src/plugins/blog/plugin.py | 353 +++++++++++++++++--------------- 2 files changed, 368 insertions(+), 338 deletions(-) diff --git a/material/plugins/blog/plugin.py b/material/plugins/blog/plugin.py index 9bfb89685..e0a9fdb6e 100644 --- a/material/plugins/blog/plugin.py +++ b/material/plugins/blog/plugin.py @@ -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: diff --git a/src/plugins/blog/plugin.py b/src/plugins/blog/plugin.py index 9bfb89685..e0a9fdb6e 100644 --- a/src/plugins/blog/plugin.py +++ b/src/plugins/blog/plugin.py @@ -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: