markdown_it_heading_anchors/
lib.rs

1//! Add id attribute (slug) to headings.
2//!
3//! ```rust
4//! use markdown_it_heading_anchors::{
5//!     add_with_options, HeadingAnchorOptions, AnchorPosition
6//! };
7//!
8//! let md = &mut markdown_it::MarkdownIt::new();
9//! markdown_it::plugins::cmark::add(md);
10//! let mut options = HeadingAnchorOptions::default();
11//! options.position = AnchorPosition::Start;
12//! options.inner_html = String::from("¶");
13//! add_with_options(md, options);
14//!
15//! assert_eq!(
16//!     md.parse("# heading\n\n# heading").render(),
17//!     "<h1>\
18//!     <a aria-hidden=\"true\" class=\"anchor\" id=\"heading\" href=\"#heading\">¶</a>\
19//!     heading</h1>\n\
20//!     <h1>\
21//!     <a aria-hidden=\"true\" class=\"anchor\" id=\"heading-1\" href=\"#heading-1\">¶</a>\
22//!     heading</h1>\n",
23//! );
24//! ```
25
26use 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
36/// Add the heading anchor plugin to MarkdownIt.
37pub fn add(md: &mut MarkdownIt) {
38    md.ext.get_or_insert_default::<HeadingAnchorOptions>();
39    md.add_rule::<AddHeadingAnchors>()
40        .after::<InlineParserRule>();
41}
42
43/// Add the heading anchor plugin to MarkdownIt, with options.
44pub 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)]
51/// Where to add the anchor, within the heading children.
52pub enum AnchorPosition {
53    Start,
54    End,
55    None,
56}
57
58#[derive(Debug)]
59/// Options for the heading anchor plugin.
60pub struct HeadingAnchorOptions {
61    /// Minimum heading level to add anchors to.
62    pub min_level: u8,
63    /// Maximum heading level to add anchors to.
64    pub max_level: u8,
65    /// Whether to add the id attribute to the heading itself.
66    pub id_on_heading: bool,
67    /// Where to add the anchor.
68    pub position: AnchorPosition,
69    /// Classes to add to the anchor.
70    pub classes: Vec<String>,
71    /// Inner HTML of the anchor.
72    pub inner_html: String,
73    // TODO allow custom slugger
74    // (must make sure reset is called, or create new slugger for each use)
75    // TODO id prefix (different to href,
76    // see <https://github.com/Flet/markdown-it-github-headings/tree/master#why-should-i-prefix-heading-ids>)
77}
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)]
95/// AST node for a heading anchor
96pub 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            // TODO should be able to halt recursion for paragraphs etc,
120            // that cannot contain headings
121            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                // TODO strip image (alt) text
133                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}