1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
//! URL-safe slug generation (issue #1260).
//!
//! `slugify` is the single shared implementation behind the scaffold
//! generator's `slug:slug{from:...}` DSL token (autumn-cli) and any
//! hand-written app that wants the same "human-readable, shareable" URL
//! segment behavior — replacing the two near-identical hand-rolled copies
//! that used to live in `examples/wiki` and `examples/reddit-clone`.
use unicode_normalization::UnicodeNormalization;
/// The Unicode "Combining Diacritical Marks" block. NFD-decomposing an
/// accented Latin letter (e.g. `é`) splits it into a base letter (`e`) plus
/// one of these combining marks (`´`); dropping characters in this range
/// after decomposition is what gives `slugify` its best-effort ASCII folding
/// ("café" -> "cafe") without pulling in a full transliteration table.
const COMBINING_MARKS: std::ops::RangeInclusive<u32> = 0x0300..=0x036F;
/// Convert `input` into a URL-safe slug.
///
/// - Lowercases ASCII letters.
/// - Best-effort ASCII-folds accented Latin characters via NFD decomposition
/// (`"café"` -> `"cafe"`, `"Zürich"` -> `"zurich"`). Non-Latin scripts (CJK,
/// Cyrillic, emoji, ...) have no ASCII fold and are treated as separators —
/// full transliteration is out of scope.
/// - Every other character (punctuation, whitespace, symbols, un-folded
/// non-ASCII) is treated as a separator.
/// - Runs of separators collapse to a single `-`; leading and trailing `-`
/// are trimmed.
///
/// If the result would be empty (input is empty, or entirely punctuation /
/// un-folded non-Latin text), returns a stable, non-empty fallback token
/// deterministically derived from `input` — the same input always slugifies
/// to the same fallback, so callers can rely on `slugify` never returning an
/// empty string.
#[must_use]
pub fn slugify(input: &str) -> String {
let mut slug = String::with_capacity(input.len());
let mut pending_separator = false;
for c in input.nfd() {
if COMBINING_MARKS.contains(&u32::from(c)) {
continue;
}
if c.is_ascii_alphanumeric() {
if pending_separator && !slug.is_empty() {
slug.push('-');
}
slug.push(c.to_ascii_lowercase());
pending_separator = false;
} else {
pending_separator = true;
}
}
if slug.is_empty() {
fallback_token(input)
} else {
slug
}
}
/// A stable, non-empty fallback for input that slugifies to nothing. Derived
/// from a plain FNV-1a hash of the raw input bytes (not the language's
/// `DefaultHasher`, whose algorithm is explicitly unstable across Rust
/// versions) so the same input always produces the same fallback token.
fn fallback_token(input: &str) -> String {
format!("n{:x}", fnv1a64(input.as_bytes()))
}
/// 64-bit FNV-1a — small, dependency-free, and deterministic.
fn fnv1a64(bytes: &[u8]) -> u64 {
const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET_BASIS;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(PRIME);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_title() {
assert_eq!(slugify("Hello World"), "hello-world");
}
#[test]
fn punctuation_becomes_separator() {
assert_eq!(slugify("Rust & WebAssembly!"), "rust-webassembly");
}
#[test]
fn already_a_slug_is_unchanged() {
assert_eq!(slugify("already-a-slug"), "already-a-slug");
}
#[test]
fn leading_trailing_and_duplicate_separators_collapse() {
assert_eq!(slugify(" --Hello---World-- "), "hello-world");
}
#[test]
fn apostrophe_is_a_separator() {
assert_eq!(slugify("What's new?"), "what-s-new");
}
#[test]
fn numbers_are_kept() {
assert_eq!(slugify("Post 123"), "post-123");
}
#[test]
fn uppercase_is_lowered() {
assert_eq!(slugify("HERO"), "hero");
}
#[test]
fn unicode_accented_latin_is_ascii_folded() {
assert_eq!(slugify("Café Münchën"), "cafe-munchen");
assert_eq!(slugify("Zürich"), "zurich");
assert_eq!(slugify("naïve"), "naive");
}
#[test]
fn unicode_non_latin_acts_as_separator() {
// No ASCII fold exists for CJK, so it's dropped like punctuation —
// the surrounding ASCII words still join with a single separator.
assert_eq!(slugify("Data 表 Table"), "data-table");
}
#[test]
fn empty_input_falls_back_to_a_stable_non_empty_token() {
let a = slugify("");
assert!(!a.is_empty());
assert_eq!(a, slugify(""), "fallback must be deterministic");
}
#[test]
fn empty_after_strip_falls_back_to_a_stable_non_empty_token() {
let a = slugify("!!!");
assert!(!a.is_empty());
assert_eq!(a, slugify("!!!"), "fallback must be deterministic");
}
#[test]
fn distinct_empty_after_strip_inputs_get_distinct_fallbacks() {
// Not required for correctness (uniqueness is enforced at the DB
// layer), but a hash-derived fallback avoids needless collisions.
assert_ne!(slugify("!!!"), slugify("???"));
assert_ne!(slugify(""), slugify("!!!"));
}
}