Source code for mdreport.table_of_contents

from __future__ import annotations

import copy
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from markdown_it.token import Token
from markdown_it.tree import SyntaxTreeNode

from .heading_anchors import anchor_link_inline, document_anchors

if TYPE_CHECKING:
    from .report import MarkdownReport

__all__ = ["TableOfContents", "TableOfContentsEntry"]

MAXIMUM_HEADING_LEVEL = 6


[docs] @dataclass class TableOfContentsEntry: """A heading, the anchor linking to it, and the headings nested beneath it.""" level: int inline: Token slug: str children: list[TableOfContentsEntry] = field(default_factory=list)
[docs] @dataclass(frozen=True) class TableOfContents: """A nested list of the report's headings, linked to their anchors. The block behind ``MarkdownReport.table_of_contents``, and the reference ``DeferredReportBlock``: it is appended as a placeholder and resolved during ``render``, so it lists headings added after it as well as before. Entries nest by heading level. Entries link to the anchor a renderer derives from the heading text, which resolves as-is on GitHub, GitLab, Pandoc, and MkDocs. Where the renderer generates no anchors, build the report with a ``MarkdownReport`` ``anchor_style`` that writes them into the document. Args: start_level: Shallowest heading level listed; headings above it are skipped along with the nesting they would have introduced. depth: How many heading levels to list, counting from ``start_level``. is_linked: False renders entries as plain text, for a document whose anchors cannot be relied on. Raises: ValueError: if start_level is outside the Markdown heading range, or depth is less than one. Example: .. code-block:: python report.append(TableOfContents()) report.append(TableOfContents(start_level=2, depth=2)) # h2 and h3 only """ start_level: int = 1 depth: int = MAXIMUM_HEADING_LEVEL is_linked: bool = True
[docs] def __post_init__(self) -> None: """Reject a scope that no heading could fall in.""" if not 1 <= self.start_level <= MAXIMUM_HEADING_LEVEL: raise ValueError("Table of contents start_level must be between 1 and 6") if self.depth < 1: raise ValueError("Table of contents depth must be at least 1")
@property def end_level(self) -> int: """Deepest heading level listed, clamped to the Markdown heading range.""" return min(MAXIMUM_HEADING_LEVEL, self.start_level + self.depth - 1)
[docs] def __resolve__(self, document: SyntaxTreeNode, report: MarkdownReport) -> list[Token]: """Return list tokens mirroring the document's heading hierarchy.""" return self.contents_tokens(self.entries(document))
[docs] def entries(self, document: SyntaxTreeNode) -> list[TableOfContentsEntry]: """Collect the headings in scope into a hierarchy, in document order. Raises: ValueError: if a heading node contains no inline token. """ root_entries: list[TableOfContentsEntry] = [] ancestors: list[TableOfContentsEntry] = [] for anchor in document_anchors(document): if not self.start_level <= anchor.level <= self.end_level: continue entry = TableOfContentsEntry( level=anchor.level, inline=anchor.inline, slug=anchor.slug, ) while ancestors and ancestors[-1].level >= entry.level: ancestors.pop() if ancestors: ancestors[-1].children.append(entry) else: root_entries.append(entry) ancestors.append(entry) return root_entries
[docs] def contents_tokens(self, entries: Sequence[TableOfContentsEntry]) -> list[Token]: """Build nested unordered-list tokens for table-of-contents entries.""" if not entries: return [] tokens = [Token("bullet_list_open", "ul", 1, markup="-", block=True)] for entry in entries: tokens.append(Token("list_item_open", "li", 1, markup="-", block=True)) tokens.extend( [ Token("paragraph_open", "p", 1, block=True, hidden=True), self.entry_inline(entry), Token("paragraph_close", "p", -1, block=True, hidden=True), ] ) tokens.extend(self.contents_tokens(entry.children)) tokens.append(Token("list_item_close", "li", -1, markup="-", block=True)) tokens.append(Token("bullet_list_close", "ul", -1, markup="-", block=True)) return tokens
[docs] def entry_inline(self, entry: TableOfContentsEntry) -> Token: """Build one entry's inline content, linked to its heading's anchor.""" if not self.is_linked: return copy.deepcopy(entry.inline) return anchor_link_inline(entry.inline, entry.slug)