Skip to content

Commit

Permalink
chore: pull more stuff in from mkdocs
Browse files Browse the repository at this point in the history
  • Loading branch information
phil65 committed Oct 25, 2024
1 parent 2fd4df1 commit 681c5ea
Showing 1 changed file with 195 additions and 10 deletions.
205 changes: 195 additions & 10 deletions mkdocs_mknodes/commands/build_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = (
'<div class="mkdocs-draft-marker" title="This page wont be included into the site.">'
"DRAFT"
"</div>"
)


def build(
config_path: str | os.PathLike[str],
repo_path: str,
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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())
Expand Down

0 comments on commit 681c5ea

Please sign in to comment.