Skip to content

Commit

Permalink
refactor!: Introduce Slugify trait
Browse files Browse the repository at this point in the history
Resolves #4 and #5.

This is a breaking change since it removes the `Heading::anchor`.
But that's a good thing anyway since the method previously returned
wrong anchors on duplicate headings (see #4).
  • Loading branch information
not-my-profile authored and rossmacarthur committed Aug 10, 2023
1 parent e5d2cd3 commit 1cc173f
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 44 deletions.
48 changes: 4 additions & 44 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,17 @@
//! ```

mod render;
mod slug;

use std::borrow::Borrow;
use std::collections::HashMap;
use std::fmt::Write;
use std::slice::Iter;

use once_cell::sync::Lazy;
pub use pulldown_cmark::HeadingLevel;
use pulldown_cmark::{Event, Options as CmarkOptions, Parser, Tag};
use regex::Regex;

pub use render::{ItemSymbol, Options};
pub use slug::{GitHubSlugifier, Slugify};

/////////////////////////////////////////////////////////////////////////
// Definitions
Expand Down Expand Up @@ -77,15 +76,6 @@ impl Heading<'_> {
}
buf
}

/// Generate an anchor link for this heading.
///
/// This is calculated in the same way that GitHub calculates it.
pub fn anchor(&self) -> String {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^\w\- ]").unwrap());
RE.replace_all(&self.text().to_ascii_lowercase().replace(' ', "-"), "")
.into_owned()
}
}

impl<'a> TableOfContents<'a> {
Expand Down Expand Up @@ -212,34 +202,23 @@ impl<'a> TableOfContents<'a> {
item_symbol,
levels,
indent,
slugifier: mut slugger,
} = options;

// this is to record duplicates
let mut counts = HashMap::new();

let mut buf = String::new();
for heading in self.headings().filter(|h| levels.contains(&h.level())) {
let title = crate::render::to_cmark(heading.events());
let anchor = heading.anchor();
let indent = indent * (heading.level() as usize - *levels.start() as usize);

// make sure the anchor is unique
let i = counts
.entry(anchor.clone())
.and_modify(|i| *i += 1)
.or_insert(0);
let anchor = match *i {
0 => anchor,
i => format!("{}-{}", anchor, i),
};

writeln!(
buf,
"{:indent$}{} [{}](#{})",
"",
item_symbol,
title,
anchor,
slugger.slugify(&heading.text()),
indent = indent,
)
.unwrap();
Expand Down Expand Up @@ -278,25 +257,6 @@ mod tests {
assert_eq!(heading.text(), "Here TOML");
}

#[test]
fn heading_anchor_with_code() {
let heading = Heading {
events: vec![Code(Borrowed("Another")), Text(Borrowed(" heading"))],
level: HeadingLevel::H1,
};
assert_eq!(heading.anchor(), "another-heading");
}

#[test]
fn heading_anchor_with_links() {
let events = Parser::new("Here [TOML](https://toml.io)").collect();
let heading = Heading {
events,
level: HeadingLevel::H1,
};
assert_eq!(heading.anchor(), "here-toml");
}

#[test]
fn toc_new() {
let toc = TableOfContents::new("# Heading\n\n## `Another` heading\n");
Expand Down
11 changes: 11 additions & 0 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use std::ops::RangeInclusive;

use pulldown_cmark::{Event, HeadingLevel, Tag};

use crate::slug::{GitHubSlugifier, Slugify};

/// Which symbol to use when rendering Markdown list items.
pub enum ItemSymbol {
/// `-`
Expand All @@ -32,6 +34,7 @@ pub struct Options {
pub(crate) item_symbol: ItemSymbol,
pub(crate) levels: RangeInclusive<HeadingLevel>,
pub(crate) indent: usize,
pub(crate) slugifier: Box<dyn Slugify>,
}

pub(crate) fn to_cmark<'a, I, E>(events: I) -> String
Expand Down Expand Up @@ -73,6 +76,7 @@ impl Default for Options {
item_symbol: ItemSymbol::Hyphen,
levels: (HeadingLevel::H1..=HeadingLevel::H6),
indent: 2,
slugifier: Box::new(GitHubSlugifier::default()),
}
}
}
Expand All @@ -98,4 +102,11 @@ impl Options {
self.indent = indent;
self
}

/// The slugifier to use for the heading anchors.
#[must_use]
pub fn slugifier(mut self, slugifier: Box<dyn Slugify>) -> Self {
self.slugifier = slugifier;
self
}
}
77 changes: 77 additions & 0 deletions src/slug.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use std::{borrow::Cow, collections::HashMap};

use once_cell::sync::Lazy;
use regex::Regex;

/// A trait to specify the anchor calculation.
pub trait Slugify {
fn slugify<'a>(&mut self, str: &'a str) -> Cow<'a, str>;
}

/// A slugifier that attempts to mimic GitHub's behavior.
///
/// Unfortunately GitHub's behavior is not documented anywhere by GitHub.
/// This should really be part of the [GitHub Flavored Markdown Spec][gfm]
/// but alas it's not. And there also does not appear to be a public issue
/// tracker for the spec where that issue could be raised.
///
/// [gfm]: https://github.github.com/gfm/
#[derive(Default)]
pub struct GitHubSlugifier {
counts: HashMap<String, i32>,
}

impl Slugify for GitHubSlugifier {
fn slugify<'a>(&mut self, str: &'a str) -> Cow<'a, str> {
static RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^\w\- ]").unwrap());
let anchor = RE
.replace_all(&str.to_ascii_lowercase().replace(' ', "-"), "")
.into_owned();

let i = self
.counts
.entry(anchor.clone())
.and_modify(|i| *i += 1)
.or_insert(0);

match *i {
0 => anchor,
i => format!("{}-{}", anchor, i),
}
.into()
}
}

#[cfg(test)]
mod tests {
use crate::slug::{GitHubSlugifier, Slugify};
use crate::Heading;
use pulldown_cmark::CowStr::Borrowed;
use pulldown_cmark::Event::{Code, Text};
use pulldown_cmark::{HeadingLevel, Parser};

#[test]
fn heading_anchor_with_code() {
let heading = Heading {
events: vec![Code(Borrowed("Another")), Text(Borrowed(" heading"))],
level: HeadingLevel::H1,
};
assert_eq!(
GitHubSlugifier::default().slugify(&heading.text()),
"another-heading"
);
}

#[test]
fn heading_anchor_with_links() {
let events = Parser::new("Here [TOML](https://toml.io)").collect();
let heading = Heading {
events,
level: HeadingLevel::H1,
};
assert_eq!(
GitHubSlugifier::default().slugify(&heading.text()),
"here-toml"
);
}
}

0 comments on commit 1cc173f

Please sign in to comment.