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

@@ -0,0 +1,36 @@
from __future__ import annotations
import abc
from typing import TYPE_CHECKING, Iterable
if TYPE_CHECKING:
from mkdocs.structure.nav import Section
class StructureItem(metaclass=abc.ABCMeta):
"""An item in MkDocs structure - see concrete subclasses Section, Page or Link."""
@abc.abstractmethod
def __init__(self):
...
parent: Section | None = None
"""The immediate parent of the item in the site navigation. `None` if it's at the top level."""
@property
def is_top_level(self) -> bool:
return self.parent is None
title: str | None
is_section: bool = False
is_page: bool = False
is_link: bool = False
@property
def ancestors(self) -> Iterable[StructureItem]:
if self.parent is None:
return []
return [self.parent, *self.parent.ancestors]
def _indent_print(self, depth: int = 0) -> str:
return (' ' * depth) + repr(self)
@@ -0,0 +1,626 @@
from __future__ import annotations
import enum
import fnmatch
import logging
import os
import posixpath
import shutil
import warnings
from functools import cached_property
from pathlib import PurePath, PurePosixPath
from typing import TYPE_CHECKING, Callable, Iterable, Iterator, Mapping, Sequence, overload
from urllib.parse import quote as urlquote
import pathspec
import pathspec.gitignore
import pathspec.util
from mkdocs import utils
if TYPE_CHECKING:
import jinja2.environment
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.structure.pages import Page
log = logging.getLogger(__name__)
class InclusionLevel(enum.Enum):
EXCLUDED = -3
"""The file is excluded and will not be processed."""
DRAFT = -2
"""The file is excluded from the final site, but will still be populated during `mkdocs serve`."""
NOT_IN_NAV = -1
"""The file is part of the site, but doesn't produce nav warnings."""
UNDEFINED = 0
"""Still needs to be computed based on the config. If the config doesn't kick in, acts the same as `included`."""
INCLUDED = 1
"""The file is part of the site. Documentation pages that are omitted from the nav will produce warnings."""
def all(self):
return True
def is_included(self):
return self.value > self.DRAFT.value
def is_excluded(self):
return self.value <= self.DRAFT.value
def is_in_serve(self):
return self.value >= self.DRAFT.value
def is_in_nav(self):
return self.value > self.NOT_IN_NAV.value
def is_not_in_nav(self):
return self.value <= self.NOT_IN_NAV.value
class Files:
"""A collection of [File][mkdocs.structure.files.File] objects."""
def __init__(self, files: Iterable[File]) -> None:
self._src_uris = {f.src_uri: f for f in files}
def __iter__(self) -> Iterator[File]:
"""Iterate over the files within."""
return iter(self._src_uris.values())
def __len__(self) -> int:
"""The number of files within."""
return len(self._src_uris)
def __contains__(self, path: str) -> bool:
"""Soft-deprecated, prefer `get_file_from_path(path) is not None`."""
return PurePath(path).as_posix() in self._src_uris
@property
def src_paths(self) -> dict[str, File]:
"""Soft-deprecated, prefer `src_uris`."""
return {file.src_path: file for file in self}
@property
def src_uris(self) -> Mapping[str, File]:
"""
A mapping containing every file, with the keys being their
[`src_uri`][mkdocs.structure.files.File.src_uri].
"""
return self._src_uris
def get_file_from_path(self, path: str) -> File | None:
"""Return a File instance with File.src_uri equal to path."""
return self._src_uris.get(PurePath(path).as_posix())
def append(self, file: File) -> None:
"""Add file to the Files collection."""
if file.src_uri in self._src_uris:
warnings.warn(
"To replace an existing file, call `remove` before `append`.", DeprecationWarning
)
del self._src_uris[file.src_uri]
self._src_uris[file.src_uri] = file
def remove(self, file: File) -> None:
"""Remove file from Files collection."""
try:
del self._src_uris[file.src_uri]
except KeyError:
raise ValueError(f'{file.src_uri!r} not in collection')
def copy_static_files(
self,
dirty: bool = False,
*,
inclusion: Callable[[InclusionLevel], bool] = InclusionLevel.is_included,
) -> None:
"""Copy static files from source to destination."""
for file in self:
if not file.is_documentation_page() and inclusion(file.inclusion):
file.copy_file(dirty)
def documentation_pages(
self, *, inclusion: Callable[[InclusionLevel], bool] = InclusionLevel.is_included
) -> Sequence[File]:
"""Return iterable of all Markdown page file objects."""
return [file for file in self if file.is_documentation_page() and inclusion(file.inclusion)]
def static_pages(self) -> Sequence[File]:
"""Return iterable of all static page file objects."""
return [file for file in self if file.is_static_page()]
def media_files(self) -> Sequence[File]:
"""Return iterable of all file objects which are not documentation or static pages."""
return [file for file in self if file.is_media_file()]
def javascript_files(self) -> Sequence[File]:
"""Return iterable of all javascript file objects."""
return [file for file in self if file.is_javascript()]
def css_files(self) -> Sequence[File]:
"""Return iterable of all CSS file objects."""
return [file for file in self if file.is_css()]
def add_files_from_theme(self, env: jinja2.Environment, config: MkDocsConfig) -> None:
"""Retrieve static files from Jinja environment and add to collection."""
def filter(name):
# '.*' filters dot files/dirs at root level whereas '*/.*' filters nested levels
patterns = ['.*', '*/.*', '*.py', '*.pyc', '*.html', '*readme*', 'mkdocs_theme.yml']
# Exclude translation files
patterns.append("locales/*")
patterns.extend(f'*{x}' for x in utils.markdown_extensions)
patterns.extend(config.theme.static_templates)
for pattern in patterns:
if fnmatch.fnmatch(name.lower(), pattern):
return False
return True
for path in env.list_templates(filter_func=filter):
# Theme files do not override docs_dir files
if self.get_file_from_path(path) is None:
for dir in config.theme.dirs:
# Find the first theme dir which contains path
if os.path.isfile(os.path.join(dir, path)):
self.append(File(path, dir, config.site_dir, config.use_directory_urls))
break
@property
def _files(self) -> Iterable[File]:
warnings.warn("Do not access Files._files.", DeprecationWarning)
return self
@_files.setter
def _files(self, value: Iterable[File]):
warnings.warn("Do not access Files._files.", DeprecationWarning)
self._src_uris = {f.src_uri: f for f in value}
class File:
"""
A MkDocs File object.
It represents how the contents of one file should be populated in the destination site.
A file always has its `abs_dest_path` (obtained by joining `dest_dir` and `dest_path`),
where the `dest_dir` is understood to be the *site* directory.
`content_bytes`/`content_string` (new in MkDocs 1.6) can always be used to obtain the file's
content. But it may be backed by one of the two sources:
* A physical source file at `abs_src_path` (by default obtained by joining `src_dir` and
`src_uri`). `src_dir` is understood to be the *docs* directory.
Then `content_bytes`/`content_string` will read the file at `abs_src_path`.
`src_dir` *should* be populated for real files and should be `None` for generated files.
* Since MkDocs 1.6 a file may alternatively be stored in memory - `content_string`/`content_bytes`.
Then `src_dir` and `abs_src_path` will remain `None`. `content_bytes`/`content_string` need
to be written to, or populated through the `content` argument in the constructor.
But `src_uri` is still populated for such files as well! The virtual file pretends as if it
originated from that path in the `docs` directory, and other values are derived.
For static files the file is just copied to the destination, and `dest_uri` equals `src_uri`.
For Markdown files (determined by the file extension in `src_uri`) the destination content
will be the rendered content, and `dest_uri` will have the `.html` extension and some
additional transformations to the path, based on `use_directory_urls`.
"""
src_uri: str
"""The pure path (always '/'-separated) of the source file relative to the source directory."""
use_directory_urls: bool
"""Whether directory URLs ('foo/') should be used or not ('foo.html').
If `False`, a Markdown file is mapped to an HTML file of the same name (the file extension is
changed to `.html`). If True, a Markdown file is mapped to an HTML index file (`index.html`)
nested in a directory using the "name" of the file in `path`. Non-Markdown files retain their
original path.
"""
src_dir: str | None
"""The OS path of the top-level directory that the source file originates from.
Assumed to be the *docs_dir*; not populated for generated files."""
dest_dir: str
"""The OS path of the destination directory (top-level site_dir) that the file should be copied to."""
inclusion: InclusionLevel = InclusionLevel.UNDEFINED
"""Whether the file will be excluded from the built site."""
generated_by: str | None = None
"""If not None, indicates that a plugin generated this file on the fly.
The value is the plugin's entrypoint name and can be used to find the plugin by key in the PluginCollection."""
_content: str | bytes | None = None
"""If set, the file's content will be read from here.
This logic is handled by `content_bytes`/`content_string`, which should be used instead of
accessing this attribute."""
@property
def src_path(self) -> str:
"""Same as `src_uri` (and synchronized with it) but will use backslashes on Windows. Discouraged."""
return os.path.normpath(self.src_uri)
@src_path.setter
def src_path(self, value: str):
self.src_uri = PurePath(value).as_posix()
@property
def dest_path(self) -> str:
"""Same as `dest_uri` (and synchronized with it) but will use backslashes on Windows. Discouraged."""
return os.path.normpath(self.dest_uri)
@dest_path.setter
def dest_path(self, value: str):
self.dest_uri = PurePath(value).as_posix()
page: Page | None = None
@overload
@classmethod
def generated(
cls,
config: MkDocsConfig,
src_uri: str,
*,
content: str | bytes,
inclusion: InclusionLevel = InclusionLevel.UNDEFINED,
) -> File:
"""
Create a virtual file backed by in-memory content.
It will pretend to be a file in the docs dir at `src_uri`.
"""
@overload
@classmethod
def generated(
cls,
config: MkDocsConfig,
src_uri: str,
*,
abs_src_path: str,
inclusion: InclusionLevel = InclusionLevel.UNDEFINED,
) -> File:
"""
Create a virtual file backed by a physical temporary file at `abs_src_path`.
It will pretend to be a file in the docs dir at `src_uri`.
"""
@classmethod
def generated(
cls,
config: MkDocsConfig,
src_uri: str,
*,
content: str | bytes | None = None,
abs_src_path: str | None = None,
inclusion: InclusionLevel = InclusionLevel.UNDEFINED,
) -> File:
"""
Create a virtual file, backed either by in-memory `content` or by a file at `abs_src_path`.
It will pretend to be a file in the docs dir at `src_uri`.
"""
if (content is None) == (abs_src_path is None):
raise TypeError("File must have exactly one of 'content' or 'abs_src_path'")
f = cls(
src_uri,
src_dir=None,
dest_dir=config.site_dir,
use_directory_urls=config.use_directory_urls,
inclusion=inclusion,
)
f.generated_by = config.plugins._current_plugin or '<unknown>'
f.abs_src_path = abs_src_path
f._content = content
return f
def __init__(
self,
path: str,
src_dir: str | None,
dest_dir: str,
use_directory_urls: bool,
*,
dest_uri: str | None = None,
inclusion: InclusionLevel = InclusionLevel.UNDEFINED,
) -> None:
self.src_path = path
self.src_dir = src_dir
self.dest_dir = dest_dir
self.use_directory_urls = use_directory_urls
if dest_uri is not None:
self.dest_uri = dest_uri
self.inclusion = inclusion
def __repr__(self):
return (
f"{type(self).__name__}({self.src_uri!r}, src_dir={self.src_dir!r}, "
f"dest_dir={self.dest_dir!r}, use_directory_urls={self.use_directory_urls!r}, "
f"dest_uri={self.dest_uri!r}, inclusion={self.inclusion})"
)
@utils.weak_property
def edit_uri(self) -> str | None:
"""
A path relative to the source repository to use for the "edit" button.
Defaults to `src_uri` and can be overwritten.
For generated files this should be set to `None`.
"""
return self.src_uri if self.generated_by is None else None
def _get_stem(self) -> str:
"""Soft-deprecated, do not use."""
filename = posixpath.basename(self.src_uri)
stem, ext = posixpath.splitext(filename)
return 'index' if stem == 'README' else stem
name = cached_property(_get_stem)
"""Return the name of the file without its extension."""
def _get_dest_path(self, use_directory_urls: bool | None = None) -> str:
"""Soft-deprecated, do not use."""
if self.is_documentation_page():
parent, filename = posixpath.split(self.src_uri)
if use_directory_urls is None:
use_directory_urls = self.use_directory_urls
if not use_directory_urls or self.name == 'index':
# index.md or README.md => index.html
# foo.md => foo.html
return posixpath.join(parent, self.name + '.html')
else:
# foo.md => foo/index.html
return posixpath.join(parent, self.name, 'index.html')
return self.src_uri
dest_uri = cached_property(_get_dest_path)
"""The pure path (always '/'-separated) of the destination file relative to the destination directory."""
def _get_url(self, use_directory_urls: bool | None = None) -> str:
"""Soft-deprecated, do not use."""
url = self.dest_uri
dirname, filename = posixpath.split(url)
if use_directory_urls is None:
use_directory_urls = self.use_directory_urls
if use_directory_urls and filename == 'index.html':
url = (dirname or '.') + '/'
return urlquote(url)
url = cached_property(_get_url)
"""The URI of the destination file relative to the destination directory as a string."""
@cached_property
def abs_src_path(self) -> str | None:
"""
The absolute concrete path of the source file. Will use backslashes on Windows.
Note: do not use this path to read the file, prefer `content_bytes`/`content_string`.
"""
if self.src_dir is None:
return None
return os.path.normpath(os.path.join(self.src_dir, self.src_uri))
@cached_property
def abs_dest_path(self) -> str:
"""The absolute concrete path of the destination file. Will use backslashes on Windows."""
return os.path.normpath(os.path.join(self.dest_dir, self.dest_uri))
def url_relative_to(self, other: File | str) -> str:
"""Return url for file relative to other file."""
return utils.get_relative_url(self.url, other.url if isinstance(other, File) else other)
@property
def content_bytes(self) -> bytes:
"""
Get the content of this file as a bytestring.
May raise if backed by a real file (`abs_src_path`) if it cannot be read.
If used as a setter, it defines the content of the file, and `abs_src_path` becomes unset.
"""
content = self._content
if content is None:
assert self.abs_src_path is not None
with open(self.abs_src_path, 'rb') as f:
return f.read()
if not isinstance(content, bytes):
content = content.encode()
return content
@content_bytes.setter
def content_bytes(self, value: bytes):
assert isinstance(value, bytes)
self._content = value
self.abs_src_path = None
@property
def content_string(self) -> str:
"""
Get the content of this file as a string. Assumes UTF-8 encoding, may raise.
May also raise if backed by a real file (`abs_src_path`) if it cannot be read.
If used as a setter, it defines the content of the file, and `abs_src_path` becomes unset.
"""
content = self._content
if content is None:
assert self.abs_src_path is not None
with open(self.abs_src_path, encoding='utf-8-sig', errors='strict') as f:
return f.read()
if not isinstance(content, str):
content = content.decode('utf-8-sig', errors='strict')
return content
@content_string.setter
def content_string(self, value: str):
assert isinstance(value, str)
self._content = value
self.abs_src_path = None
def copy_file(self, dirty: bool = False) -> None:
"""Copy source file to destination, ensuring parent directories exist."""
if dirty and not self.is_modified():
log.debug(f"Skip copying unmodified file: '{self.src_uri}'")
return
log.debug(f"Copying media file: '{self.src_uri}'")
output_path = self.abs_dest_path
os.makedirs(os.path.dirname(output_path), exist_ok=True)
content = self._content
if content is None:
assert self.abs_src_path is not None
try:
utils.copy_file(self.abs_src_path, output_path)
except shutil.SameFileError:
pass # Let plugins write directly into site_dir.
elif isinstance(content, str):
with open(output_path, 'w', encoding='utf-8') as output_file:
output_file.write(content)
else:
with open(output_path, 'wb') as output_file:
output_file.write(content)
def is_modified(self) -> bool:
if self._content is not None:
return True
assert self.abs_src_path is not None
if os.path.isfile(self.abs_dest_path):
return os.path.getmtime(self.abs_dest_path) < os.path.getmtime(self.abs_src_path)
return True
def is_documentation_page(self) -> bool:
"""Return True if file is a Markdown page."""
return utils.is_markdown_file(self.src_uri)
def is_static_page(self) -> bool:
"""Return True if file is a static page (HTML, XML, JSON)."""
return self.src_uri.endswith(('.html', '.htm', '.xml', '.json'))
def is_media_file(self) -> bool:
"""Return True if file is not a documentation or static page."""
return not (self.is_documentation_page() or self.is_static_page())
def is_javascript(self) -> bool:
"""Return True if file is a JavaScript file."""
return self.src_uri.endswith(('.js', '.javascript', '.mjs'))
def is_css(self) -> bool:
"""Return True if file is a CSS file."""
return self.src_uri.endswith('.css')
_default_exclude = pathspec.gitignore.GitIgnoreSpec.from_lines(['.*', '/templates/'])
def set_exclusions(files: Iterable[File], config: MkDocsConfig) -> None:
"""Re-calculate which files are excluded, based on the patterns in the config."""
exclude: pathspec.gitignore.GitIgnoreSpec | None = config.get('exclude_docs')
exclude = _default_exclude + exclude if exclude else _default_exclude
drafts: pathspec.gitignore.GitIgnoreSpec | None = config.get('draft_docs')
nav_exclude: pathspec.gitignore.GitIgnoreSpec | None = config.get('not_in_nav')
for file in files:
if file.inclusion == InclusionLevel.UNDEFINED:
if exclude.match_file(file.src_uri):
file.inclusion = InclusionLevel.EXCLUDED
elif drafts and drafts.match_file(file.src_uri):
file.inclusion = InclusionLevel.DRAFT
elif nav_exclude and nav_exclude.match_file(file.src_uri):
file.inclusion = InclusionLevel.NOT_IN_NAV
else:
file.inclusion = InclusionLevel.INCLUDED
def get_files(config: MkDocsConfig) -> Files:
"""Walk the `docs_dir` and return a Files collection."""
files: list[File] = []
conflicting_files: list[tuple[File, File]] = []
for source_dir, dirnames, filenames in os.walk(config['docs_dir'], followlinks=True):
relative_dir = os.path.relpath(source_dir, config['docs_dir'])
dirnames.sort()
filenames.sort(key=_file_sort_key)
files_by_dest: dict[str, File] = {}
for filename in filenames:
file = File(
os.path.join(relative_dir, filename),
config['docs_dir'],
config['site_dir'],
config['use_directory_urls'],
)
# Skip README.md if an index file also exists in dir (part 1)
prev_file = files_by_dest.setdefault(file.dest_uri, file)
if prev_file is not file:
conflicting_files.append((prev_file, file))
files.append(file)
prev_file = file
set_exclusions(files, config)
# Skip README.md if an index file also exists in dir (part 2)
for a, b in conflicting_files:
if b.inclusion.is_included():
if a.inclusion.is_included():
log.warning(
f"Excluding '{a.src_uri}' from the site because it conflicts with '{b.src_uri}'."
)
try:
files.remove(a)
except ValueError:
pass # Catching this to avoid errors if attempting to remove the same file twice.
else:
try:
files.remove(b)
except ValueError:
pass
return Files(files)
def file_sort_key(f: File, /):
"""
Replicates the sort order how `get_files` produces it - index first, directories last.
To sort a list of `File`, pass as the `key` argument to `sort`.
"""
parts = PurePosixPath(f.src_uri).parts
if not parts:
return ()
return (parts[:-1], f.name != "index", parts[-1])
def _file_sort_key(f: str):
"""Always sort `index` or `README` as first filename in list. This works only on basenames of files."""
return (os.path.splitext(f)[0] not in ('index', 'README'), f)
def _sort_files(filenames: Iterable[str]) -> list[str]:
"""Soft-deprecated, do not use."""
return sorted(filenames, key=_file_sort_key)
def _filter_paths(basename: str, path: str, is_dir: bool, exclude: Iterable[str]) -> bool:
warnings.warn(
"_filter_paths is not used since MkDocs 1.5 and will be removed soon.", DeprecationWarning
)
for item in exclude:
# Items ending in '/' apply only to directories.
if item.endswith('/') and not is_dir:
continue
# Items starting with '/' apply to the whole path.
# In any other cases just the basename is used.
match = path if item.startswith('/') else basename
if fnmatch.fnmatch(match, item.strip('/')):
return True
return False
@@ -0,0 +1,251 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Iterator, TypeVar
from urllib.parse import urlsplit
from mkdocs.exceptions import BuildError
from mkdocs.structure import StructureItem
from mkdocs.structure.files import file_sort_key
from mkdocs.structure.pages import Page, _AbsoluteLinksValidationValue
from mkdocs.utils import nest_paths
if TYPE_CHECKING:
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.structure.files import Files
log = logging.getLogger(__name__)
class Navigation:
def __init__(self, items: list, pages: list[Page]) -> None:
self.items = items # Nested List with full navigation of Sections, Pages, and Links.
self.pages = pages # Flat List of subset of Pages in nav, in order.
self.homepage = None
for page in pages:
if page.is_homepage:
self.homepage = page
break
homepage: Page | None
"""The [page][mkdocs.structure.pages.Page] object for the homepage of the site."""
pages: list[Page]
"""A flat list of all [page][mkdocs.structure.pages.Page] objects contained in the navigation."""
def __str__(self) -> str:
return '\n'.join(item._indent_print() for item in self)
def __iter__(self) -> Iterator:
return iter(self.items)
def __len__(self) -> int:
return len(self.items)
class Section(StructureItem):
def __init__(self, title: str, children: list[StructureItem]) -> None:
self.title = title
self.children = children
self.active = False
def __repr__(self):
name = self.__class__.__name__
return f"{name}(title={self.title!r})"
title: str
"""The title of the section."""
children: list[StructureItem]
"""An iterable of all child navigation objects. Children may include nested sections, pages and links."""
@property
def active(self) -> bool:
"""
When `True`, indicates that a child page of this section is the current page and
can be used to highlight the section as the currently viewed section. Defaults
to `False`.
"""
return self.__active
@active.setter
def active(self, value: bool):
"""Set active status of section and ancestors."""
self.__active = bool(value)
if self.parent is not None:
self.parent.active = bool(value)
is_section: bool = True
"""Indicates that the navigation object is a "section" object. Always `True` for section objects."""
is_page: bool = False
"""Indicates that the navigation object is a "page" object. Always `False` for section objects."""
is_link: bool = False
"""Indicates that the navigation object is a "link" object. Always `False` for section objects."""
def _indent_print(self, depth: int = 0) -> str:
ret = [super()._indent_print(depth)]
for item in self.children:
ret.append(item._indent_print(depth + 1))
return '\n'.join(ret)
class Link(StructureItem):
def __init__(self, title: str, url: str):
self.title = title
self.url = url
def __repr__(self):
name = self.__class__.__name__
title = f"{self.title!r}" if self.title is not None else '[blank]'
return f"{name}(title={title}, url={self.url!r})"
title: str
"""The title of the link. This would generally be used as the label of the link."""
url: str
"""The URL that the link points to. The URL should always be an absolute URLs and
should not need to have `base_url` prepended."""
children: None = None
"""Links do not contain children and the attribute is always `None`."""
active: bool = False
"""External links cannot be "active" and the attribute is always `False`."""
is_section: bool = False
"""Indicates that the navigation object is a "section" object. Always `False` for link objects."""
is_page: bool = False
"""Indicates that the navigation object is a "page" object. Always `False` for link objects."""
is_link: bool = True
"""Indicates that the navigation object is a "link" object. Always `True` for link objects."""
def get_navigation(files: Files, config: MkDocsConfig) -> Navigation:
"""Build site navigation from config and files."""
documentation_pages = files.documentation_pages()
nav_config = config['nav']
if nav_config is None:
documentation_pages = sorted(documentation_pages, key=file_sort_key)
nav_config = nest_paths(f.src_uri for f in documentation_pages if f.inclusion.is_in_nav())
items = _data_to_navigation(nav_config, files, config)
if not isinstance(items, list):
items = [items]
# Get only the pages from the navigation, ignoring any sections and links.
pages = _get_by_type(items, Page)
# Include next, previous and parent links.
_add_previous_and_next_links(pages)
_add_parent_links(items)
missing_from_config = []
for file in documentation_pages:
if file.page is None:
# Any documentation files not found in the nav should still have an associated page, so we
# create them here. The Page object will automatically be assigned to `file.page` during
# its creation (and this is the only way in which these page objects are accessible).
Page(None, file, config)
if file.inclusion.is_in_nav():
missing_from_config.append(file.src_path)
if missing_from_config:
log.log(
config.validation.nav.omitted_files,
'The following pages exist in the docs directory, but are not '
'included in the "nav" configuration:\n - ' + '\n - '.join(missing_from_config),
)
links = _get_by_type(items, Link)
for link in links:
scheme, netloc, path, query, fragment = urlsplit(link.url)
if scheme or netloc:
log.debug(f"An external link to '{link.url}' is included in the 'nav' configuration.")
elif (
link.url.startswith('/')
and config.validation.nav.absolute_links
is not _AbsoluteLinksValidationValue.RELATIVE_TO_DOCS
):
log.log(
config.validation.nav.absolute_links,
f"An absolute path to '{link.url}' is included in the 'nav' "
"configuration, which presumably points to an external resource.",
)
else:
log.log(
config.validation.nav.not_found,
f"A reference to '{link.url}' is included in the 'nav' "
"configuration, which is not found in the documentation files.",
)
return Navigation(items, pages)
def _data_to_navigation(data, files: Files, config: MkDocsConfig):
if isinstance(data, dict):
return [
_data_to_navigation((key, value), files, config)
if isinstance(value, str)
else Section(title=key, children=_data_to_navigation(value, files, config))
for key, value in data.items()
]
elif isinstance(data, list):
return [
_data_to_navigation(item, files, config)[0]
if isinstance(item, dict) and len(item) == 1
else _data_to_navigation(item, files, config)
for item in data
]
title, path = data if isinstance(data, tuple) else (None, data)
lookup_path = path
if (
lookup_path.startswith('/')
and config.validation.nav.absolute_links is _AbsoluteLinksValidationValue.RELATIVE_TO_DOCS
):
lookup_path = lookup_path.lstrip('/')
if file := files.get_file_from_path(lookup_path):
if file.inclusion.is_excluded():
log.log(
min(logging.INFO, config.validation.nav.not_found),
f"A reference to '{file.src_path}' is included in the 'nav' "
"configuration, but this file is excluded from the built site.",
)
page = file.page
if page is not None:
if not isinstance(page, Page):
raise BuildError("A plugin has set File.page to a type other than Page.")
return page
return Page(title, file, config)
return Link(title, path)
T = TypeVar('T')
def _get_by_type(nav, t: type[T]) -> list[T]:
ret = []
for item in nav:
if isinstance(item, t):
ret.append(item)
if item.children:
ret.extend(_get_by_type(item.children, t))
return ret
def _add_parent_links(nav) -> None:
for item in nav:
if item.is_section:
for child in item.children:
child.parent = item
_add_parent_links(item.children)
def _add_previous_and_next_links(pages: list[Page]) -> None:
bookended = [None, *pages, None]
zipped = zip(bookended[:-2], pages, bookended[2:])
for page0, page1, page2 in zipped:
page1.previous_page, page1.next_page = page0, page2
@@ -0,0 +1,569 @@
from __future__ import annotations
import enum
import logging
import posixpath
import warnings
from typing import TYPE_CHECKING, Any, Callable, Iterator, MutableMapping, Sequence
from urllib.parse import unquote as urlunquote
from urllib.parse import urljoin, urlsplit, urlunsplit
import markdown
import markdown.extensions.toc
import markdown.htmlparser # type: ignore
import markdown.postprocessors
import markdown.treeprocessors
from markdown.util import AMP_SUBSTITUTE
from mkdocs import utils
from mkdocs.structure import StructureItem
from mkdocs.structure.toc import get_toc
from mkdocs.utils import _removesuffix, get_build_date, get_markdown_title, meta, weak_property
from mkdocs.utils.rendering import get_heading_text
if TYPE_CHECKING:
from xml.etree import ElementTree as etree
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.structure.files import File, Files
from mkdocs.structure.toc import TableOfContents
log = logging.getLogger(__name__)
class Page(StructureItem):
def __init__(self, title: str | None, file: File, config: MkDocsConfig) -> None:
file.page = self
self.file = file
if title is not None:
self.title = title
# Navigation attributes
self.children = None
self.previous_page = None
self.next_page = None
self.active = False
self.update_date: str = get_build_date()
self._set_canonical_url(config.get('site_url', None))
self._set_edit_url(
config.get('repo_url', None), config.get('edit_uri'), config.get('edit_uri_template')
)
# Placeholders to be filled in later in the build process.
self.markdown = None
self._title_from_render: str | None = None
self.content = None
self.toc = [] # type: ignore
self.meta = {}
def __eq__(self, other) -> bool:
return (
isinstance(other, self.__class__)
and self.title == other.title
and self.file == other.file
)
def __repr__(self):
name = self.__class__.__name__
title = f"{self.title!r}" if self.title is not None else '[blank]'
url = self.abs_url or self.file.url
return f"{name}(title={title}, url={url!r})"
markdown: str | None
"""The original Markdown content from the file."""
content: str | None
"""The rendered Markdown as HTML, this is the contents of the documentation.
Populated after `.render()`."""
toc: TableOfContents
"""An iterable object representing the Table of contents for a page. Each item in
the `toc` is an [`AnchorLink`][mkdocs.structure.toc.AnchorLink]."""
meta: MutableMapping[str, Any]
"""A mapping of the metadata included at the top of the markdown page."""
@property
def url(self) -> str:
"""The URL of the page relative to the MkDocs `site_dir`."""
url = self.file.url
if url in ('.', './'):
return ''
return url
file: File
"""The documentation [`File`][mkdocs.structure.files.File] that the page is being rendered from."""
abs_url: str | None
"""The absolute URL of the page from the server root as determined by the value
assigned to the [site_url][] configuration setting. The value includes any
subdirectory included in the `site_url`, but not the domain. [base_url][] should
not be used with this variable."""
canonical_url: str | None
"""The full, canonical URL to the current page as determined by the value assigned
to the [site_url][] configuration setting. The value includes the domain and any
subdirectory included in the `site_url`. [base_url][] should not be used with this
variable."""
@property
def active(self) -> bool:
"""When `True`, indicates that this page is the currently viewed page. Defaults to `False`."""
return self.__active
@active.setter
def active(self, value: bool):
"""Set active status of page and ancestors."""
self.__active = bool(value)
if self.parent is not None:
self.parent.active = bool(value)
@property
def is_index(self) -> bool:
return self.file.name == 'index'
edit_url: str | None
"""The full URL to the source page in the source repository. Typically used to
provide a link to edit the source page. [base_url][] should not be used with this
variable."""
@property
def is_homepage(self) -> bool:
"""Evaluates to `True` for the homepage of the site and `False` for all other pages."""
return self.is_top_level and self.is_index and self.file.url in ('.', './', 'index.html')
previous_page: Page | None
"""The [page][mkdocs.structure.pages.Page] object for the previous page or `None`.
The value will be `None` if the current page is the first item in the site navigation
or if the current page is not included in the navigation at all."""
next_page: Page | None
"""The [page][mkdocs.structure.pages.Page] object for the next page or `None`.
The value will be `None` if the current page is the last item in the site navigation
or if the current page is not included in the navigation at all."""
children: None = None
"""Pages do not contain children and the attribute is always `None`."""
is_section: bool = False
"""Indicates that the navigation object is a "section" object. Always `False` for page objects."""
is_page: bool = True
"""Indicates that the navigation object is a "page" object. Always `True` for page objects."""
is_link: bool = False
"""Indicates that the navigation object is a "link" object. Always `False` for page objects."""
def _set_canonical_url(self, base: str | None) -> None:
if base:
if not base.endswith('/'):
base += '/'
self.canonical_url = canonical_url = urljoin(base, self.url)
self.abs_url = urlsplit(canonical_url).path
else:
self.canonical_url = None
self.abs_url = None
def _set_edit_url(
self,
repo_url: str | None,
edit_uri: str | None = None,
edit_uri_template: str | None = None,
) -> None:
if not edit_uri_template and not edit_uri:
self.edit_url = None
return
src_uri = self.file.edit_uri
if src_uri is None:
self.edit_url = None
return
if edit_uri_template:
noext = posixpath.splitext(src_uri)[0]
file_edit_uri = edit_uri_template.format(path=src_uri, path_noext=noext)
else:
assert edit_uri is not None and edit_uri.endswith('/')
file_edit_uri = edit_uri + src_uri
if repo_url:
# Ensure urljoin behavior is correct
if not file_edit_uri.startswith(('?', '#')) and not repo_url.endswith('/'):
repo_url += '/'
else:
try:
parsed_url = urlsplit(file_edit_uri)
if not parsed_url.scheme or not parsed_url.netloc:
log.warning(
f"edit_uri: {file_edit_uri!r} is not a valid URL, it should include the http:// (scheme)"
)
except ValueError as e:
log.warning(f"edit_uri: {file_edit_uri!r} is not a valid URL: {e}")
self.edit_url = urljoin(repo_url or '', file_edit_uri)
def read_source(self, config: MkDocsConfig) -> None:
source = config.plugins.on_page_read_source(page=self, config=config)
if source is None:
try:
source = self.file.content_string
except OSError:
log.error(f'File not found: {self.file.src_path}')
raise
except ValueError:
log.error(f'Encoding error reading file: {self.file.src_path}')
raise
self.markdown, self.meta = meta.get_data(source)
def _set_title(self) -> None:
warnings.warn(
"_set_title is no longer used in MkDocs and will be removed soon.", DeprecationWarning
)
@weak_property
def title(self) -> str | None: # type: ignore[override]
"""
Returns the title for the current page.
Before calling `read_source()`, this value is empty. It can also be updated by `render()`.
Checks these in order and uses the first that returns a valid title:
- value provided on init (passed in from config)
- value of metadata 'title'
- content of the first H1 in Markdown content
- convert filename to title
"""
if self.markdown is None:
return None
if 'title' in self.meta:
return self.meta['title']
if self._title_from_render:
return self._title_from_render
elif self.content is None: # Preserve legacy behavior only for edge cases in plugins.
title_from_md = get_markdown_title(self.markdown)
if title_from_md is not None:
return title_from_md
if self.is_homepage:
return 'Home'
title = self.file.name.replace('-', ' ').replace('_', ' ')
# Capitalize if the filename was all lowercase, otherwise leave it as-is.
if title.lower() == title:
title = title.capitalize()
return title
def render(self, config: MkDocsConfig, files: Files) -> None:
"""Convert the Markdown source file to HTML as per the config."""
if self.markdown is None:
raise RuntimeError("`markdown` field hasn't been set (via `read_source`)")
md = markdown.Markdown(
extensions=config['markdown_extensions'],
extension_configs=config['mdx_configs'] or {},
)
raw_html_ext = _RawHTMLPreprocessor()
raw_html_ext._register(md)
extract_anchors_ext = _ExtractAnchorsTreeprocessor(self.file, files, config)
extract_anchors_ext._register(md)
relative_path_ext = _RelativePathTreeprocessor(self.file, files, config)
relative_path_ext._register(md)
extract_title_ext = _ExtractTitleTreeprocessor()
extract_title_ext._register(md)
self.content = md.convert(self.markdown)
self.toc = get_toc(getattr(md, 'toc_tokens', []))
self._title_from_render = extract_title_ext.title
self.present_anchor_ids = (
extract_anchors_ext.present_anchor_ids | raw_html_ext.present_anchor_ids
)
if log.getEffectiveLevel() > logging.DEBUG:
self.links_to_anchors = relative_path_ext.links_to_anchors
present_anchor_ids: set[str] | None = None
"""Anchor IDs that this page contains (can be linked to in this page)."""
links_to_anchors: dict[File, dict[str, str]] | None = None
"""Links to anchors in other files that this page contains.
The structure is: `{file_that_is_linked_to: {'anchor': 'original_link/to/some_file.md#anchor'}}`.
Populated after `.render()`. Populated only if `validation: {anchors: info}` (or greater) is set.
"""
def validate_anchor_links(self, *, files: Files, log_level: int) -> None:
if not self.links_to_anchors:
return
for to_file, links in self.links_to_anchors.items():
for anchor, original_link in links.items():
page = to_file.page
if page is None:
continue
if page.present_anchor_ids is None: # Page was somehow not rendered.
continue
if anchor in page.present_anchor_ids:
continue
context = ""
if to_file == self.file:
problem = "there is no such anchor on this page"
if anchor.startswith('fnref:'):
context = " This seems to be a footnote that is never referenced."
else:
problem = f"the doc '{to_file.src_uri}' does not contain an anchor '#{anchor}'"
log.log(
log_level,
f"Doc file '{self.file.src_uri}' contains a link '{original_link}', but {problem}.{context}",
)
class _ExtractAnchorsTreeprocessor(markdown.treeprocessors.Treeprocessor):
def __init__(self, file: File, files: Files, config: MkDocsConfig) -> None:
self.present_anchor_ids: set[str] = set()
def run(self, root: etree.Element) -> None:
add = self.present_anchor_ids.add
for element in root.iter():
if anchor := element.get('id'):
add(anchor)
if element.tag == 'a':
if anchor := element.get('name'):
add(anchor)
def _register(self, md: markdown.Markdown) -> None:
md.treeprocessors.register(self, "mkdocs_extract_anchors", priority=5) # Same as 'toc'.
class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
def __init__(self, file: File, files: Files, config: MkDocsConfig) -> None:
self.file = file
self.files = files
self.config = config
self.links_to_anchors: dict[File, dict[str, str]] = {}
def run(self, root: etree.Element) -> etree.Element:
"""
Update urls on anchors and images to make them relative.
Iterates through the full document tree looking for specific
tags and then makes them relative based on the site navigation
"""
for element in root.iter():
if element.tag == 'a':
key = 'href'
elif element.tag == 'img':
key = 'src'
else:
continue
url = element.get(key)
assert url is not None
new_url = self.path_to_url(url)
element.set(key, new_url)
return root
@classmethod
def _target_uri(cls, src_path: str, dest_path: str) -> str:
return posixpath.normpath(
posixpath.join(posixpath.dirname(src_path), dest_path).lstrip('/')
)
@classmethod
def _possible_target_uris(
cls, file: File, path: str, use_directory_urls: bool, suggest_absolute: bool = False
) -> Iterator[str]:
"""First yields the resolved file uri for the link, then proceeds to yield guesses for possible mistakes."""
target_uri = cls._target_uri(file.src_uri, path)
yield target_uri
if posixpath.normpath(path) == '.':
# Explicitly link to current file.
yield file.src_uri
return
tried = {target_uri}
prefixes = [target_uri, cls._target_uri(file.url, path)]
if prefixes[0] == prefixes[1]:
prefixes.pop()
suffixes: list[Callable[[str], str]] = []
if use_directory_urls:
suffixes.append(lambda p: p)
if not posixpath.splitext(target_uri)[-1]:
suffixes.append(lambda p: posixpath.join(p, 'index.md'))
suffixes.append(lambda p: posixpath.join(p, 'README.md'))
if (
not target_uri.endswith('.')
and not path.endswith('.md')
and (use_directory_urls or not path.endswith('/'))
):
suffixes.append(lambda p: _removesuffix(p, '.html') + '.md')
for pref in prefixes:
for suf in suffixes:
guess = posixpath.normpath(suf(pref))
if guess not in tried and not guess.startswith('../'):
yield guess
tried.add(guess)
def path_to_url(self, url: str) -> str:
scheme, netloc, path, query, anchor = urlsplit(url)
absolute_link = None
warning_level, warning = 0, ''
# Ignore URLs unless they are a relative link to a source file.
if scheme or netloc: # External link.
return url
elif url.startswith(('/', '\\')): # Absolute link.
absolute_link = self.config.validation.links.absolute_links
if absolute_link is not _AbsoluteLinksValidationValue.RELATIVE_TO_DOCS:
warning_level = absolute_link
warning = f"Doc file '{self.file.src_uri}' contains an absolute link '{url}', it was left as is."
elif AMP_SUBSTITUTE in url: # AMP_SUBSTITUTE is used internally by Markdown only for email.
return url
elif not path: # Self-link containing only query or anchor.
if anchor:
# Register that the page links to itself with an anchor.
self.links_to_anchors.setdefault(self.file, {}).setdefault(anchor, url)
return url
path = urlunquote(path)
# Determine the filepath of the target.
possible_target_uris = self._possible_target_uris(
self.file, path, self.config.use_directory_urls
)
if warning:
# For absolute path (already has a warning), the primary lookup path should be preserved as a tip option.
target_uri = url
target_file = None
else:
# Validate that the target exists in files collection.
target_uri = next(possible_target_uris)
target_file = self.files.get_file_from_path(target_uri)
if target_file is None and not warning:
# Primary lookup path had no match, definitely produce a warning, just choose which one.
if not posixpath.splitext(path)[-1] and absolute_link is None:
# No '.' in the last part of a path indicates path does not point to a file.
warning_level = self.config.validation.links.unrecognized_links
warning = (
f"Doc file '{self.file.src_uri}' contains an unrecognized relative link '{url}', "
f"it was left as is."
)
else:
target = f" '{target_uri}'" if target_uri != url.lstrip('/') else ""
warning_level = self.config.validation.links.not_found
warning = (
f"Doc file '{self.file.src_uri}' contains a link '{url}', "
f"but the target{target} is not found among documentation files."
)
if warning:
if self.file.inclusion.is_excluded():
warning_level = min(logging.INFO, warning_level)
# There was no match, so try to guess what other file could've been intended.
if warning_level > logging.DEBUG:
suggest_url = ''
for path in possible_target_uris:
if self.files.get_file_from_path(path) is not None:
if anchor and path == self.file.src_uri:
path = ''
elif absolute_link is _AbsoluteLinksValidationValue.RELATIVE_TO_DOCS:
path = '/' + path
else:
path = utils.get_relative_url(path, self.file.src_uri)
suggest_url = urlunsplit(('', '', path, query, anchor))
break
else:
if '@' in url and '.' in url and '/' not in url:
suggest_url = f'mailto:{url}'
if suggest_url:
warning += f" Did you mean '{suggest_url}'?"
log.log(warning_level, warning)
return url
assert target_uri is not None
assert target_file is not None
if anchor:
# Register that this page links to the target file with an anchor.
self.links_to_anchors.setdefault(target_file, {}).setdefault(anchor, url)
if target_file.inclusion.is_excluded():
if self.file.inclusion.is_excluded():
warning_level = logging.DEBUG
else:
warning_level = min(logging.INFO, self.config.validation.links.not_found)
warning = (
f"Doc file '{self.file.src_uri}' contains a link to "
f"'{target_uri}' which is excluded from the built site."
)
log.log(warning_level, warning)
path = utils.get_relative_url(target_file.url, self.file.url)
return urlunsplit(('', '', path, query, anchor))
def _register(self, md: markdown.Markdown) -> None:
md.treeprocessors.register(self, "relpath", 0)
class _RawHTMLPreprocessor(markdown.preprocessors.Preprocessor):
def __init__(self) -> None:
super().__init__()
self.present_anchor_ids: set[str] = set()
def run(self, lines: list[str]) -> list[str]:
parser = _HTMLHandler()
parser.feed('\n'.join(lines))
parser.close()
self.present_anchor_ids = parser.present_anchor_ids
return lines
def _register(self, md: markdown.Markdown) -> None:
md.preprocessors.register(
self, "mkdocs_raw_html", priority=21 # Right before 'html_block'.
)
class _HTMLHandler(markdown.htmlparser.htmlparser.HTMLParser): # type: ignore[name-defined]
def __init__(self) -> None:
super().__init__()
self.present_anchor_ids: set[str] = set()
def handle_starttag(self, tag: str, attrs: Sequence[tuple[str, str]]) -> None:
for k, v in attrs:
if k == 'id' or (k == 'name' and tag == 'a'):
self.present_anchor_ids.add(v)
return super().handle_starttag(tag, attrs)
class _ExtractTitleTreeprocessor(markdown.treeprocessors.Treeprocessor):
title: str | None = None
md: markdown.Markdown
def run(self, root: etree.Element) -> etree.Element:
for el in root:
if el.tag == 'h1':
self.title = get_heading_text(el, self.md)
break
return root
def _register(self, md: markdown.Markdown) -> None:
self.md = md
md.treeprocessors.register(self, "mkdocs_extract_title", priority=1) # Close to the end.
class _AbsoluteLinksValidationValue(enum.IntEnum):
RELATIVE_TO_DOCS = -1
@@ -0,0 +1,80 @@
"""
Deals with generating the per-page table of contents.
For the sake of simplicity we use the Python-Markdown `toc` extension to
generate a list of dicts for each toc item, and then store it as AnchorLinks to
maintain compatibility with older versions of MkDocs.
"""
from __future__ import annotations
from typing import Iterable, Iterator, TypedDict
class _TocToken(TypedDict):
level: int
id: str
name: str
children: list[_TocToken]
def get_toc(toc_tokens: list[_TocToken]) -> TableOfContents:
toc = [_parse_toc_token(i) for i in toc_tokens]
# For the table of contents, always mark the first element as active
if len(toc):
toc[0].active = True # type: ignore[attr-defined]
return TableOfContents(toc)
class AnchorLink:
"""A single entry in the table of contents."""
def __init__(self, title: str, id: str, level: int) -> None:
self.title, self.id, self.level = title, id, level
self.children = []
title: str
"""The text of the item, as HTML."""
@property
def url(self) -> str:
"""The hash fragment of a URL pointing to the item."""
return '#' + self.id
level: int
"""The zero-based level of the item."""
children: list[AnchorLink]
"""An iterable of any child items."""
def __str__(self) -> str:
return self.indent_print()
def indent_print(self, depth: int = 0) -> str:
indent = ' ' * depth
ret = f'{indent}{self.title} - {self.url}\n'
for item in self.children:
ret += item.indent_print(depth + 1)
return ret
class TableOfContents(Iterable[AnchorLink]):
"""Represents the table of contents for a given page."""
def __init__(self, items: list[AnchorLink]) -> None:
self.items = items
def __iter__(self) -> Iterator[AnchorLink]:
return iter(self.items)
def __len__(self) -> int:
return len(self.items)
def __str__(self) -> str:
return ''.join(str(item) for item in self)
def _parse_toc_token(token: _TocToken) -> AnchorLink:
anchor = AnchorLink(token['name'], token['id'], token['level'])
for i in token['children']:
anchor.children.append(_parse_toc_token(i))
return anchor