pulldown_cmark_toc/
slug.rs1use std::{borrow::Cow, collections::HashMap, sync::LazyLock};
2
3use regex::Regex;
4
5pub trait Slugify {
7 fn slugify<'a>(&mut self, str: &'a str) -> Cow<'a, str>;
8}
9
10#[derive(Default)]
19pub struct GitHubSlugifier {
20 counts: HashMap<String, i32>,
21}
22
23impl Slugify for GitHubSlugifier {
24 fn slugify<'a>(&mut self, str: &'a str) -> Cow<'a, str> {
25 static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^\w\- ]").unwrap());
26 let anchor = RE
27 .replace_all(&str.to_lowercase().replace(' ', "-"), "")
28 .into_owned();
29
30 let i = self
31 .counts
32 .entry(anchor.clone())
33 .and_modify(|i| *i += 1)
34 .or_insert(0);
35
36 match *i {
37 0 => anchor,
38 i => format!("{}-{}", anchor, i),
39 }
40 .into()
41 }
42}
43
44#[cfg(test)]
45mod tests {
46 use crate::slug::{GitHubSlugifier, Slugify};
47 use crate::Heading;
48 use pulldown_cmark::CowStr::Borrowed;
49 use pulldown_cmark::Event::{Code, Text};
50 use pulldown_cmark::{HeadingLevel, Parser};
51
52 #[test]
53 fn heading_anchor_with_code() {
54 let heading = Heading {
55 events: vec![Code(Borrowed("Another")), Text(Borrowed(" heading"))],
56 level: HeadingLevel::H1,
57 };
58 assert_eq!(
59 GitHubSlugifier::default().slugify(&heading.text()),
60 "another-heading"
61 );
62 }
63
64 #[test]
65 fn heading_anchor_with_links() {
66 let events = Parser::new("Here [TOML](https://toml.io)").collect();
67 let heading = Heading {
68 events,
69 level: HeadingLevel::H1,
70 };
71 assert_eq!(
72 GitHubSlugifier::default().slugify(&heading.text()),
73 "here-toml"
74 );
75 }
76
77 #[test]
78 fn github_slugger_non_ascii_lowercase() {
79 assert_eq!(GitHubSlugifier::default().slugify("Привет"), "привет");
80 }
81}