1import re
2from typing import Callable, List, Optional, Set
3
4from markdown_it import MarkdownIt
5from markdown_it.rules_core import StateCore
6from markdown_it.token import Token
7
8
9def anchors_plugin(
10 md: MarkdownIt,
11 min_level: int = 1,
12 max_level: int = 2,
13 slug_func: Optional[Callable[[str], str]] = None,
14 permalink: bool = False,
15 permalinkSymbol: str = "¶",
16 permalinkBefore: bool = False,
17 permalinkSpace: bool = True,
18) -> None:
19 """Plugin for adding header anchors, based on
20 `markdown-it-anchor <https://github.com/valeriangalliat/markdown-it-anchor>`__
21
22 .. code-block:: md
23
24 # Title String
25
26 renders as:
27
28 .. code-block:: html
29
30 <h1 id="title-string">Title String <a class="header-anchor" href="#title-string">¶</a></h1>
31
32 :param min_level: minimum header level to apply anchors
33 :param max_level: maximum header level to apply anchors
34 :param slug_func: function to convert title text to id slug.
35 :param permalink: Add a permalink next to the title
36 :param permalinkSymbol: the symbol to show
37 :param permalinkBefore: Add the permalink before the title, otherwise after
38 :param permalinkSpace: Add a space between the permalink and the title
39
40 Note, the default slug function aims to mimic the GitHub Markdown format, see:
41
42 - https://github.com/jch/html-pipeline/blob/master/lib/html/pipeline/toc_filter.rb
43 - https://gist.github.com/asabaylus/3071099
44
45 """
46 selected_levels = list(range(min_level, max_level + 1))
47 md.core.ruler.push(
48 "anchor",
49 _make_anchors_func(
50 selected_levels,
51 slug_func or slugify,
52 permalink,
53 permalinkSymbol,
54 permalinkBefore,
55 permalinkSpace,
56 ),
57 )
58
59
60def _make_anchors_func(
61 selected_levels: List[int],
62 slug_func: Callable[[str], str],
63 permalink: bool,
64 permalinkSymbol: str,
65 permalinkBefore: bool,
66 permalinkSpace: bool,
67) -> Callable[[StateCore], None]:
68 def _anchor_func(state: StateCore) -> None:
69 slugs: Set[str] = set()
70 for idx, token in enumerate(state.tokens):
71 if token.type != "heading_open":
72 continue
73 level = int(token.tag[1])
74 if level not in selected_levels:
75 continue
76 inline_token = state.tokens[idx + 1]
77 assert inline_token.children is not None
78 title = "".join(
79 child.content
80 for child in inline_token.children
81 if child.type in ["text", "code_inline"]
82 )
83 slug = unique_slug(slug_func(title), slugs)
84 token.attrSet("id", slug)
85
86 if permalink:
87 link_open = Token(
88 "link_open",
89 "a",
90 1,
91 )
92 link_open.attrSet("class", "header-anchor")
93 link_open.attrSet("href", f"#{slug}")
94 link_tokens = [
95 link_open,
96 Token("html_block", "", 0, content=permalinkSymbol),
97 Token("link_close", "a", -1),
98 ]
99 if permalinkBefore:
100 inline_token.children = (
101 link_tokens
102 + (
103 [Token("text", "", 0, content=" ")]
104 if permalinkSpace
105 else []
106 )
107 + inline_token.children
108 )
109 else:
110 inline_token.children.extend(
111 ([Token("text", "", 0, content=" ")] if permalinkSpace else [])
112 + link_tokens
113 )
114
115 return _anchor_func
116
117
118def slugify(title: str) -> str:
119 return re.sub(r"[^\w\u4e00-\u9fff\- ]", "", title.strip().lower().replace(" ", "-"))
120
121
122def unique_slug(slug: str, slugs: Set[str]) -> str:
123 uniq = slug
124 i = 1
125 while uniq in slugs:
126 uniq = f"{slug}-{i}"
127 i += 1
128 slugs.add(uniq)
129 return uniq