from __future__ import annotations import gzip import logging import os import time from typing import TYPE_CHECKING, Sequence from urllib.parse import urljoin, urlsplit import jinja2 from jinja2.exceptions import TemplateNotFound import mkdocs from mkdocs import utils from mkdocs.exceptions import Abort, BuildError from mkdocs.structure.files import File, Files, InclusionLevel, get_files, set_exclusions from mkdocs.structure.nav import Navigation, get_navigation from mkdocs.structure.pages import Page from mkdocs.utils import DuplicateFilter # noqa: F401 - legacy re-export from mkdocs.utils import templates if TYPE_CHECKING: from mkdocs.config.defaults import MkDocsConfig log = logging.getLogger(__name__) def get_context( nav: Navigation, files: Sequence[File] | Files, config: MkDocsConfig, page: Page | None = None, base_url: str = '', ) -> templates.TemplateContext: """Return the template context for a given page or template.""" if page is not None: base_url = utils.get_relative_url('.', page.url) extra_javascript = [ utils.normalize_url(str(script), page, base_url) for script in config.extra_javascript ] extra_css = [utils.normalize_url(path, page, base_url) for path in config.extra_css] if isinstance(files, Files): files = files.documentation_pages() return templates.TemplateContext( nav=nav, pages=files, base_url=base_url, extra_css=extra_css, extra_javascript=extra_javascript, mkdocs_version=mkdocs.__version__, build_date_utc=utils.get_build_datetime(), config=config, page=page, ) 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 utils.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 = utils.get_relative_url('.', name) context = get_context(nav, files, config, base_url=base_url) # Run `template_context` plugin events. context = config.plugins.on_template_context(context, template_name=name, config=config) output = template.render(context) # Run `post_template` plugin events. output = config.plugins.on_post_template(output, template_name=name, config=config) return output def _build_theme_template( template_name: str, env: jinja2.Environment, files: Files, config: MkDocsConfig, nav: Navigation ) -> None: """Build a template using the theme environment.""" log.debug(f"Building theme template: {template_name}") try: template = env.get_template(template_name) except TemplateNotFound: log.warning(f"Template skipped: '{template_name}' not found in theme directories.") return output = _build_template(template_name, template, files, config, nav) if output.strip(): output_path = os.path.join(config.site_dir, template_name) utils.write_file(output.encode('utf-8'), output_path) if template_name == 'sitemap.xml': log.debug(f"Gzipping template: {template_name}") gz_filename = f'{output_path}.gz' with open(gz_filename, 'wb') as f: timestamp = utils.get_build_timestamp( pages=[f.page for f in files.documentation_pages() if f.page is not None] ) with gzip.GzipFile( fileobj=f, filename=gz_filename, mode='wb', mtime=timestamp ) as gz_buf: gz_buf.write(output.encode('utf-8')) else: log.info(f"Template skipped: '{template_name}' generated empty output.") def _build_extra_template(template_name: str, files: Files, config: MkDocsConfig, nav: Navigation): """Build user templates which are not part of the theme.""" log.debug(f"Building extra template: {template_name}") file = files.get_file_from_path(template_name) if file is None: log.warning(f"Template skipped: '{template_name}' not found in docs_dir.") return try: template = jinja2.Template(file.content_string) except Exception as e: log.warning(f"Error reading template '{template_name}': {e}") return output = _build_template(template_name, template, files, config, nav) if output.strip(): utils.write_file(output.encode('utf-8'), file.abs_dest_path) else: log.info(f"Template skipped: '{template_name}' generated empty output.") def _populate_page(page: Page, config: MkDocsConfig, files: Files, dirty: bool = False) -> None: """Read page content from docs_dir and render Markdown.""" config._current_page = page try: # When --dirty is used, only read the page if the file has been modified since the # previous build of the output. if dirty and not page.file.is_modified(): return # Run the `pre_page` plugin event page = config.plugins.on_pre_page(page, config=config, files=files) page.read_source(config) assert page.markdown is not None # Run `page_markdown` plugin events. page.markdown = config.plugins.on_page_markdown( page.markdown, page=page, config=config, files=files ) page.render(config, files) assert page.content is not None # Run `page_content` plugin events. page.content = config.plugins.on_page_content( page.content, page=page, config=config, files=files ) except Exception as e: message = f"Error reading page '{page.file.src_uri}':" # Prevent duplicated the error message because it will be printed immediately afterwards. if not isinstance(e, BuildError): message += f" {e}" log.error(message) raise finally: 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: # When --dirty is used, only build the page if the file has been modified since the # previous build of the output. if dirty and not page.file.is_modified(): return log.debug(f"Building page {page.file.src_uri}") # Activate page. Signals to theme that this is the current page. page.active = True context = 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. context = config.plugins.on_page_context(context, page=page, config=config, nav=nav) if excluded: page.content = ( '