This commit is contained in:
CreaRobotics committed 2026-03-02 11:34:40 +01:00
1 parent 6ccab0347b
commit 954a2fd6d6
18684 files changed
+601012 -190

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,369 @@
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 = (
'<div class="mkdocs-draft-marker" title="This page will not be included into the built site.">'
'DRAFT'
'</div>' + (page.content or '')
)
# Render the template.
output = template.render(context)
# Run `post_page` plugin events.
output = config.plugins.on_post_page(output, page=page, config=config)
# Write the output file.
if output.strip():
utils.write_file(
output.encode('utf-8', errors='xmlcharrefreplace'), page.file.abs_dest_path
)
else:
log.info(f"Page skipped: '{page.file.src_uri}'. Generated empty output.")
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, BuildError):
message += f" {e}"
log.error(message)
raise
finally:
# Deactivate page
page.active = False
config._current_page = None
def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = False) -> None:
"""Perform a full site build."""
logger = logging.getLogger('mkdocs')
# Add CountHandler for strict mode
warning_counter = utils.CountHandler()
warning_counter.setLevel(logging.WARNING)
if config.strict:
logging.getLogger('mkdocs').addHandler(warning_counter)
inclusion = InclusionLevel.is_in_serve if serve_url else InclusionLevel.is_included
try:
start = time.monotonic()
# Run `config` plugin events.
config = config.plugins.on_config(config)
# Run `pre_build` plugin events.
config.plugins.on_pre_build(config=config)
if not dirty:
log.info("Cleaning site directory")
utils.clean_directory(config.site_dir)
else: # pragma: no cover
# Warn user about problems that may occur with --dirty option
log.warning(
"A 'dirty' build is being performed, this will likely lead to inaccurate navigation and other"
" links within your site. This option is designed for site development purposes only."
)
if not serve_url: # pragma: no cover
log.info(f"Building documentation to directory: {config.site_dir}")
if dirty and site_directory_contains_stale_files(config.site_dir):
log.info("The directory contains stale files. Use --clean to remove them.")
# First gather all data from all files/pages to ensure all data is consistent across all pages.
files = get_files(config)
env = config.theme.get_env()
files.add_files_from_theme(env, config)
# Run `files` plugin events.
files = config.plugins.on_files(files, config=config)
# If plugins have added files but haven't set their inclusion level, calculate it again.
set_exclusions(files, config)
nav = get_navigation(files, config)
# Run `nav` plugin events.
nav = config.plugins.on_nav(nav, config=config, files=files)
log.debug("Reading markdown pages.")
excluded = []
for file in files.documentation_pages(inclusion=inclusion):
log.debug(f"Reading: {file.src_uri}")
if file.page is None and file.inclusion.is_not_in_nav():
if serve_url and file.inclusion.is_excluded():
excluded.append(urljoin(serve_url, file.url))
Page(None, file, config)
assert file.page is not None
_populate_page(file.page, config, files, dirty)
if excluded:
log.info(
"The following pages are being built only for the preview "
"but will be excluded from `mkdocs build` per `draft_docs` config:\n - "
+ "\n - ".join(excluded)
)
# Run `env` plugin events.
env = config.plugins.on_env(env, config=config, files=files)
# Start writing files to site_dir now that all data is gathered. Note that order matters. Files
# with lower precedence get written first so that files with higher precedence can overwrite them.
log.debug("Copying static assets.")
files.copy_static_files(dirty=dirty, inclusion=inclusion)
for template in config.theme.static_templates:
_build_theme_template(template, env, files, config, nav)
for template in config.extra_templates:
_build_extra_template(template, files, config, nav)
log.debug("Building markdown pages.")
doc_files = files.documentation_pages(inclusion=inclusion)
for file in doc_files:
assert file.page is not None
_build_page(
file.page, config, doc_files, nav, env, dirty, excluded=file.inclusion.is_excluded()
)
log_level = config.validation.links.anchors
for file in doc_files:
assert file.page is not None
file.page.validate_anchor_links(files=files, log_level=log_level)
# Run `post_build` plugin events.
config.plugins.on_post_build(config=config)
if counts := warning_counter.get_counts():
msg = ', '.join(f'{v} {k.lower()}s' for k, v in counts)
raise Abort(f'Aborted with {msg} in strict mode!')
log.info(f'Documentation built in {time.monotonic() - start:.2f} seconds')
except Exception as e:
# Run `build_error` plugin events.
config.plugins.on_build_error(error=e)
if isinstance(e, BuildError):
log.error(str(e))
raise Abort('Aborted with a BuildError!')
raise
finally:
logger.removeHandler(warning_counter)
def site_directory_contains_stale_files(site_directory: str) -> bool:
"""Check if the site directory contains stale files from a previous build."""
return bool(os.path.exists(site_directory) and os.listdir(site_directory))
@@ -0,0 +1,169 @@
from __future__ import annotations
import logging
import os
import re
import subprocess
from typing import TYPE_CHECKING
import ghp_import # type: ignore
from packaging import version
import mkdocs
from mkdocs.exceptions import Abort
if TYPE_CHECKING:
from mkdocs.config.defaults import MkDocsConfig
log = logging.getLogger(__name__)
default_message = """Deployed {sha} with MkDocs version: {version}"""
def _is_cwd_git_repo() -> bool:
try:
proc = subprocess.Popen(
['git', 'rev-parse', '--is-inside-work-tree'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except FileNotFoundError:
log.error("Could not find git - is it installed and on your path?")
raise Abort('Deployment Aborted!')
proc.communicate()
return proc.wait() == 0
def _get_current_sha(repo_path) -> str:
proc = subprocess.Popen(
['git', 'rev-parse', '--short', 'HEAD'],
cwd=repo_path or None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = proc.communicate()
sha = stdout.decode('utf-8').strip()
return sha
def _get_remote_url(remote_name: str) -> tuple[str, str] | tuple[None, None]:
# No CNAME found. We will use the origin URL to determine the GitHub
# Pages location.
remote = f"remote.{remote_name}.url"
proc = subprocess.Popen(
["git", "config", "--get", remote],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = proc.communicate()
url = stdout.decode('utf-8').strip()
if 'github.com/' in url:
host, path = url.split('github.com/', 1)
elif 'github.com:' in url:
host, path = url.split('github.com:', 1)
else:
return None, None
return host, path
def _check_version(branch: str) -> None:
proc = subprocess.Popen(
['git', 'show', '-s', '--format=%s', f'refs/heads/{branch}'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = proc.communicate()
msg = stdout.decode('utf-8').strip()
m = re.search(r'\d+(\.\d+)+((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?', msg, re.X | re.I)
previousv = version.parse(m.group()) if m else None
currentv = version.parse(mkdocs.__version__)
if not previousv:
log.warning('Version check skipped: No version specified in previous deployment.')
elif currentv > previousv:
log.info(
f'Previous deployment was done with MkDocs version {previousv}; '
f'you are deploying with a newer version ({currentv})'
)
elif currentv < previousv:
log.error(
f'Deployment terminated: Previous deployment was made with MkDocs version {previousv}; '
f'you are attempting to deploy with an older version ({currentv}). Use --ignore-version '
'to deploy anyway.'
)
raise Abort('Deployment Aborted!')
def gh_deploy(
config: MkDocsConfig,
message: str | None = None,
force=False,
no_history=False,
ignore_version=False,
shell=False,
) -> None:
if not _is_cwd_git_repo():
log.error('Cannot deploy - this directory does not appear to be a git repository')
remote_branch = config.remote_branch
remote_name = config.remote_name
if not ignore_version:
_check_version(remote_branch)
if message is None:
message = default_message
sha = _get_current_sha(os.path.dirname(config.config_file_path))
message = message.format(version=mkdocs.__version__, sha=sha)
log.info(
"Copying '%s' to '%s' branch and pushing to GitHub.",
config.site_dir,
config.remote_branch,
)
try:
ghp_import.ghp_import(
config.site_dir,
mesg=message,
remote=remote_name,
branch=remote_branch,
push=True,
force=force,
use_shell=shell,
no_history=no_history,
nojekyll=True,
)
except ghp_import.GhpError as e:
log.error(f"Failed to deploy to GitHub with error: \n{e.message}")
raise Abort('Deployment Aborted!')
cname_file = os.path.join(config.site_dir, 'CNAME')
# Does this repository have a CNAME set for GitHub Pages?
if os.path.isfile(cname_file):
# This GitHub Pages repository has a CNAME configured.
with open(cname_file) as f:
cname_host = f.read().strip()
log.info(
f'Based on your CNAME file, your documentation should be '
f'available shortly at: http://{cname_host}'
)
log.info(
'NOTE: Your DNS records must be configured appropriately for your CNAME URL to work.'
)
return
host, path = _get_remote_url(remote_name)
if host is None or path is None:
# This could be a GitHub Enterprise deployment.
log.info('Your documentation should be available shortly.')
else:
username, repo = path.split('/', 1)
if repo.endswith('.git'):
repo = repo[: -len('.git')]
url = f'https://{username}.github.io/{repo}/'
log.info(f"Your documentation should shortly be available at: {url}")
@@ -0,0 +1,53 @@
from __future__ import annotations
import logging
import os
config_text = 'site_name: My Docs\n'
index_text = """# Welcome to MkDocs
For full documentation visit [mkdocs.org](https://www.mkdocs.org).
## Commands
* `mkdocs new [dir-name]` - Create a new project.
* `mkdocs serve` - Start the live-reloading docs server.
* `mkdocs build` - Build the documentation site.
* `mkdocs -h` - Print help message and exit.
## Project layout
mkdocs.yml # The configuration file.
docs/
index.md # The documentation homepage.
... # Other markdown pages, images and other files.
"""
log = logging.getLogger(__name__)
def new(output_dir: str) -> None:
docs_dir = os.path.join(output_dir, 'docs')
config_path = os.path.join(output_dir, 'mkdocs.yml')
index_path = os.path.join(docs_dir, 'index.md')
if os.path.exists(config_path):
log.info('Project already exists.')
return
if not os.path.exists(output_dir):
log.info(f'Creating project directory: {output_dir}')
os.mkdir(output_dir)
log.info(f'Writing config file: {config_path}')
with open(config_path, 'w', encoding='utf-8') as f:
f.write(config_text)
if os.path.exists(index_path):
return
log.info(f'Writing initial docs: {index_path}')
if not os.path.exists(docs_dir):
os.mkdir(docs_dir)
with open(index_path, 'w', encoding='utf-8') as f:
f.write(index_text)
@@ -0,0 +1,112 @@
from __future__ import annotations
import logging
import shutil
import tempfile
from os.path import isdir, isfile, join
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from mkdocs.commands.build import build
from mkdocs.config import load_config
from mkdocs.livereload import LiveReloadServer, _serve_url
if TYPE_CHECKING:
from mkdocs.config.defaults import MkDocsConfig
log = logging.getLogger(__name__)
def serve(
config_file: str | None = None,
livereload: bool = True,
build_type: str | None = None,
watch_theme: bool = False,
watch: list[str] = [],
*,
open_in_browser: bool = False,
**kwargs,
) -> None:
"""
Start the MkDocs development server.
By default it will serve the documentation on http://localhost:8000/ and
it will rebuild the documentation and refresh the page automatically
whenever a file is edited.
"""
# Create a temporary build directory, and set some options to serve it
# PY2 returns a byte string by default. The Unicode prefix ensures a Unicode
# string is returned. And it makes MkDocs temp dirs easier to identify.
site_dir = tempfile.mkdtemp(prefix='mkdocs_')
def get_config():
config = load_config(
config_file=config_file,
site_dir=site_dir,
**kwargs,
)
config.watch.extend(watch)
return config
is_clean = build_type == 'clean'
is_dirty = build_type == 'dirty'
config = get_config()
config.plugins.on_startup(command=('build' if is_clean else 'serve'), dirty=is_dirty)
host, port = config.dev_addr
mount_path = urlsplit(config.site_url or '/').path
config.site_url = serve_url = _serve_url(host, port, mount_path)
def builder(config: MkDocsConfig | None = None):
log.info("Building documentation...")
if config is None:
config = get_config()
config.site_url = serve_url
build(config, serve_url=None if is_clean else serve_url, dirty=is_dirty)
server = LiveReloadServer(
builder=builder, host=host, port=port, root=site_dir, mount_path=mount_path
)
def error_handler(code) -> bytes | None:
if code in (404, 500):
error_page = join(site_dir, f'{code}.html')
if isfile(error_page):
with open(error_page, 'rb') as f:
return f.read()
return None
server.error_handler = error_handler
try:
# Perform the initial build
builder(config)
if livereload:
# Watch the documentation files, the config file and the theme files.
server.watch(config.docs_dir)
if config.config_file_path:
server.watch(config.config_file_path)
if watch_theme:
for d in config.theme.dirs:
server.watch(d)
# Run `serve` plugin events.
server = config.plugins.on_serve(server, config=config, builder=builder)
for item in config.watch:
server.watch(item)
try:
server.serve(open_in_browser=open_in_browser)
except KeyboardInterrupt:
log.info("Shutting down...")
finally:
server.shutdown()
finally:
config.plugins.on_shutdown()
if isdir(site_dir):
shutil.rmtree(site_dir)