text.slugify
Turn a title into a lowercase ASCII URL slug, transliterating common accented Latin letters.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 21 tests, run in TypeScript, Python and Rust.
What it does
The output alphabet is exactly a-z, 0-9 and the hyphen. Runs of anything else collapse to one hyphen, and there is never a leading or trailing one.
Transliteration covers one closed, nameable set: every letter of the Latin-1 supplement (U+00C0 to U+00FF) plus the seven letters Windows-1252 adds (OE, oe, S-caron, s-caron, Z-caron, z-caron, Y-diaeresis). Diacritics are dropped rather than expanded, so a-umlaut becomes a, not ae - the convention every URL slug in the wild follows. The exceptions are the letters that are not accented vowels at all: sharp s becomes ss, ae-ligature becomes ae, oe-ligature becomes oe, thorn becomes th.
For example
slugify(Hello World)→ hello-world an ordinary titleslugify(already-a-slug)→ already-a-slug something already a slug is left aloneslugify( --Héllo, Wörld!-- )→ hello-world punctuation, repeated spaces and stray hyphens all collapse to one hyphen
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn slugify(value: &str) -> String
| value | string | the title or label to slugify |
| returns | string | lowercase a-z, 0-9 and single hyphens; possibly empty |
Your code names it in one line, in the file that uses it
fune!(text.slugify@^1); // then call slugify(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
/// The transliteration table: every letter of the Latin-1 supplement
/// (U+00C0-U+00FF) plus the seven letters Windows-1252 adds.
///
/// Spelled out character by character rather than left to a Unicode
/// normalisation crate, because that is the only way three languages with
/// three different Unicode stacks can be pinned to the same output by one
/// vector - and because a slug should not cost a dependency.
///
/// Diacritics are dropped rather than expanded, so U+00E4 is "a" and not "ae":
/// that is the convention every URL slug in the wild follows. The exceptions
/// are the letters that are not accented vowels at all - sharp s is genuinely
/// two letters, and the ligatures and thorn are letters in their own right.
fn transliterate(ch: char) -> &'static str {
match ch {
'\u{00c0}' | '\u{00c1}' | '\u{00c2}' | '\u{00c3}' | '\u{00c4}' | '\u{00c5}' => "a",
'\u{00c6}' => "ae",
'\u{00c7}' => "c",
'\u{00c8}' | '\u{00c9}' | '\u{00ca}' | '\u{00cb}' => "e",
'\u{00cc}' | '\u{00cd}' | '\u{00ce}' | '\u{00cf}' => "i",
'\u{00d0}' => "d",
'\u{00d1}' => "n",
'\u{00d2}' | '\u{00d3}' | '\u{00d4}' | '\u{00d5}' | '\u{00d6}' | '\u{00d8}' => "o",
'\u{00d9}' | '\u{00da}' | '\u{00db}' | '\u{00dc}' => "u",
'\u{00dd}' => "y",
'\u{00de}' => "th",
'\u{00df}' => "ss",
'\u{00e0}' | '\u{00e1}' | '\u{00e2}' | '\u{00e3}' | '\u{00e4}' | '\u{00e5}' => "a",
'\u{00e6}' => "ae",
'\u{00e7}' => "c",
'\u{00e8}' | '\u{00e9}' | '\u{00ea}' | '\u{00eb}' => "e",
'\u{00ec}' | '\u{00ed}' | '\u{00ee}' | '\u{00ef}' => "i",
'\u{00f0}' => "d",
'\u{00f1}' => "n",
'\u{00f2}' | '\u{00f3}' | '\u{00f4}' | '\u{00f5}' | '\u{00f6}' | '\u{00f8}' => "o",
'\u{00f9}' | '\u{00fa}' | '\u{00fb}' | '\u{00fc}' => "u",
'\u{00fd}' => "y",
'\u{00fe}' => "th",
'\u{00ff}' => "y",
'\u{0152}' | '\u{0153}' => "oe",
'\u{0160}' | '\u{0161}' => "s",
'\u{017d}' | '\u{017e}' => "z",
'\u{0178}' => "y",
_ => "",
}
}
/// A URL-safe slug: lowercase ASCII letters and digits, single hyphens between
/// them, none at either end.
///
/// Anything outside the transliteration table is dropped rather than guessed,
/// so a title written entirely in another script slugifies to "". That empty
/// string is returned, not a panic: the caller knows what its fallback is (an
/// id, a date, a hash) and this function does not.
pub fn slugify(value: &str) -> String {
let mut out = String::with_capacity(value.len());
// A pending separator rather than a trailing hyphen plus a trim: it
// collapses runs and drops the leading and trailing ones in one pass.
let mut pending_separator = false;
// `chars()` yields whole code points, so a character outside the Basic
// Multilingual Plane is dropped once rather than as several separators.
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
if pending_separator {
out.push('-');
pending_separator = false;
}
// Digits are unaffected by ASCII lowercasing, so one branch covers both.
out.push(ch.to_ascii_lowercase());
continue;
}
let piece = transliterate(ch);
if piece.is_empty() {
// Remember that something was skipped, but only emit the hyphen
// once a keeper follows: that is what trims both ends.
pending_separator = !out.is_empty();
continue;
}
if pending_separator {
out.push('-');
pending_separator = false;
}
out.push_str(piece);
}
out
}
/// A slug guaranteed to be non-empty, falling back when nothing survives.
pub fn slugify_or(value: &str, fallback: &str) -> String {
let slug = slugify(value);
if slug.is_empty() {
fallback.to_string()
} else {
slug
}
}
pub fn fune_vector(args: &[Value]) -> Value {
// Refuse what the typed signature cannot hold, with the wording TypeScript
// and Python use, rather than let the conversion below quietly change it.
if !matches!(args[0], Value::Str(_)) {
panic!("slugify needs a string, received {:?}", args[0]);
}
Value::Str(slugify(args[0].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add text.slugify
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./text.slugify-1.0.0-rust.fune, or fetch it from a terminal with fune pull text.slugify@1.0.0:rust.
The whole function, every language, is one file too: text.slugify-1.0.0.fune, 16,855 bytes, sha256 6d01bf54f96ea7650d9b1fb098f56f1fb3119a0164e1d193fb181385a1125b2e. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before text.slugify
after — your function gets the result and the arguments, and returns the final result.
// fune: after text.slugify
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show text.slugify --steps.
// fune: step text.slugify after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| an ordinary title | Hello World | → | hello-world |
| something already a slug is left alone | already-a-slug | → | already-a-slug |
| punctuation, repeated spaces and stray hyphens all collapse to one hyphen | --Héllo, Wörld!-- | → | hello-world |
| accents are dropped, not expanded | Crème Brûlée | → | creme-brulee |
| sharp s is two letters, so it becomes ss | Straße | → | strasse |
| ligatures are letters in their own right | Æther & Œuvre | → | aether-oeuvre |
| thorn and slashed o transliterate | Ølsen Þórsdóttir | → | olsen-thorsdottir |
| the Windows-1252 carons are covered too | Žižek Šuma | → | zizek-suma |
| y with diaeresis, upper and lower | Ünicode ÿ Ÿ | → | unicode-y-y |
| digits survive and brackets do not | Model 3 (2024) | → | model-3-2024 |
Show the other 11 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| underscores are separators like any other non-alphanumeric | snake_case_name | → | snake-case-name |
| a run of hyphens collapses to one | a---b | → | a-b |
| separators at either end are trimmed | -leading and trailing- | → | leading-and-trailing |
| mixed case folds down | MIXED Case ÜBER | → | mixed-case-uber |
| an empty string slugifies to an empty string | → | ||
| punctuation alone leaves nothing | !!!??? | → | |
| characters outside the table are dropped, keeping the Latin ones | 東京 Tokyo | → | tokyo |
| a title in a script the table does not cover slugifies to empty rather than raising | Ελλάδα | → | |
| emoji are dropped without leaving stray hyphens | 🚀 Launch Day 🚀 | → | launch-day |
| a single character is a single character slug | é | → | e |
| a non-string is a caller bug | 42 | → | error: slugify needs a string |
More from the author
Anything outside that set is dropped, not guessed: Greek, Cyrillic, CJK, emoji and every Latin Extended letter beyond the seven above become separators. A title written entirely in one of those scripts slugifies to the empty string, which is returned rather than raised - the caller knows what its fallback is (an id, a date, a hash) and this function does not.
The map is spelled out character by character rather than left to a Unicode normalisation library, because that is the only way three languages with three different Unicode stacks can be pinned to the same output by one test vector.
Files
| Path | Bytes |
|---|---|
| README.md | 1,251 |
| impl/python.py | 3,043 |
| impl/rust.rs | 4,408 |
| impl/typescript.ts | 3,262 |
| vectors.json | 2,312 |