markdown_it_heading_anchors/
lib.rs1use github_slugger::Slugger;
27use markdown_it::{
28 parser::{core::CoreRule, extset::MarkdownItExt, inline::builtin::InlineParserRule},
29 plugins::{
30 cmark::block::{heading::ATXHeading, lheading::SetextHeader},
31 html::html_inline::HtmlInline,
32 },
33 MarkdownIt, Node, NodeValue,
34};
35
36pub fn add(md: &mut MarkdownIt) {
38 md.ext.get_or_insert_default::<HeadingAnchorOptions>();
39 md.add_rule::<AddHeadingAnchors>()
40 .after::<InlineParserRule>();
41}
42
43pub fn add_with_options(md: &mut MarkdownIt, options: HeadingAnchorOptions) {
45 md.ext.insert(options);
46 md.add_rule::<AddHeadingAnchors>()
47 .after::<InlineParserRule>();
48}
49
50#[derive(Debug)]
51pub enum AnchorPosition {
53 Start,
54 End,
55 None,
56}
57
58#[derive(Debug)]
59pub struct HeadingAnchorOptions {
61 pub min_level: u8,
63 pub max_level: u8,
65 pub id_on_heading: bool,
67 pub position: AnchorPosition,
69 pub classes: Vec<String>,
71 pub inner_html: String,
73 }
78impl Default for HeadingAnchorOptions {
79 fn default() -> Self {
80 Self {
81 min_level: 1,
82 max_level: 6,
83 id_on_heading: false,
84 position: AnchorPosition::Start,
85 classes: vec![String::from("anchor")],
86 inner_html: String::from(
87 r#"<svg class="octicon octicon-link" viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path></svg>"#,
88 ),
89 }
90 }
91}
92impl MarkdownItExt for HeadingAnchorOptions {}
93
94#[derive(Debug)]
95pub struct HeadingAnchor {
97 pub href: String,
98 pub id: Option<String>,
99}
100impl NodeValue for HeadingAnchor {
101 fn render(&self, node: &Node, fmt: &mut dyn markdown_it::Renderer) {
102 let mut attrs = node.attrs.clone();
103 if let Some(id) = &self.id {
104 attrs.push(("id", id.clone()));
105 }
106 attrs.push(("href", format!("#{}", self.href)));
107 fmt.open("a", &attrs);
108 fmt.contents(&node.children);
109 fmt.close("a");
110 }
111}
112
113struct AddHeadingAnchors;
114impl CoreRule for AddHeadingAnchors {
115 fn run(root: &mut Node, md: &MarkdownIt) {
116 let options = md.ext.get::<HeadingAnchorOptions>().unwrap();
117 let mut slugger = Slugger::default();
118 root.walk_mut(|node, _| {
119 if let Some(value) = node.cast::<ATXHeading>() {
122 if value.level < options.min_level || value.level > options.max_level {
123 return;
124 }
125 }
126 if let Some(value) = node.cast::<SetextHeader>() {
127 if value.level < options.min_level || value.level > options.max_level {
128 return;
129 }
130 }
131 if node.is::<ATXHeading>() || node.is::<SetextHeader>() {
132 let id = slugger.slug(&node.collect_text());
134 if options.id_on_heading {
135 node.attrs.push(("id", id.clone()));
136 }
137 let anchor = HeadingAnchor {
138 href: id.clone(),
139 id: {
140 if options.id_on_heading {
141 None
142 } else {
143 Some(id)
144 }
145 },
146 };
147 let mut link_node = Node::new(anchor);
148 link_node.attrs.push(("aria-hidden", String::from("true")));
149 link_node.children.push(Node::new(HtmlInline {
150 content: options.inner_html.clone(),
151 }));
152 for class in &options.classes {
153 link_node.attrs.push(("class", class.clone()));
154 }
155 match options.position {
156 AnchorPosition::Start => {
157 node.children.insert(0, link_node);
158 }
159 AnchorPosition::End => {
160 node.children.push(link_node);
161 }
162 AnchorPosition::None => {}
163 }
164 }
165 });
166 }
167}