pub const MAX_SLUG_LEN: usize = 60;
pub const FALLBACK_SLUG: &str = "untitled";
#[must_use]
pub fn slugify(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut last_was_dash = false;
for ch in input.chars() {
let mapped = ascii_fold_lower(ch);
for c in mapped.chars() {
if c.is_ascii_alphanumeric() {
out.push(c);
last_was_dash = false;
} else if !last_was_dash {
out.push('-');
last_was_dash = true;
}
}
}
while out.starts_with('-') {
out.remove(0);
}
while out.ends_with('-') {
out.pop();
}
if out.len() > MAX_SLUG_LEN {
out.truncate(MAX_SLUG_LEN);
while out.ends_with('-') {
out.pop();
}
}
if out.is_empty() {
FALLBACK_SLUG.to_string()
} else {
out
}
}
fn ascii_fold_lower(ch: char) -> String {
match ch {
'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' | 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => {
"a".into()
}
'Æ' | 'æ' => "ae".into(),
'Ç' | 'ç' => "c".into(),
'È' | 'É' | 'Ê' | 'Ë' | 'è' | 'é' | 'ê' | 'ë' => "e".into(),
'Ì' | 'Í' | 'Î' | 'Ï' | 'ì' | 'í' | 'î' | 'ï' => "i".into(),
'Ñ' | 'ñ' => "n".into(),
'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' | 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => {
"o".into()
}
'Œ' | 'œ' => "oe".into(),
'ß' => "ss".into(),
'Ù' | 'Ú' | 'Û' | 'Ü' | 'ù' | 'ú' | 'û' | 'ü' => "u".into(),
'Ý' | 'ý' | 'ÿ' => "y".into(),
c if c.is_ascii() => c.to_ascii_lowercase().to_string(),
c => c.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_question_slugifies_lowercase_kebab() {
assert_eq!(
slugify("What is the role of attention?"),
"what-is-the-role-of-attention"
);
}
#[test]
fn punctuation_is_dashed_and_collapsed() {
assert_eq!(
slugify("Learning & reasoning, in LLMs!"),
"learning-reasoning-in-llms"
);
}
#[test]
fn diacritics_fold_to_ascii() {
assert_eq!(slugify("Über résumé"), "uber-resume");
assert_eq!(slugify("Crème brûlée"), "creme-brulee");
assert_eq!(slugify("naïve façade"), "naive-facade");
}
#[test]
fn all_non_alphanumeric_returns_fallback() {
assert_eq!(slugify("???"), FALLBACK_SLUG);
assert_eq!(slugify(" "), FALLBACK_SLUG);
assert_eq!(slugify("--- ---"), FALLBACK_SLUG);
}
#[test]
fn empty_string_returns_fallback() {
assert_eq!(slugify(""), FALLBACK_SLUG);
}
#[test]
fn length_is_capped_without_trailing_dash() {
let long = "a ".repeat(80); let out = slugify(&long);
assert!(out.len() <= MAX_SLUG_LEN, "len = {}", out.len());
assert!(!out.ends_with('-'), "got: {out}");
}
#[test]
fn cjk_only_returns_fallback() {
assert_eq!(slugify("日本語"), FALLBACK_SLUG);
}
#[test]
fn leading_and_trailing_punctuation_trimmed() {
assert_eq!(slugify("--hello world!!"), "hello-world");
}
#[test]
fn numbers_are_preserved() {
assert_eq!(slugify("GPT-4 vs Claude 3.5"), "gpt-4-vs-claude-3-5");
}
}