252 lines
8.8 KiB
Python
252 lines
8.8 KiB
Python
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
|