test bkp
This commit is contained in:
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)
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
Reference in new issue
Block a user