Source code for mdreport.heading_anchors

from __future__ import annotations

import copy
import enum
import re
import unicodedata
from collections.abc import Sequence
from dataclasses import dataclass

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

__all__ = [
    "HeadingAnchor",
    "HeadingAnchorStyle",
    "anchor_link_inline",
    "anchored_inline",
    "anchored_tokens",
    "document_anchors",
    "heading_text",
    "slugify",
]


[docs] class HeadingAnchorStyle(enum.StrEnum): """How a heading's anchor is written into the rendered Markdown. ``IMPLICIT`` writes nothing and relies on the anchor the renderer derives from the heading text — what GitHub, GitLab, Pandoc, MkDocs, and Docusaurus all do, and what ``slugify`` reproduces. ``HTML`` prefixes the heading with an ``<a id="...">`` element, for renderers that generate no anchors of their own. ``ATTRIBUTE`` appends the ``{#slug}`` attribute Pandoc, kramdown, and python-markdown's ``attr_list`` understand; anything else renders it as visible text. """ IMPLICIT = "implicit" HTML = "html" ATTRIBUTE = "attribute"
@dataclass(frozen=True) class HeadingAnchor: """A heading, its level, and the slug that links to it.""" level: int inline: Token slug: str # Everything a slug does not keep: anything that is not a word character, a # space, or a hyphen. Dropping the rest is what makes a slug safe to write into # an HTML id attribute or a link destination without escaping. DISCARDED_SLUG_CHARACTERS = re.compile(r"[^\w\- ]", flags=re.UNICODE) # The inline tokens that carry a heading's visible words. Emphasis and link # markup wrap these rather than containing text of their own, so building the # slug from these alone strips the markup without a second traversal. TEXT_TOKEN_TYPES = frozenset({"text", "code_inline"}) BREAK_TOKEN_TYPES = frozenset({"softbreak", "hardbreak"}) LINK_TOKEN_TYPES = frozenset({"link_open", "link_close"}) FALLBACK_SLUG = "section"
[docs] def slugify(text: str) -> str: """Return the anchor slug a heading of this text is linked by. Follows the GitHub algorithm — case folded, punctuation dropped, spaces turned into hyphens — so a link to the slug resolves on every renderer that derives heading anchors the same way, with nothing written into the document. Text that slugifies to nothing yields ``section``. """ folded = unicodedata.normalize("NFKC", text).strip().lower() slug = DISCARDED_SLUG_CHARACTERS.sub("", folded).replace(" ", "-") return slug or FALLBACK_SLUG
def unique_slug(text: str, taken: set[str]) -> str: """Return a slug for text that is not in taken, and record it there. Repeats are numbered the way GitHub numbers them, so a second "Revenue" heading anchors at ``revenue-1``. """ base = slugify(text) slug = base occurrence = 0 while slug in taken: occurrence += 1 slug = f"{base}-{occurrence}" taken.add(slug) return slug def heading_text(inline: Token) -> str: """Return a heading's visible text, with inline markup removed.""" if inline.children is None: return inline.content words: list[str] = [] for child in inline.children: if child.type in TEXT_TOKEN_TYPES: words.append(child.content) elif child.type in BREAK_TOKEN_TYPES: words.append(" ") return "".join(words) def document_anchors(document: SyntaxTreeNode) -> list[HeadingAnchor]: """Return every heading in the document, in order, with a unique slug. Raises: ValueError: if a heading node contains no inline token. """ anchors: list[HeadingAnchor] = [] taken: set[str] = set() for node in document.walk(): if node.type != "heading": continue inline_node = next((child for child in node.children if child.type == "inline"), None) if inline_node is None: continue if inline_node.token is None: raise ValueError("Heading inline node must contain a token") anchors.append( HeadingAnchor( level=int(node.tag.removeprefix("h")), inline=copy.deepcopy(inline_node.token), slug=unique_slug(heading_text(inline_node.token), taken), ) ) return anchors def anchored_tokens(tokens: Sequence[Token], style: HeadingAnchorStyle) -> list[Token]: """Return a token stream whose headings carry an explicit anchor. Slugs are derived from the stream itself, so they match the ones ``document_anchors`` collects from the same headings. The given tokens are left unmodified. """ if style is HeadingAnchorStyle.IMPLICIT: return list(tokens) taken: set[str] = set() anchored: list[Token] = [] is_heading_inline = False for token in tokens: if is_heading_inline and token.type == "inline": slug = unique_slug(heading_text(token), taken) anchored.append(anchored_inline(token, slug, style)) is_heading_inline = False continue is_heading_inline = token.type == "heading_open" anchored.append(token) return anchored def anchored_inline(inline: Token, slug: str, style: HeadingAnchorStyle) -> Token: """Return a copy of a heading's inline content carrying its anchor. The slug is written unescaped into an HTML attribute under ``HeadingAnchorStyle.HTML``; ``slugify`` is what makes that safe, having already dropped every character that could close the attribute or the tag. """ anchored = copy.deepcopy(inline) children = list(anchored.children or []) match style: case HeadingAnchorStyle.IMPLICIT: return anchored case HeadingAnchorStyle.HTML: anchor = Token("html_inline", "", 0, content=f'<a id="{slug}"></a>') anchored.children = [anchor, *children] case HeadingAnchorStyle.ATTRIBUTE: attribute = Token("html_inline", "", 0, content=f" {{#{slug}}}") anchored.children = [*children, attribute] return anchored def anchor_link_inline(inline: Token, slug: str) -> Token: """Return a copy of inline content wrapped in a link to a heading's anchor. A link already inside the content is unwrapped, keeping its text: Markdown has no nested links, so the link to the heading has to be the one that survives. """ linked = copy.deepcopy(inline) children = [child for child in (linked.children or []) if child.type not in LINK_TOKEN_TYPES] link_open = Token("link_open", "a", 1) link_open.attrSet("href", f"#{slug}") linked.children = [link_open, *children, Token("link_close", "a", -1)] return linked