use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
#[must_use]
pub fn slugify(s: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_owned()
}
#[must_use]
pub fn markdown_dialect() -> Options {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
opts
}
#[must_use]
pub fn first_h1(md: &str) -> Option<String> {
let mut text: Option<String> = None;
for event in Parser::new_ext(md, markdown_dialect()) {
match event {
Event::Start(Tag::Heading {
level: HeadingLevel::H1,
..
}) => text = Some(String::new()),
Event::Text(t) | Event::Code(t) => {
if let Some(text) = text.as_mut() {
text.push_str(&t);
}
}
Event::End(TagEnd::Heading(HeadingLevel::H1)) => break,
_ => {}
}
}
text.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
}
#[must_use]
pub fn heading_text(source: &str) -> String {
first_h1(&format!("# {source}")).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{first_h1, heading_text, slugify};
#[test]
fn collapses_punctuation_and_trims() {
assert_eq!(slugify("Install & build"), "install-build");
assert_eq!(
slugify("The five ways to run it"),
"the-five-ways-to-run-it"
);
assert_eq!(slugify(" §2 — Context! "), "2-context");
assert_eq!(
slugify("Cross-repo: a hub and its spokes"),
"cross-repo-a-hub-and-its-spokes"
);
assert_eq!(slugify("!!!"), "");
}
#[test]
fn an_attribute_block_is_markup_not_part_of_the_title() {
let title = first_h1("# The five ways to run it {#modes}\n").expect("an h1");
assert_eq!(title, "The five ways to run it");
assert!(
!title.contains("{#"),
"an attribute block must not survive into a title: {title:?}"
);
}
#[test]
fn both_entry_points_agree_on_where_a_heading_ends() {
for source in [
"The five ways to run it {#modes}",
"Sets like {#1, #2}",
"The `--json` flag",
"See [the docs](x.md)",
"A ~~retracted~~ claim",
"Install & build",
"",
] {
assert_eq!(
first_h1(&format!("# {source}")).unwrap_or_default(),
heading_text(source),
"the two entry points disagreed about {source:?}"
);
}
}
#[test]
fn a_heading_inside_a_fence_is_a_code_sample() {
let md = "```\n# Widget — Technical Implementation Plan\n```\n\n# Real title\n";
assert_eq!(first_h1(md).as_deref(), Some("Real title"));
assert_eq!(
first_h1("```\n# Fenced only\n```\n"),
None,
"a fenced `#` is not a heading at all"
);
}
#[test]
fn a_setext_heading_is_an_h1() {
assert_eq!(
first_h1("Underlined title\n===\n").as_deref(),
Some("Underlined title")
);
}
#[test]
fn an_empty_heading_names_nothing() {
assert_eq!(first_h1("#\n\n# Second\n"), None);
assert_eq!(heading_text(""), "");
}
#[test]
fn heading_text_feeds_slugify_the_text_a_reader_sees() {
assert_eq!(
slugify(&heading_text("1 · Offline mode — the default {#offline}")),
"1-offline-mode-the-default"
);
}
}