diff options
Diffstat (limited to 'docs/src/material/plugins/blog/plugin.py')
| -rw-r--r-- | docs/src/material/plugins/blog/plugin.py | 884 |
1 files changed, 884 insertions, 0 deletions
diff --git a/docs/src/material/plugins/blog/plugin.py b/docs/src/material/plugins/blog/plugin.py new file mode 100644 index 00000000..375b8cfe --- /dev/null +++ b/docs/src/material/plugins/blog/plugin.py @@ -0,0 +1,884 @@ +# Copyright (c) 2016-2023 Martin Donath <martin.donath@squidfunk.com> + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import logging +import os +import posixpath +import yaml + +from babel.dates import format_date +from datetime import datetime +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.exceptions import PluginError +from mkdocs.plugins import BasePlugin, event_priority +from mkdocs.structure import StructureItem +from mkdocs.structure.files import File, Files, InclusionLevel +from mkdocs.structure.nav import Navigation, Section +from mkdocs.structure.pages import Page +from mkdocs.utils import copy_file, get_relative_url +from paginate import Page as Pagination +from shutil import rmtree +from tempfile import mkdtemp +from yaml import SafeLoader + +from .author import Authors +from .config import BlogConfig +from .readtime import readtime +from .structure import Archive, Category, Excerpt, Post, View +from .templates import url_filter + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + +# Blog plugin +class BlogPlugin(BasePlugin[BlogConfig]): + supports_multiple_instances = True + + # Initialize plugin + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Initialize incremental builds + self.is_serve = False + self.is_dirty = False + + # Initialize temporary directory + self.temp_dir = mkdtemp() + + # Determine whether we're serving the site + def on_startup(self, *, command, dirty): + self.is_serve = command == "serve" + self.is_dirty = dirty + + # Initialize authors and set defaults + def on_config(self, config): + if not self.config.enabled: + return + + # Initialize entrypoint + self.blog: View + + # Initialize and resolve authors, if enabled + if self.config.authors: + self.authors = self._resolve_authors(config) + + # Initialize table of contents settings + if not isinstance(self.config.archive_toc, bool): + self.config.archive_toc = self.config.blog_toc + if not isinstance(self.config.categories_toc, bool): + 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 user experience + if self.is_serve and self.config.draft_on_serve: + self.config.draft = True + + # Resolve and load posts and generate views (run later) - we want to allow + # other plugins to add generated posts or views, so we run this plugin as + # late as possible. We also need to remove the posts from the navigation + # before navigation is constructed, as the entrypoint should be considered + # to be the active page for each post. The URLs of posts are computed before + # Markdown processing, so that when linking to and from posts, behavior is + # exactly the same as with regular documentation pages. We create all pages + # related to posts as part of this plugin, so we control the entire process. + @event_priority(-50) + def on_files(self, files, *, config): + if not self.config.enabled: + return + + # Resolve path to entrypoint and site directory + root = posixpath.normpath(self.config.blog_dir) + site = config.site_dir + + # Compute path to posts directory + path = self.config.post_dir.format(blog = root) + path = posixpath.normpath(path) + + # Adjust destination paths for media files + for file in files.media_files(): + if not file.src_uri.startswith(path): + continue + + # We need to adjust destination paths for assets to remove the + # purely functional posts directory prefix when building + file.dest_uri = file.dest_uri.replace(path, root) + file.abs_dest_path = os.path.join(site, file.dest_path) + file.url = file.url.replace(path, root) + + # Resolve entrypoint and posts sorted by descending date - if the posts + # directory or entrypoint do not exist, they are automatically created + self.blog = self._resolve(files, config) + self.blog.posts = sorted( + self._resolve_posts(files, config), + key = lambda post: post.config.date.created, + reverse = True + ) + + # Generate views for archive + if self.config.archive: + views = self._generate_archive(config, files) + self.blog.views.extend(views) + + # Generate views for categories + if self.config.categories: + views = self._generate_categories(config, files) + 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 + + # If we're not building a standalone blog, the entrypoint will always + # have a parent when it is included in the navigation. The parent is + # essential to correctly resolve the location where the archive and + # category views are attached. If the entrypoint doesn't have a parent, + # we know that the author did not include it in the navigation, so we + # explicitly mark it as not included. + if not self.blog.parent and self.config.blog_dir != ".": + self.blog.file.inclusion = InclusionLevel.NOT_IN_NAV + + # Attach posts to entrypoint without adding them to the navigation, so + # that the entrypoint is considered to be the active page for each post + self._attach(self.blog, [None, *reversed(self.blog.posts), None]) + for post in self.blog.posts: + post.file.inclusion = InclusionLevel.NOT_IN_NAV + + # Revert temporary exclusion of views from navigation + for view in self._resolve_views(self.blog): + for page in view.pages: + page.file.inclusion = self.blog.file.inclusion + + # Attach views for archive + if self.config.archive: + title = self._translate(self.config.archive_name, config) + views = [_ for _ in self.blog.views if isinstance(_, Archive)] + + # Attach and link views for archive + if self.blog.file.inclusion.is_in_nav(): + self._attach_to(self.blog, Section(title, views), nav) + + # Attach views for categories + if self.config.categories: + title = self._translate(self.config.categories_name, config) + views = [_ for _ in self.blog.views if isinstance(_, Category)] + + # Attach and link views for categories, if any + if self.blog.file.inclusion.is_in_nav() and views: + self._attach_to(self.blog, Section(title, views), nav) + + # Attach pages for views + if self.config.pagination: + for view in self._resolve_views(self.blog): + for at in range(1, len(view.pages)): + self._attach_at(view.parent, view, view.pages[at]) + + # Prepare post for rendering (run later) - allow other plugins to alter + # the contents or metadata of a post before it is rendered and make sure + # that the post includes a separator, which is essential for rendering + # excerpts that should be included in views + @event_priority(-50) + def on_page_markdown(self, markdown, *, page, config, files): + if not self.config.enabled: + return + + # Skip if page is not a post managed by this instance - this plugin has + # support for multiple instances, which is why this check is necessary + if page not in self.blog.posts: + if not self.config.pagination: + return + + # We set the contents of the view to its title if pagination should + # not keep the content of the original view on paginated views + if not self.config.pagination_keep_content: + view = self._resolve_original(page) + if view in self._resolve_views(self.blog): + + # If the current view is paginated, use the rendered title + # of the original view in case the author set the title in + # the page's contents, or it would be overridden with the + # one set in mkdocs.yml, leading to inconsistent headings + assert isinstance(view, View) + if view != page: + name = view._title_from_render or view.title + return f"# {name}" + + # Nothing more to be done for views + return + + # Extract and assign authors to post, if enabled + if self.config.authors: + for name in page.config.authors: + if name not in self.authors: + raise PluginError(f"Couldn't find author '{name}'") + + # Append to list of authors + page.authors.append(self.authors[name]) + + # Extract settings for excerpts + separator = self.config.post_excerpt_separator + max_authors = self.config.post_excerpt_max_authors + max_categories = self.config.post_excerpt_max_categories + + # Ensure presence of separator and throw, if its absent and required - + # we append the separator to the end of the contents of the post, if it + # is not already present, so we can remove footnotes or other content + # from the excerpt without affecting the content of the excerpt + if separator not in page.markdown: + path = page.file.src_path + if self.config.post_excerpt == "required": + raise PluginError( + f"Couldn't find '{separator}' separator in '{path}'" + ) + else: + page.markdown += f"\n\n{separator}" + + # Create excerpt for post and inherit authors and categories - excerpts + # can contain a subset of the authors and categories of the post + page.excerpt = Excerpt(page, config, files) + page.excerpt.authors = page.authors[:max_authors] + page.excerpt.categories = page.categories[:max_categories] + + # Process posts + def on_page_content(self, html, *, page, config, files): + if not self.config.enabled: + return + + # Skip if page is not a post managed by this instance - this plugin has + # support for multiple instances, which is why this check is necessary + if page not in self.blog.posts: + return + + # Compute readtime of post, if enabled and not explicitly set + if self.config.post_readtime: + words_per_minute = self.config.post_readtime_words_per_minute + if not page.config.readtime: + page.config.readtime = readtime(html, words_per_minute) + + # Register template filters for plugin + def on_env(self, env, *, config, files): + if not self.config.enabled: + return + + # Filter for formatting dates related to posts + def date_filter(date: datetime): + return self._format_date_for_post(date, config) + + # Register custom template filters + env.filters["date"] = date_filter + env.filters["url"] = url_filter + + # Prepare view for rendering (run latest) - views are rendered last, as we + # need to mutate the navigation to account for pagination. The main problem + # is that we need to replace the view in the navigation, because otherwise + # the view would not be considered active. + @event_priority(-100) + def on_page_context(self, context, *, page, config, nav): + if not self.config.enabled: + return + + # Skip if page is not a view managed by this instance - this plugin has + # support for multiple instances, which is why this check is necessary + view = self._resolve_original(page) + if view not in self._resolve_views(self.blog): + return + + # If the current view is paginated, replace and rewire it - the current + # view temporarily becomes the main view, and is reset after rendering + assert isinstance(view, View) + if view != page: + prev = view.pages[view.pages.index(page) - 1] + + # Replace previous page with current page + items = self._resolve_siblings(view, nav) + items[items.index(prev)] = page + + # Render excerpts and prepare pagination + posts, pagination = self._render(page) + + # Render pagination links + def pager(args: object): + return pagination.pager( + format = self.config.pagination_format, + show_if_single_page = self.config.pagination_if_single_page, + **args + ) + + # Assign posts and pagination to context + context["posts"] = posts + context["pagination"] = pager if pagination else None + + # After rendering a paginated view, replace the URL of the paginated view + # with the URL of the original view - since we need to replace the original + # view with a paginated view in `on_page_context` for correct resolution of + # the active state, we must fix the paginated view URLs after rendering + def on_post_page(self, output, *, page, config): + if not self.config.enabled: + return + + # Skip if page is not a view managed by this instance - this plugin has + # support for multiple instances, which is why this check is necessary + view = self._resolve_original(page) + if view not in self._resolve_views(self.blog): + return + + # If the current view is paginated, replace the URL of the paginated + # view with the URL of the original view - see https://t.ly/Yeh-P + assert isinstance(view, View) + if view != page: + page.file.url = view.file.url + + # Remove temporary directory on shutdown + def on_shutdown(self): + rmtree(self.temp_dir) + + # ------------------------------------------------------------------------- + + # Check if the given post is excluded + def _is_excluded(self, post: Post): + if self.config.draft: + return False + + # If a post was not explicitly marked or unmarked as draft, and the + # date should be taken into account, we automatically mark it as draft + # if the publishing date is in the future. This, of course, is opt-in + # and must be explicitly enabled by the author. + if not isinstance(post.config.draft, bool): + if self.config.draft_if_future_date: + return post.config.date.created > datetime.now() + + # Post might be a draft + return bool(post.config.draft) + + # ------------------------------------------------------------------------- + + # Resolve entrypoint - the entrypoint of the blog must have been created + # if it did not exist before, and hosts all posts sorted by descending date + def _resolve(self, files: Files, config: MkDocsConfig): + path = os.path.join(self.config.blog_dir, "index.md") + path = os.path.normpath(path) + + # Create entrypoint, if it does not exist - note that the entrypoint is + # created in the docs directory, not in the temporary directory + docs = os.path.relpath(config.docs_dir) + name = os.path.join(docs, path) + if not os.path.isfile(name): + file = self._path_to_file(path, config, temp = False) + files.append(file) + + # Create file in docs directory + self._save_to_file(file.abs_src_path, "# Blog\n\n") + + # Create and return entrypoint + file = files.get_file_from_path(path) + return View(None, file, config) + + # Resolve post - the caller must make sure that the given file points to an + # actual post (and not a page), or behavior might be unpredictable + def _resolve_post(self, file: File, config: MkDocsConfig): + post = Post(file, config) + + # Compute path and create a temporary file for path resolution + path = self._format_path_for_post(post, config) + temp = self._path_to_file(path, config, temp = False) + + # Replace destination file system path and URL + file.dest_uri = temp.dest_uri + file.abs_dest_path = temp.abs_dest_path + file.url = temp.url + + # Replace canonical URL and return post + post._set_canonical_url(config.site_url) + return post + + # Resolve posts from directory - traverse all documentation pages and filter + # and yield those that are located in the posts directory + def _resolve_posts(self, files: Files, config: MkDocsConfig): + path = self.config.post_dir.format(blog = self.config.blog_dir) + path = os.path.normpath(path) + + # Create posts directory, if it does not exist + docs = os.path.relpath(config.docs_dir) + name = os.path.join(docs, path) + if not os.path.isdir(name): + os.makedirs(name, exist_ok = True) + + # Filter posts from pages + for file in files.documentation_pages(): + if not file.src_path.startswith(path): + continue + + # Temporarily remove post from navigation + file.inclusion = InclusionLevel.EXCLUDED + + # Resolve post - in order to determine whether a post should be + # excluded, we must load it and analyze its metadata. All posts + # marked as drafts are excluded, except for when the author has + # configured drafts to be included in the navigation. + post = self._resolve_post(file, config) + if not self._is_excluded(post): + yield post + + # Resolve authors - check if there's an authors file at the configured + # location, and if one was found, load and validate it + def _resolve_authors(self, config: MkDocsConfig): + path = self.config.authors_file.format(blog = self.config.blog_dir) + path = os.path.normpath(path) + + # Resolve path relative to docs directory + docs = os.path.relpath(config.docs_dir) + file = os.path.join(docs, path) + + # If the authors file does not exist, return here + config: Authors = Authors() + if not os.path.isfile(file): + return config.authors + + # Open file and parse as YAML + with open(file, encoding = "utf-8") as f: + config.config_file_path = os.path.abspath(file) + try: + config.load_dict(yaml.load(f, SafeLoader) or {}) + + # The authors file could not be loaded because of a syntax error, + # which we display to the author with a nice error message + except Exception as e: + raise PluginError( + f"Error reading authors file '{path}' in '{docs}':\n" + f"{e}" + ) + + # Validate authors and throw if errors occurred + errors, warnings = config.validate() + if not config.authors and warnings: + log.warning( + f"Action required: the format of the authors file changed.\n" + f"All authors must now be located under the 'authors' key.\n" + f"Please adjust '{file}' to match:\n" + f"\n" + f"authors:\n" + f" squidfunk:\n" + f" avatar: https://avatars.githubusercontent.com/u/932156\n" + f" description: Creator\n" + f" name: Martin Donath\n" + f"\n" + ) + for _, w in warnings: + log.warning(w) + for _, e in errors: + raise PluginError( + f"Error reading authors file '{path}' in '{docs}':\n" + f"{e}" + ) + + # Return authors + return config.authors + + # Resolve views of the given view in pre-order + def _resolve_views(self, view: View): + yield view + + # Resolve views recursively + for page in view.views: + for next in self._resolve_views(page): + assert isinstance(next, View) + yield next + + # Resolve siblings of a navigation item + def _resolve_siblings(self, item: StructureItem, nav: Navigation): + if isinstance(item.parent, Section): + return item.parent.children + else: + return nav.items + + # Resolve original page or view (e.g. for paginated views) + def _resolve_original(self, page: Page): + if isinstance(page, View): + return page.pages[0] + else: + return page + + # ------------------------------------------------------------------------- + + # Generate views for archive - analyze posts and generate the necessary + # views, taking the date format provided by the author into account + def _generate_archive(self, config: MkDocsConfig, files: Files): + for post in self.blog.posts: + date = post.config.date.created + + # Compute name and path of archive view + name = self._format_date_for_archive(date, config) + path = self._format_path_for_archive(post, config) + + # Create file for view, if it does not exist + file = files.get_file_from_path(path) + if not file 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 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) + file.page.posts.append(post) + + # Generate views for categories - analyze posts and generate the necessary + # views, taking the allowed categories as set by the author into account + def _generate_categories(self, config: MkDocsConfig, files: Files): + for post in self.blog.posts: + for name in post.config.categories: + path = self._format_path_for_category(name) + + # Ensure category is in non-empty allow list + categories = self.config.categories_allowed or [name] + if name not in categories: + docs = os.path.relpath(config.docs_dir) + path = os.path.relpath(post.file.abs_src_path, docs) + raise PluginError( + f"Error reading categories of post '{path}' in " + f"'{docs}': category '{name}' not in allow list" + ) + + # Create file for view, if it does not exist + file = files.get_file_from_path(path) + if not file 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 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) + file.page.posts.append(post) + post.categories.append(file.page) + + # Generate pages for pagination - analyze view and generate the necessary + # pages, creating a chain of views for simple rendering and replacement + def _generate_pages(self, view: View, config: MkDocsConfig, files: Files): + yield view + + # Compute pagination boundaries and create pages - pages are internally + # handled as copies of a view, as they map to the same source location + step = self.config.pagination_per_page + for at in range(step, len(view.posts), step): + path = self._format_path_for_pagination(view, 1 + at // step) + + # Create file for view, if it does not exist + file = files.get_file_from_path(path) + if not file or self.temp_dir not in file.abs_src_path: + file = self._path_to_file(path, config) + files.append(file) + + # Copy file to temporary directory + copy_file(view.file.abs_src_path, file.abs_src_path) + + # Create view and attach to previous page + if not isinstance(file.page, View): + yield View(None, file, config) + + # Assign pages and posts to view + assert isinstance(file.page, View) + file.page.pages = view.pages + file.page.posts = view.posts + + # ------------------------------------------------------------------------- + + # Attach a list of pages to each other and to the given parent item without + # explicitly adding them to the navigation, which can be done by the caller + def _attach(self, parent: StructureItem, pages: list[Page]): + for tail, page, head in zip(pages, pages[1:], pages[2:]): + + # Link page to parent and siblings + page.parent = parent + page.previous_page = tail + page.next_page = head + + # If the page is a view, we know that we generated it and need to + # link its siblings back to the view + if isinstance(page, View): + view = self._resolve_original(page) + if tail: tail.next_page = view + if head: head.previous_page = view + + # Attach a page to the given parent and link it to the previous and next + # page of the given host - this is exclusively used for paginated views + def _attach_at(self, parent: StructureItem, host: Page, page: Page): + self._attach(parent, [host.previous_page, page, host.next_page]) + + # Attach a section as a sibling to the given view, make sure its pages are + # part of the navigation, and ensure all pages are linked correctly + def _attach_to(self, view: View, section: Section, nav: Navigation): + section.parent = view.parent + + # Resolve siblings, which are the children of the parent section, or + # the top-level list of navigation items if the view is at the root of + # the project, and append the given section to it. It's currently not + # possible to chose the position of a section. + items = self._resolve_siblings(view, nav) + items.append(section) + + # Find last sibling that is a page, skipping sections, as we need to + # append the given section after all other pages + tail = next(item for item in reversed(items) if isinstance(item, Page)) + head = tail.next_page + + # Attach section to navigation and pages to each other + nav.pages.extend(section.children) + self._attach(section, [tail, *section.children, head]) + + # ------------------------------------------------------------------------- + + # Render excerpts and pagination for the given view + def _render(self, view: View): + posts, pagination = view.posts, None + + # Create pagination, if enabled + if self.config.pagination: + at = view.pages.index(view) + + # Compute pagination boundaries + step = self.config.pagination_per_page + p, q = at * step, at * step + step + + # Extract posts in pagination boundaries + posts = view.posts[p:q] + pagination = self._render_pagination(view, (p, q)) + + # Render excerpts for selected posts + posts = [ + self._render_post(post.excerpt, view) + for post in posts if post.excerpt + ] + + # Return posts and pagination + return posts, pagination + + # Render excerpt in the context of the given view + def _render_post(self, excerpt: Excerpt, view: View): + excerpt.render(view, self.config.post_excerpt_separator) + + # Determine whether to add posts to the table of contents of the view - + # note that those settings can be changed individually for each type of + # view, which is why we need to check the type of view and the table of + # contents setting for that type of view + toc = self.config.blog_toc + if isinstance(view, Archive): + toc = self.config.archive_toc + if isinstance(view, Category): + toc = self.config.categories_toc + + # Attach top-level table of contents item to view if it should be added + # and both, the view and excerpt contain table of contents items + if toc and excerpt.toc.items and view.toc.items: + view.toc.items[0].children.append(excerpt.toc.items[0]) + + # Return excerpt + return excerpt + + # Create pagination for the given view and range + def _render_pagination(self, view: View, range: tuple[int, int]): + p, q = range + + # Create URL from the given page to another page + def url_maker(n: int): + return get_relative_url(view.pages[n - 1].url, view.url) + + # Return pagination + return Pagination( + view.posts, page = q // (q - p), + items_per_page = q - p, + url_maker = url_maker + ) + + # ------------------------------------------------------------------------- + + # Format path for post + def _format_path_for_post(self, post: Post, config: MkDocsConfig): + categories = post.config.categories[:self.config.post_url_max_categories] + categories = [self._slugify_category(name) for name in categories] + + # Replace placeholders in format string + date = post.config.date.created + path = self.config.post_url_format.format( + categories = "/".join(categories), + date = self._format_date_for_post_url(date, config), + file = post.file.name, + slug = post.config.slug or self._slugify_post(post) + ) + + # Normalize path and strip slashes at the beginning and end + path = posixpath.normpath(path.strip("/")) + return posixpath.join(self.config.blog_dir, f"{path}.md") + + # Format path for archive + def _format_path_for_archive(self, post: Post, config: MkDocsConfig): + date = post.config.date.created + path = self.config.archive_url_format.format( + date = self._format_date_for_archive_url(date, config) + ) + + # Normalize path and strip slashes at the beginning and end + path = posixpath.normpath(path.strip("/")) + return posixpath.join(self.config.blog_dir, f"{path}.md") + + # Format path for category + def _format_path_for_category(self, name: str): + path = self.config.categories_url_format.format( + slug = self._slugify_category(name) + ) + + # Normalize path and strip slashes at the beginning and end + path = posixpath.normpath(path.strip("/")) + return posixpath.join(self.config.blog_dir, f"{path}.md") + + # Format path for pagination + def _format_path_for_pagination(self, view: View, page: int): + path = self.config.pagination_url_format.format( + page = page + ) + + # Compute base path for pagination - if the given view is an index file, + # we need to pop the file name from the base so it's not part of the URL + # and we need to append `index` to the path, so the paginated view is + # also an index page - see https://t.ly/71MKF + base, _ = posixpath.splitext(view.file.src_uri) + if view.is_index: + base = posixpath.dirname(base) + path = posixpath.join(path, "index") + + # Normalize path and strip slashes at the beginning and end + path = posixpath.normpath(path.strip("/")) + return posixpath.join(base, f"{path}.md") + + # ------------------------------------------------------------------------- + + # Format date + def _format_date(self, date: datetime, format: str, config: MkDocsConfig): + locale = config.theme["language"] + return format_date(date, format = format, locale = locale) + + # Format date for post + def _format_date_for_post(self, date: datetime, config: MkDocsConfig): + format = self.config.post_date_format + return self._format_date(date, format, config) + + # Format date for post URL + def _format_date_for_post_url(self, date: datetime, config: MkDocsConfig): + format = self.config.post_url_date_format + return self._format_date(date, format, config) + + # Format date for archive + def _format_date_for_archive(self, date: datetime, config: MkDocsConfig): + format = self.config.archive_date_format + return self._format_date(date, format, config) + + # Format date for archive URL + def _format_date_for_archive_url(self, date: datetime, config: MkDocsConfig): + format = self.config.archive_url_date_format + return self._format_date(date, format, config) + + # ------------------------------------------------------------------------- + + # Slugify post title + def _slugify_post(self, post: Post): + separator = self.config.post_slugify_separator + return self.config.post_slugify(post.title, separator) + + # Slugify category + def _slugify_category(self, name: str): + separator = self.config.categories_slugify_separator + return self.config.categories_slugify(name, separator) + + # ------------------------------------------------------------------------- + + # Create a file for the given path, which must point to a valid source file, + # either inside the temporary directory or the docs directory + def _path_to_file(self, path: str, config: MkDocsConfig, *, temp = True): + assert path.endswith(".md") + file = File( + path, + config.docs_dir if not temp else self.temp_dir, + config.site_dir, + config.use_directory_urls + ) + + # Hack: mark file as generated, so other plugins don't think it's part + # of the file system. This is more or less a new quasi-standard that + # still needs to be adopted by MkDocs, and was introduced by the + # git-revision-date-localized-plugin - see https://bit.ly/3ZUmdBx + if temp: + file.generated_by = "material/blog" + + # Return file + return file + + # Create a file with the given content on disk + def _save_to_file(self, path: str, content: str): + os.makedirs(os.path.dirname(path), exist_ok = True) + with open(path, "w", encoding = "utf-8") as f: + f.write(content) + + # ------------------------------------------------------------------------- + + # Translate the placeholder referenced by the given key + def _translate(self, key: str, config: MkDocsConfig) -> str: + env = config.theme.get_env() + template = env.get_template( + "partials/language.html", globals = { "config": config } + ) + + # Translate placeholder + return template.module.t(key) + +# ----------------------------------------------------------------------------- +# Data +# ----------------------------------------------------------------------------- + +# Set up logging +log = logging.getLogger("mkdocs.material.blog") |
