diff --git a/mkdocs_mknodes/commands/build_page.py b/mkdocs_mknodes/commands/build_page.py index 8e1a1f0..c572237 100644 --- a/mkdocs_mknodes/commands/build_page.py +++ b/mkdocs_mknodes/commands/build_page.py @@ -2,18 +2,25 @@ from __future__ import annotations +from collections.abc import Collection, Sequence +from datetime import UTC, datetime +import gzip import io import os import pathlib +import re from typing import TYPE_CHECKING, Any -from urllib.parse import urljoin +from urllib.parse import urljoin, urlsplit +import jinja2 +from jinja2.exceptions import TemplateNotFound +from jinjarope import htmlfilters import logfire from mkdocs import exceptions from mkdocs.commands import build as mkdocs_build from mkdocs.config import load_config from mkdocs.structure.files import InclusionLevel -from mkdocs.structure.nav import get_navigation +from mkdocs.structure.nav import Navigation, get_navigation from mkdocs.structure.pages import Page from mknodes.info import mkdocsconfigfile from mknodes.utils import pathhelpers, yamlhelpers @@ -24,12 +31,19 @@ if TYPE_CHECKING: from mkdocs.config.defaults import MkDocsConfig - from mkdocs.structure.files import Files + from mkdocs.structure.files import File, Files logger = telemetry.get_plugin_logger(__name__) +DRAFT_CONTENT = ( + '
' + "DRAFT" + "
" +) + + def build( config_path: str | os.PathLike[str], repo_path: str, @@ -128,9 +142,9 @@ def _build( with logfire.span(f"populate page for {file.src_uri}", file=file): logger.debug("Reading: %s", file.src_uri) if file.page is None and file.inclusion.is_not_in_nav(): + Page(None, file, config) if live_server_url and file.inclusion.is_excluded(): excluded.append(urljoin(live_server_url, file.url)) - Page(None, file, config) assert file.page is not None _populate_page(file.page, config, files, dirty) if excluded: @@ -149,9 +163,9 @@ def _build( with logfire.span("build_templates"): for template in config.theme.static_templates: - mkdocs_build._build_theme_template(template, env, files, config, nav) + _build_theme_template(template, env, files, config, nav) for template in config.extra_templates: - mkdocs_build._build_extra_template(template, files, config, nav) + _build_extra_template(template, files, config, nav) logger.debug("Building markdown pages.") doc_files = files.documentation_pages(inclusion=inclusion) @@ -160,10 +174,7 @@ def _build( assert file.page excl = file.inclusion.is_excluded() with logfire.span(f"build_page {file.page.url}", page=file.page): - mkdocs_build._build_page( - file.page, config, doc_files, nav, env, dirty, excl - ) - + _build_page(file.page, config, doc_files, nav, env, dirty, excl) log_level = config.validation.links.anchors with logfire.span("validate_anchor_links"): for file in doc_files: @@ -217,6 +228,58 @@ def _populate_page( config._current_page = None +def _build_page( + page: Page, + config: MkDocsConfig, + doc_files: Sequence[File], + nav: Navigation, + env: jinja2.Environment, + dirty: bool = False, + excluded: bool = False, +) -> None: + """Pass a Page to theme template and write output to site_dir.""" + config._current_page = page + try: + # only build pages if the file has been modified since the previous build + if dirty and not page.file.is_modified(): + return + logger.debug("Building page %s", page.file.src_uri) + # Activate page. Signals to theme that this is the current page. + page.active = True + ctx = mkdocs_build.get_context(nav, doc_files, config, page) + # Allow 'template:' override in md source files. + template = env.get_template(page.meta.get("template", "main.html")) + # Run `page_context` plugin events. + ctx = config.plugins.on_page_context(ctx, page=page, config=config, nav=nav) + + if excluded: + page.content = DRAFT_CONTENT + (page.content or "") + # Render the template. + output = template.render(ctx) + # Run `post_page` plugin events. + output = config.plugins.on_post_page(output, page=page, config=config) + + # Write the output file. + if output.strip(): + text = output.encode("utf-8", errors="xmlcharrefreplace") + pathhelpers.write_file(text, page.file.abs_dest_path) + else: + logger.info("Page skipped: '%s'. Generated empty output.", page.file.src_uri) + + except Exception as e: + message = f"Error building page '{page.file.src_uri}':" + # Prevent duplicated the error message because + # it will be printed immediately afterwards. + if not isinstance(e, exceptions.BuildError): + message += f" {e}" + logger.error(message) # noqa: TRY400 + raise + finally: + # Deactivate page + page.active = False + config._current_page = None + + def contains_files(folder: str | os.PathLike[str]) -> bool: """Check if given path exists and contains any files or folders. @@ -227,6 +290,128 @@ def contains_files(folder: str | os.PathLike[str]) -> bool: return path.exists() and any(path.iterdir()) +def _build_template( + name: str, + template: jinja2.Template, + files: Files, + config: MkDocsConfig, + nav: Navigation, +) -> str: + """Return rendered output for given template as a string.""" + # Run `pre_template` plugin events. + template = config.plugins.on_pre_template(template, template_name=name, config=config) + + if is_error_template(name): + # Force absolute URLs in the nav of error pages and account for the + # possibility that the docs root might be different than the server root. + # See https://github.com/mkdocs/mkdocs/issues/77. + # However, if site_url is not set, assume the docs root and server root + # are the same. See https://github.com/mkdocs/mkdocs/issues/1598. + base_url = urlsplit(config.site_url or "/").path + else: + base_url = htmlfilters.relative_url_mkdocs(".", name) + context = mkdocs_build.get_context(nav, files, config, base_url=base_url) + # Run `template_context` plugin events. + ctx = config.plugins.on_template_context(context, template_name=name, config=config) + output = template.render(ctx) + # Run `post_template` plugin events. + return config.plugins.on_post_template(output, template_name=name, config=config) + + +def _build_theme_template( + template_name: str, + env: jinja2.Environment, + files: Files, + config: MkDocsConfig, + nav: Navigation, +) -> None: + """Build a template using the theme environment.""" + logger.debug("Building theme template: %s", template_name) + + try: + template = env.get_template(template_name) + except TemplateNotFound: + logger.warning("Template skipped: '%s' not found in theme dirs.", template_name) + return + + output = _build_template(template_name, template, files, config, nav) + + if output.strip(): + output_path = pathlib.Path(config.site_dir) / template_name + pathhelpers.write_file(output.encode(), output_path) + + if template_name == "sitemap.xml": + logger.debug("Gzipping template: %s", template_name) + gz_filename = pathlib.Path(f"{output_path}.gz") + docs = files.documentation_pages() + ts = get_build_timestamp(pages=[f.page for f in docs if f.page is not None]) + with ( + gz_filename.open("wb") as f, + gzip.GzipFile(gz_filename, fileobj=f, mode="wb", mtime=ts) as gz_buf, + ): + gz_buf.write(output.encode()) + else: + logger.info("Template skipped: '%s' generated empty output.", template_name) + + +def _build_extra_template( + template_name: str, files: Files, config: MkDocsConfig, nav: Navigation +): + """Build user templates which are not part of the theme.""" + logger.debug("Building extra template: %s", template_name) + + file = files.get_file_from_path(template_name) + if file is None: + logger.warning("Template skipped: '%s' not found in docs_dir.", template_name) + return + try: + template = jinja2.Template(file.content_string) + except Exception as e: # noqa: BLE001 + logger.warning("Error reading template '%s': %s", template_name, e) + return + output = _build_template(template_name, template, files, config, nav) + if output.strip(): + pathhelpers.write_file(output.encode(), file.abs_dest_path) + else: + logger.info("Template skipped: '%s' generated empty output.", template_name) + + +def get_build_timestamp(*, pages: Collection[Page] | None = None) -> int: + """Returns the number of seconds since the epoch for the latest updated page. + + In reality this is just today's date because that's how pages' update time + is populated. + """ + if pages: + # Lexicographic comparison is OK for ISO date. + date_string = max(p.update_date for p in pages) + dt = datetime.fromisoformat(date_string).replace(tzinfo=UTC) + else: + dt = get_build_datetime() + return int(dt.timestamp()) + + +def get_build_datetime() -> datetime: + """Returns an aware datetime object. + + Support SOURCE_DATE_EPOCH environment variable for reproducible builds. + See https://reproducible-builds.org/specs/source-date-epoch/ + """ + source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH") + if source_date_epoch is None: + return datetime.now(UTC) + + return datetime.fromtimestamp(int(source_date_epoch), UTC) + + +_ERROR_TEMPLATE_RE = re.compile(r"^\d{3}\.html?$") + + +def is_error_template(path: str) -> bool: + """Return True if the given file path is an HTTP error template.""" + return bool(_ERROR_TEMPLATE_RE.match(path)) + + if __name__ == "__main__": config = mkdocsconfigfile.MkDocsConfigFile("mkdocs.yml") print(config.dump_config())