每个 CMS、静态站点生成器和 Web 框架都需要一个 slugify 函数,可大多数用的是一条简单的正则,一遇到带重音的标题就出错。本指南先讲一种适用于真实文本的 slugify 正则思路,再给出经过测试的 JavaScript、Python、PHP、Go 和 SQL 版 slugify 函数。它们都会把 Crème Brûlée & Café: 50% Off! 转成 creme-brulee-and-cafe-50-percent-off,既不会弄坏字母,也不会把单词拆开。
Slugify 六步法
- 01先把有意义的符号换成单词,比如
&换成 and,%换成 percent,否则它们会被直接删掉。 - 02手动展开不能分解的字母。
ß、æ、œ、ø、đ、ł没有可拆分的重音符号,要手动映射成ss、ae、oe、o、d、l。 - 03规范化为 NFD 并去掉组合符号。 Unicode NFD 会把
é拆成e加一个重音符号,再用正则\p{M}(或组合符号范围[\u0300-\u036f])去掉重音。 - 04转成小写并删掉撇号,让 don't 变成 dont,而不是 don-t。
- 05用
[^a-z0-9]+把每一段非字母数字字符替换成一个连字符。 空格、标点和 emoji 一次处理完。 - 06去掉首尾的连字符;如果需要长度上限,就在上限之前最后一个连字符处截断,不切断单词。
JavaScript / TypeScript
这个版本用的是 String.prototype.normalize 和 Unicode 属性转义,所有现代浏览器、Node.js、Deno 和 Bun 都支持。CHAR_MAP 覆盖了不能分解的字母,以及西里尔和希腊字母的转写。TypeScript 里也能直接用。
// slugify() generated by Slug Generator — separator: hyphen, lowercase, transliterate, symbols to words
// Works in modern browsers, Node.js 16+, Deno and Bun. No dependencies.
const CHAR_MAP = {"ß":"ss","ẞ":"SS","æ":"ae","Æ":"AE","œ":"oe","Œ":"OE","ø":"o","Ø":"O","đ":"d","Đ":"D","ð":"d","Ð":"D","þ":"th","Þ":"TH","ł":"l","Ł":"L","ı":"i","ħ":"h","Ħ":"H","ŧ":"t","Ŧ":"T","ŀ":"l","Ŀ":"L","ŋ":"n","Ŋ":"N","ſ":"s","ĸ":"k","ƒ":"f","а":"a","А":"A","б":"b","Б":"B","в":"v","В":"V","г":"g","Г":"G","д":"d","Д":"D","е":"e","Е":"E","ё":"yo","Ё":"Yo","ж":"zh","Ж":"Zh","з":"z","З":"Z","и":"i","И":"I","й":"y","Й":"Y","к":"k","К":"K","л":"l","Л":"L","м":"m","М":"M","н":"n","Н":"N","о":"o","О":"O","п":"p","П":"P","р":"r","Р":"R","с":"s","С":"S","т":"t","Т":"T","у":"u","У":"U","ф":"f","Ф":"F","х":"kh","Х":"Kh","ц":"ts","Ц":"Ts","ч":"ch","Ч":"Ch","ш":"sh","Ш":"Sh","щ":"shch","Щ":"Shch","ъ":"","Ъ":"","ы":"y","Ы":"Y","ь":"","Ь":"","э":"e","Э":"E","ю":"yu","Ю":"Yu","я":"ya","Я":"Ya","є":"ye","Є":"Ye","і":"i","І":"I","ї":"yi","Ї":"Yi","ґ":"g","Ґ":"G","ў":"u","Ў":"U","ђ":"dj","Ђ":"Dj","ј":"j","Ј":"J","љ":"lj","Љ":"Lj","њ":"nj","Њ":"Nj","ћ":"c","Ћ":"C","џ":"dz","Џ":"Dz","ѓ":"gj","Ѓ":"Gj","ќ":"kj","Ќ":"Kj","ѕ":"dz","Ѕ":"Dz","α":"a","Α":"A","β":"v","Β":"V","γ":"g","Γ":"G","δ":"d","Δ":"D","ε":"e","Ε":"E","ζ":"z","Ζ":"Z","η":"i","Η":"I","θ":"th","Θ":"Th","ι":"i","Ι":"I","κ":"k","Κ":"K","λ":"l","Λ":"L","μ":"m","Μ":"M","ν":"n","Ν":"N","ξ":"x","Ξ":"X","ο":"o","Ο":"O","π":"p","Π":"P","ρ":"r","Ρ":"R","σ":"s","Σ":"S","ς":"s","τ":"t","Τ":"T","υ":"y","Υ":"Y","φ":"f","Φ":"F","χ":"ch","Χ":"Ch","ψ":"ps","Ψ":"Ps","ω":"o","Ω":"O"};
const SYMBOLS = [["&","and"],["@","at"],["%","percent"],["+","plus"],["€","euro"],["$","dollar"],["£","pound"]];
const STOP_WORDS = new Set([]);
const SEPARATOR = "-";
const MAX_WORDS = 0;
const MAX_CHARS = 0;
function mapChars(s) {
let out = '';
for (const ch of s) out += Object.prototype.hasOwnProperty.call(CHAR_MAP, ch) ? CHAR_MAP[ch] : ch;
return out;
}
function slugify(input) {
let s = String(input ?? '').normalize('NFC');
for (const [k, v] of SYMBOLS) s = s.split(k).join(' ' + v + ' ');
s = s.replace(/[\uFE00-\uFE0F\u200D\u20E3]/gu, '').replace(/['\u2019\u02BC`]/gu, '');
s = mapChars(mapChars(s).normalize('NFD').replace(/\p{M}/gu, ''));
s = s.replace(/[^A-Za-z0-9]+/g, ' ');
let tokens = s.split(' ').filter(Boolean);
tokens = tokens.map((t) => t.toLowerCase());
if (MAX_WORDS > 0) tokens = tokens.slice(0, MAX_WORDS);
if (MAX_CHARS > 0 && tokens.length) {
const out = [tokens[0]];
let len = [...tokens[0]].length;
for (let i = 1; i < tokens.length; i++) {
const add = [...SEPARATOR].length + [...tokens[i]].length;
if (len + add > MAX_CHARS) break; // stop at a word boundary
out.push(tokens[i]);
len += add;
}
tokens = out;
}
return tokens.join(SEPARATOR);
}
// Example: slugify("Crème Brûlée & Café")
// export { slugify }; // uncomment when using ES modulesPython
Python 标准库自带 unicodedata,不用装任何包。这个函数和 JavaScript 版用同一份字符映射和符号表,返回的 slug 完全一样。
# slugify() generated by Slug Generator — separator: hyphen, lowercase, transliterate, symbols to words
# Python 3.8+, standard library only.
import re
import unicodedata
CHAR_MAP = {"ß":"ss","ẞ":"SS","æ":"ae","Æ":"AE","œ":"oe","Œ":"OE","ø":"o","Ø":"O","đ":"d","Đ":"D","ð":"d","Ð":"D","þ":"th","Þ":"TH","ł":"l","Ł":"L","ı":"i","ħ":"h","Ħ":"H","ŧ":"t","Ŧ":"T","ŀ":"l","Ŀ":"L","ŋ":"n","Ŋ":"N","ſ":"s","ĸ":"k","ƒ":"f","а":"a","А":"A","б":"b","Б":"B","в":"v","В":"V","г":"g","Г":"G","д":"d","Д":"D","е":"e","Е":"E","ё":"yo","Ё":"Yo","ж":"zh","Ж":"Zh","з":"z","З":"Z","и":"i","И":"I","й":"y","Й":"Y","к":"k","К":"K","л":"l","Л":"L","м":"m","М":"M","н":"n","Н":"N","о":"o","О":"O","п":"p","П":"P","р":"r","Р":"R","с":"s","С":"S","т":"t","Т":"T","у":"u","У":"U","ф":"f","Ф":"F","х":"kh","Х":"Kh","ц":"ts","Ц":"Ts","ч":"ch","Ч":"Ch","ш":"sh","Ш":"Sh","щ":"shch","Щ":"Shch","ъ":"","Ъ":"","ы":"y","Ы":"Y","ь":"","Ь":"","э":"e","Э":"E","ю":"yu","Ю":"Yu","я":"ya","Я":"Ya","є":"ye","Є":"Ye","і":"i","І":"I","ї":"yi","Ї":"Yi","ґ":"g","Ґ":"G","ў":"u","Ў":"U","ђ":"dj","Ђ":"Dj","ј":"j","Ј":"J","љ":"lj","Љ":"Lj","њ":"nj","Њ":"Nj","ћ":"c","Ћ":"C","џ":"dz","Џ":"Dz","ѓ":"gj","Ѓ":"Gj","ќ":"kj","Ќ":"Kj","ѕ":"dz","Ѕ":"Dz","α":"a","Α":"A","β":"v","Β":"V","γ":"g","Γ":"G","δ":"d","Δ":"D","ε":"e","Ε":"E","ζ":"z","Ζ":"Z","η":"i","Η":"I","θ":"th","Θ":"Th","ι":"i","Ι":"I","κ":"k","Κ":"K","λ":"l","Λ":"L","μ":"m","Μ":"M","ν":"n","Ν":"N","ξ":"x","Ξ":"X","ο":"o","Ο":"O","π":"p","Π":"P","ρ":"r","Ρ":"R","σ":"s","Σ":"S","ς":"s","τ":"t","Τ":"T","υ":"y","Υ":"Y","φ":"f","Φ":"F","χ":"ch","Χ":"Ch","ψ":"ps","Ψ":"Ps","ω":"o","Ω":"O"}
SYMBOLS = [("&", "and"), ("@", "at"), ("%", "percent"), ("+", "plus"), ("€", "euro"), ("$", "dollar"), ("£", "pound")]
STOP_WORDS = set()
SEPARATOR = "-"
MAX_WORDS = 0
MAX_CHARS = 0
def _map_chars(s):
return ''.join(CHAR_MAP.get(ch, ch) for ch in s)
def slugify(text):
s = unicodedata.normalize('NFC', str(text))
for k, v in SYMBOLS:
s = s.replace(k, ' ' + v + ' ')
s = re.sub('[\ufe00-\ufe0f\u200d\u20e3]', '', s)
s = re.sub("['\u2019\u02bc`]", '', s)
s = unicodedata.normalize('NFD', _map_chars(s))
s = _map_chars(''.join(ch for ch in s if not unicodedata.category(ch).startswith('M')))
s = re.sub(r'[^A-Za-z0-9]+', ' ', s)
tokens = [t for t in s.split(' ') if t]
tokens = [t.lower() for t in tokens]
if MAX_WORDS > 0:
tokens = tokens[:MAX_WORDS]
if MAX_CHARS > 0 and tokens:
out, length = [tokens[0]], len(tokens[0])
for t in tokens[1:]:
add = len(SEPARATOR) + len(t)
if length + add > MAX_CHARS:
break # stop at a word boundary
out.append(t)
length += add
tokens = out
return SEPARATOR.join(tokens)
if __name__ == '__main__':
print(slugify("Crème Brûlée & Café"))PHP
PHP 需要 mbstring 扩展来处理多字节小写,需要 intl 扩展来调用 Normalizer::normalize()。大多数 PHP 版本都自带这两个扩展,可以用 php -m 检查。
<?php
// slugify() generated by Slug Generator — separator: hyphen, lowercase, transliterate, symbols to words
// PHP 7.4+ with the mbstring and intl extensions.
function slugify(string $text): string
{
static $charMap = [
'ß' => 'ss', 'ẞ' => 'SS', 'æ' => 'ae', 'Æ' => 'AE', 'œ' => 'oe', 'Œ' => 'OE',
'ø' => 'o', 'Ø' => 'O', 'đ' => 'd', 'Đ' => 'D', 'ð' => 'd', 'Ð' => 'D',
'þ' => 'th', 'Þ' => 'TH', 'ł' => 'l', 'Ł' => 'L', 'ı' => 'i', 'ħ' => 'h',
'Ħ' => 'H', 'ŧ' => 't', 'Ŧ' => 'T', 'ŀ' => 'l', 'Ŀ' => 'L', 'ŋ' => 'n',
'Ŋ' => 'N', 'ſ' => 's', 'ĸ' => 'k', 'ƒ' => 'f', 'а' => 'a', 'А' => 'A',
'б' => 'b', 'Б' => 'B', 'в' => 'v', 'В' => 'V', 'г' => 'g', 'Г' => 'G',
'д' => 'd', 'Д' => 'D', 'е' => 'e', 'Е' => 'E', 'ё' => 'yo', 'Ё' => 'Yo',
'ж' => 'zh', 'Ж' => 'Zh', 'з' => 'z', 'З' => 'Z', 'и' => 'i', 'И' => 'I',
'й' => 'y', 'Й' => 'Y', 'к' => 'k', 'К' => 'K', 'л' => 'l', 'Л' => 'L',
'м' => 'm', 'М' => 'M', 'н' => 'n', 'Н' => 'N', 'о' => 'o', 'О' => 'O',
'п' => 'p', 'П' => 'P', 'р' => 'r', 'Р' => 'R', 'с' => 's', 'С' => 'S',
'т' => 't', 'Т' => 'T', 'у' => 'u', 'У' => 'U', 'ф' => 'f', 'Ф' => 'F',
'х' => 'kh', 'Х' => 'Kh', 'ц' => 'ts', 'Ц' => 'Ts', 'ч' => 'ch', 'Ч' => 'Ch',
'ш' => 'sh', 'Ш' => 'Sh', 'щ' => 'shch', 'Щ' => 'Shch', 'ъ' => '', 'Ъ' => '',
'ы' => 'y', 'Ы' => 'Y', 'ь' => '', 'Ь' => '', 'э' => 'e', 'Э' => 'E',
'ю' => 'yu', 'Ю' => 'Yu', 'я' => 'ya', 'Я' => 'Ya', 'є' => 'ye', 'Є' => 'Ye',
'і' => 'i', 'І' => 'I', 'ї' => 'yi', 'Ї' => 'Yi', 'ґ' => 'g', 'Ґ' => 'G',
'ў' => 'u', 'Ў' => 'U', 'ђ' => 'dj', 'Ђ' => 'Dj', 'ј' => 'j', 'Ј' => 'J',
'љ' => 'lj', 'Љ' => 'Lj', 'њ' => 'nj', 'Њ' => 'Nj', 'ћ' => 'c', 'Ћ' => 'C',
'џ' => 'dz', 'Џ' => 'Dz', 'ѓ' => 'gj', 'Ѓ' => 'Gj', 'ќ' => 'kj', 'Ќ' => 'Kj',
'ѕ' => 'dz', 'Ѕ' => 'Dz', 'α' => 'a', 'Α' => 'A', 'β' => 'v', 'Β' => 'V',
'γ' => 'g', 'Γ' => 'G', 'δ' => 'd', 'Δ' => 'D', 'ε' => 'e', 'Ε' => 'E',
'ζ' => 'z', 'Ζ' => 'Z', 'η' => 'i', 'Η' => 'I', 'θ' => 'th', 'Θ' => 'Th',
'ι' => 'i', 'Ι' => 'I', 'κ' => 'k', 'Κ' => 'K', 'λ' => 'l', 'Λ' => 'L',
'μ' => 'm', 'Μ' => 'M', 'ν' => 'n', 'Ν' => 'N', 'ξ' => 'x', 'Ξ' => 'X',
'ο' => 'o', 'Ο' => 'O', 'π' => 'p', 'Π' => 'P', 'ρ' => 'r', 'Ρ' => 'R',
'σ' => 's', 'Σ' => 'S', 'ς' => 's', 'τ' => 't', 'Τ' => 'T', 'υ' => 'y',
'Υ' => 'Y', 'φ' => 'f', 'Φ' => 'F', 'χ' => 'ch', 'Χ' => 'Ch', 'ψ' => 'ps',
'Ψ' => 'Ps', 'ω' => 'o', 'Ω' => 'O',
];
$symbols = [['&', 'and'], ['@', 'at'], ['%', 'percent'], ['+', 'plus'], ['€', 'euro'], ['$', 'dollar'], ['£', 'pound']];
$stopWords = [];
$separator = '-';
$maxWords = 0;
$maxChars = 0;
if (!class_exists('Normalizer')) {
throw new RuntimeException('slugify() needs the PHP intl extension (Normalizer).');
}
$s = Normalizer::normalize($text, Normalizer::FORM_C);
foreach ($symbols as [$k, $v]) {
$s = str_replace($k, ' ' . $v . ' ', $s);
}
$s = preg_replace('/[\x{FE00}-\x{FE0F}\x{200D}\x{20E3}]/u', '', $s);
$s = preg_replace('/[\'\x{2019}\x{02BC}`]/u', '', $s);
$s = Normalizer::normalize(strtr($s, $charMap), Normalizer::FORM_D);
$s = strtr(preg_replace('/\p{M}/u', '', $s), $charMap);
$s = preg_replace('/[^A-Za-z0-9]+/', ' ', $s);
$tokens = array_values(array_filter(explode(' ', $s), 'strlen'));
$tokens = array_map(function ($t) { return mb_strtolower($t, 'UTF-8'); }, $tokens);
if ($maxWords > 0) {
$tokens = array_slice($tokens, 0, $maxWords);
}
if ($maxChars > 0 && $tokens) {
$out = [$tokens[0]];
$len = mb_strlen($tokens[0], 'UTF-8');
for ($i = 1; $i < count($tokens); $i++) {
$add = mb_strlen($separator, 'UTF-8') + mb_strlen($tokens[$i], 'UTF-8');
if ($len + $add > $maxChars) {
break; // stop at a word boundary
}
$out[] = $tokens[$i];
$len += $add;
}
$tokens = $out;
}
return implode($separator, $tokens);
}
// echo slugify('Crème Brûlée & Café');Go
Go 标准库没有 Unicode 规范化工具,所以这个函数用了 golang.org/x/text 模块(go get golang.org/x/text)。它和 JavaScript 版一样使用字符映射、符号转单词、NFD 去重音和按单词截断,返回的 slug 完全一样。
// Package slug: Slugify() generated by Slug Generator — separator: hyphen, lowercase, transliterate, symbols to words
// Go 1.18+. Needs golang.org/x/text for Unicode normalization:
// go get golang.org/x/text
package slug
import (
"regexp"
"strings"
"unicode/utf8"
"golang.org/x/text/unicode/norm"
)
var charMap = map[rune]string{
'ß': "ss", 'ẞ': "SS", 'æ': "ae", 'Æ': "AE", 'œ': "oe", 'Œ': "OE",
'ø': "o", 'Ø': "O", 'đ': "d", 'Đ': "D", 'ð': "d", 'Ð': "D",
'þ': "th", 'Þ': "TH", 'ł': "l", 'Ł': "L", 'ı': "i", 'ħ': "h",
'Ħ': "H", 'ŧ': "t", 'Ŧ': "T", 'ŀ': "l", 'Ŀ': "L", 'ŋ': "n",
'Ŋ': "N", 'ſ': "s", 'ĸ': "k", 'ƒ': "f", 'а': "a", 'А': "A",
'б': "b", 'Б': "B", 'в': "v", 'В': "V", 'г': "g", 'Г': "G",
'д': "d", 'Д': "D", 'е': "e", 'Е': "E", 'ё': "yo", 'Ё': "Yo",
'ж': "zh", 'Ж': "Zh", 'з': "z", 'З': "Z", 'и': "i", 'И': "I",
'й': "y", 'Й': "Y", 'к': "k", 'К': "K", 'л': "l", 'Л': "L",
'м': "m", 'М': "M", 'н': "n", 'Н': "N", 'о': "o", 'О': "O",
'п': "p", 'П': "P", 'р': "r", 'Р': "R", 'с': "s", 'С': "S",
'т': "t", 'Т': "T", 'у': "u", 'У': "U", 'ф': "f", 'Ф': "F",
'х': "kh", 'Х': "Kh", 'ц': "ts", 'Ц': "Ts", 'ч': "ch", 'Ч': "Ch",
'ш': "sh", 'Ш': "Sh", 'щ': "shch", 'Щ': "Shch", 'ъ': "", 'Ъ': "",
'ы': "y", 'Ы': "Y", 'ь': "", 'Ь': "", 'э': "e", 'Э': "E",
'ю': "yu", 'Ю': "Yu", 'я': "ya", 'Я': "Ya", 'є': "ye", 'Є': "Ye",
'і': "i", 'І': "I", 'ї': "yi", 'Ї': "Yi", 'ґ': "g", 'Ґ': "G",
'ў': "u", 'Ў': "U", 'ђ': "dj", 'Ђ': "Dj", 'ј': "j", 'Ј': "J",
'љ': "lj", 'Љ': "Lj", 'њ': "nj", 'Њ': "Nj", 'ћ': "c", 'Ћ': "C",
'џ': "dz", 'Џ': "Dz", 'ѓ': "gj", 'Ѓ': "Gj", 'ќ': "kj", 'Ќ': "Kj",
'ѕ': "dz", 'Ѕ': "Dz", 'α': "a", 'Α': "A", 'β': "v", 'Β': "V",
'γ': "g", 'Γ': "G", 'δ': "d", 'Δ': "D", 'ε': "e", 'Ε': "E",
'ζ': "z", 'Ζ': "Z", 'η': "i", 'Η': "I", 'θ': "th", 'Θ': "Th",
'ι': "i", 'Ι': "I", 'κ': "k", 'Κ': "K", 'λ': "l", 'Λ': "L",
'μ': "m", 'Μ': "M", 'ν': "n", 'Ν': "N", 'ξ': "x", 'Ξ': "X",
'ο': "o", 'Ο': "O", 'π': "p", 'Π': "P", 'ρ': "r", 'Ρ': "R",
'σ': "s", 'Σ': "S", 'ς': "s", 'τ': "t", 'Τ': "T", 'υ': "y",
'Υ': "Y", 'φ': "f", 'Φ': "F", 'χ': "ch", 'Χ': "Ch", 'ψ': "ps",
'Ψ': "Ps", 'ω': "o", 'Ω': "O",
}
// Replaced in this order (longest symbol first).
var symbols = [][2]string{{"&", "and"}, {"@", "at"}, {"%", "percent"}, {"+", "plus"}, {"€", "euro"}, {"$", "dollar"}, {"£", "pound"}}
var stopWords = map[string]bool{}
const (
separator = "-"
maxWords = 0
maxChars = 0
)
var (
reJoiners = regexp.MustCompile(`[\x{FE00}-\x{FE0F}\x{200D}\x{20E3}]`)
reApostrophe = regexp.MustCompile("['\u2019\u02BC`]")
reMarks = regexp.MustCompile(`\p{M}`)
reNonAlnum = regexp.MustCompile(`[^A-Za-z0-9]+`)
)
func mapChars(s string) string {
var b strings.Builder
for _, r := range s {
if v, ok := charMap[r]; ok {
b.WriteString(v)
} else {
b.WriteRune(r)
}
}
return b.String()
}
// Slugify turns a title into a URL slug.
func Slugify(input string) string {
s := norm.NFC.String(input)
for _, kv := range symbols {
s = strings.ReplaceAll(s, kv[0], " "+kv[1]+" ")
}
s = reApostrophe.ReplaceAllString(reJoiners.ReplaceAllString(s, ""), "")
s = mapChars(reMarks.ReplaceAllString(norm.NFD.String(mapChars(s)), ""))
s = reNonAlnum.ReplaceAllString(s, " ")
var tokens []string
for _, t := range strings.Split(s, " ") {
t = strings.ToLower(t)
if t != "" {
tokens = append(tokens, t)
}
}
if maxWords > 0 && len(tokens) > maxWords {
tokens = tokens[:maxWords]
}
if maxChars > 0 && len(tokens) > 0 {
out := []string{tokens[0]}
n := utf8.RuneCountInString(tokens[0])
for _, t := range tokens[1:] {
add := utf8.RuneCountInString(separator) + utf8.RuneCountInString(t)
if n+add > maxChars {
break // stop at a word boundary
}
out = append(out, t)
n += add
}
tokens = out
}
return strings.Join(tokens, separator)
}
// Example: slug.Slugify("Crème Brûlée & Café")SQL(PostgreSQL)
这个 PL/pgSQL 函数需要 PostgreSQL 13 或更高版本(因为要用 normalize())和 UTF-8 数据库,不需要任何扩展。它和 JavaScript 版用同一份字符映射和符号词,并且把 Unicode 组合符号范围逐一列出,因为 PostgreSQL 的正则不支持 \p{M}。函数标记为 IMMUTABLE,所以可以用在索引或生成列里。
-- slugify() generated by Slug Generator — separator: hyphen, lowercase, transliterate, symbols to words
-- PostgreSQL 13+ with a UTF-8 database. No extensions needed.
-- Usage: SELECT slugify(title) FROM posts;
CREATE OR REPLACE FUNCTION slugify(input text)
RETURNS text
LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE
AS $slugify$
DECLARE
char_map jsonb := '{"ß":"ss","ẞ":"SS","æ":"ae","Æ":"AE","œ":"oe","Œ":"OE","ø":"o","Ø":"O","đ":"d","Đ":"D","ð":"d","Ð":"D","þ":"th","Þ":"TH","ł":"l","Ł":"L","ı":"i","ħ":"h","Ħ":"H","ŧ":"t","Ŧ":"T","ŀ":"l","Ŀ":"L","ŋ":"n","Ŋ":"N","ſ":"s","ĸ":"k","ƒ":"f","а":"a","А":"A","б":"b","Б":"B","в":"v","В":"V","г":"g","Г":"G","д":"d","Д":"D","е":"e","Е":"E","ё":"yo","Ё":"Yo","ж":"zh","Ж":"Zh","з":"z","З":"Z","и":"i","И":"I","й":"y","Й":"Y","к":"k","К":"K","л":"l","Л":"L","м":"m","М":"M","н":"n","Н":"N","о":"o","О":"O","п":"p","П":"P","р":"r","Р":"R","с":"s","С":"S","т":"t","Т":"T","у":"u","У":"U","ф":"f","Ф":"F","х":"kh","Х":"Kh","ц":"ts","Ц":"Ts","ч":"ch","Ч":"Ch","ш":"sh","Ш":"Sh","щ":"shch","Щ":"Shch","ъ":"","Ъ":"","ы":"y","Ы":"Y","ь":"","Ь":"","э":"e","Э":"E","ю":"yu","Ю":"Yu","я":"ya","Я":"Ya","є":"ye","Є":"Ye","і":"i","І":"I","ї":"yi","Ї":"Yi","ґ":"g","Ґ":"G","ў":"u","Ў":"U","ђ":"dj","Ђ":"Dj","ј":"j","Ј":"J","љ":"lj","Љ":"Lj","њ":"nj","Њ":"Nj","ћ":"c","Ћ":"C","џ":"dz","Џ":"Dz","ѓ":"gj","Ѓ":"Gj","ќ":"kj","Ќ":"Kj","ѕ":"dz","Ѕ":"Dz","α":"a","Α":"A","β":"v","Β":"V","γ":"g","Γ":"G","δ":"d","Δ":"D","ε":"e","Ε":"E","ζ":"z","Ζ":"Z","η":"i","Η":"I","θ":"th","Θ":"Th","ι":"i","Ι":"I","κ":"k","Κ":"K","λ":"l","Λ":"L","μ":"m","Μ":"M","ν":"n","Ν":"N","ξ":"x","Ξ":"X","ο":"o","Ο":"O","π":"p","Π":"P","ρ":"r","Ρ":"R","σ":"s","Σ":"S","ς":"s","τ":"t","Τ":"T","υ":"y","Υ":"Y","φ":"f","Φ":"F","χ":"ch","Χ":"Ch","ψ":"ps","Ψ":"Ps","ω":"o","Ω":"O"}';
marks text := '\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1-\u05C2\u05C4-\u05C5\u05C7\u0610-\u061A'
|| '\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7-\u06E8\u06EA-\u06ED\u0711\u0730-\u074A'
|| '\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D'
|| '\u0859-\u085B\u0897-\u089F\u08CA-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957'
|| '\u0962-\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7-\u09C8\u09CB-\u09CD\u09D7\u09E2-\u09E3\u09FE'
|| '\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47-\u0A48\u0A4B-\u0A4D\u0A51\u0A70-\u0A71\u0A75\u0A81-\u0A83'
|| '\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2-\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C'
|| '\u0B3E-\u0B44\u0B47-\u0B48\u0B4B-\u0B4D\u0B55-\u0B57\u0B62-\u0B63\u0B82\u0BBE-\u0BC2'
|| '\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3C\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D'
|| '\u0C55-\u0C56\u0C62-\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD'
|| '\u0CD5-\u0CD6\u0CE2-\u0CE3\u0CF3\u0D00-\u0D03\u0D3B-\u0D3C\u0D3E-\u0D44\u0D46-\u0D48'
|| '\u0D4A-\u0D4D\u0D57\u0D62-\u0D63\u0D81-\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2-\u0DF3'
|| '\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EBC\u0EC8-\u0ECE\u0F18-\u0F19\u0F35\u0F37\u0F39'
|| '\u0F3E-\u0F3F\u0F71-\u0F84\u0F86-\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E'
|| '\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F'
|| '\u109A-\u109D\u135D-\u135F\u1712-\u1715\u1732-\u1734\u1752-\u1753\u1772-\u1773\u17B4-\u17D3'
|| '\u17DD\u180B-\u180D\u180F\u1885-\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E'
|| '\u1A60-\u1A7C\u1A7F\u1AB0-\u1ACE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82'
|| '\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF4\u1CF7-\u1CF9'
|| '\u1DC0-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099-\u309A'
|| '\uA66F-\uA672\uA674-\uA67D\uA69E-\uA69F\uA6F0-\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA82C'
|| '\uA880-\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983'
|| '\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C-\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7-\uAAB8'
|| '\uAABE-\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5-\uAAF6\uABE3-\uABEA\uABEC-\uABED\uFB1E\uFE00-\uFE0F'
|| '\uFE20-\uFE2F\U000101FD\U000102E0\U00010376-\U0001037A\U00010A01-\U00010A03\U00010A05-\U00010A06'
|| '\U00010A0C-\U00010A0F\U00010A38-\U00010A3A\U00010A3F\U00010AE5-\U00010AE6\U00010D24-\U00010D27'
|| '\U00010D69-\U00010D6D\U00010EAB-\U00010EAC\U00010EFC-\U00010EFF\U00010F46-\U00010F50'
|| '\U00010F82-\U00010F85\U00011000-\U00011002\U00011038-\U00011046\U00011070\U00011073-\U00011074'
|| '\U0001107F-\U00011082\U000110B0-\U000110BA\U000110C2\U00011100-\U00011102\U00011127-\U00011134'
|| '\U00011145-\U00011146\U00011173\U00011180-\U00011182\U000111B3-\U000111C0\U000111C9-\U000111CC'
|| '\U000111CE-\U000111CF\U0001122C-\U00011237\U0001123E\U00011241\U000112DF-\U000112EA'
|| '\U00011300-\U00011303\U0001133B-\U0001133C\U0001133E-\U00011344\U00011347-\U00011348'
|| '\U0001134B-\U0001134D\U00011357\U00011362-\U00011363\U00011366-\U0001136C\U00011370-\U00011374'
|| '\U000113B8-\U000113C0\U000113C2\U000113C5\U000113C7-\U000113CA\U000113CC-\U000113D0\U000113D2'
|| '\U000113E1-\U000113E2\U00011435-\U00011446\U0001145E\U000114B0-\U000114C3\U000115AF-\U000115B5'
|| '\U000115B8-\U000115C0\U000115DC-\U000115DD\U00011630-\U00011640\U000116AB-\U000116B7'
|| '\U0001171D-\U0001172B\U0001182C-\U0001183A\U00011930-\U00011935\U00011937-\U00011938'
|| '\U0001193B-\U0001193E\U00011940\U00011942-\U00011943\U000119D1-\U000119D7\U000119DA-\U000119E0'
|| '\U000119E4\U00011A01-\U00011A0A\U00011A33-\U00011A39\U00011A3B-\U00011A3E\U00011A47'
|| '\U00011A51-\U00011A5B\U00011A8A-\U00011A99\U00011C2F-\U00011C36\U00011C38-\U00011C3F'
|| '\U00011C92-\U00011CA7\U00011CA9-\U00011CB6\U00011D31-\U00011D36\U00011D3A\U00011D3C-\U00011D3D'
|| '\U00011D3F-\U00011D45\U00011D47\U00011D8A-\U00011D8E\U00011D90-\U00011D91\U00011D93-\U00011D97'
|| '\U00011EF3-\U00011EF6\U00011F00-\U00011F01\U00011F03\U00011F34-\U00011F3A\U00011F3E-\U00011F42'
|| '\U00011F5A\U00013440\U00013447-\U00013455\U0001611E-\U0001612F\U00016AF0-\U00016AF4'
|| '\U00016B30-\U00016B36\U00016F4F\U00016F51-\U00016F87\U00016F8F-\U00016F92\U00016FE4'
|| '\U00016FF0-\U00016FF1\U0001BC9D-\U0001BC9E\U0001CF00-\U0001CF2D\U0001CF30-\U0001CF46'
|| '\U0001D165-\U0001D169\U0001D16D-\U0001D172\U0001D17B-\U0001D182\U0001D185-\U0001D18B'
|| '\U0001D1AA-\U0001D1AD\U0001D242-\U0001D244\U0001DA00-\U0001DA36\U0001DA3B-\U0001DA6C\U0001DA75'
|| '\U0001DA84\U0001DA9B-\U0001DA9F\U0001DAA1-\U0001DAAF\U0001E000-\U0001E006\U0001E008-\U0001E018'
|| '\U0001E01B-\U0001E021\U0001E023-\U0001E024\U0001E026-\U0001E02A\U0001E08F\U0001E130-\U0001E136'
|| '\U0001E2AE\U0001E2EC-\U0001E2EF\U0001E4EC-\U0001E4EF\U0001E5EE-\U0001E5EF\U0001E8D0-\U0001E8D6'
|| '\U0001E944-\U0001E94A\U000E0100-\U000E01EF';
stop_words text[] := ARRAY[]::text[];
separator text := '-';
max_words int := 0;
max_chars int := 0;
s text;
tokens text[];
kept text[];
len int;
BEGIN
s := normalize(input, NFC);
s := replace(s, '&', ' and ');
s := replace(s, '@', ' at ');
s := replace(s, '%', ' percent ');
s := replace(s, '+', ' plus ');
s := replace(s, '€', ' euro ');
s := replace(s, '$', ' dollar ');
s := replace(s, '£', ' pound ');
s := regexp_replace(s, '[\uFE00-\uFE0F\u200D\u20E3]', '', 'g');
s := regexp_replace(s, '[''\u2019\u02BC`]', '', 'g');
SELECT coalesce(string_agg(coalesce(char_map ->> c, c), '' ORDER BY n), '') INTO s
FROM regexp_split_to_table(s, '') WITH ORDINALITY AS x(c, n);
s := regexp_replace(normalize(s, NFD), '[' || marks || ']', '', 'g');
SELECT coalesce(string_agg(coalesce(char_map ->> c, c), '' ORDER BY n), '') INTO s
FROM regexp_split_to_table(s, '') WITH ORDINALITY AS x(c, n);
s := regexp_replace(s, '[^A-Za-z0-9]+', ' ', 'g');
tokens := array_remove(string_to_array(s, ' '), '');
tokens := ARRAY(SELECT lower(t) FROM unnest(tokens) WITH ORDINALITY AS u(t, n) ORDER BY n);
IF max_words > 0 THEN
tokens := tokens[1:max_words];
END IF;
IF max_chars > 0 AND cardinality(tokens) > 0 THEN
kept := ARRAY[tokens[1]];
len := char_length(tokens[1]);
FOR i IN 2..cardinality(tokens) LOOP
EXIT WHEN len + char_length(separator) + char_length(tokens[i]) > max_chars; -- word boundary
kept := kept || tokens[i];
len := len + char_length(separator) + char_length(tokens[i]);
END LOOP;
tokens := kept;
END IF;
RETURN coalesce(array_to_string(tokens, separator), '');
END;
$slugify$;
-- SELECT slugify('Crème Brûlée & Café');注意
常见坑
- 跳过规范化。 只用
[^a-z0-9]+这样的正则,café会变成caf,字母悄悄丢了。 - 忘了不能分解的字母。 NFD 不会改变
ß或ø,不先映射就会直接消失。去除重音符号工具 用的是同一张对照表,写代码前可以先拿一批人名试试。 - 把撇号变成连字符。 没有单独规则的话,don't 会变成
don-t。在主正则之前删掉撇号,包括弯引号’。 - emoji 和符号。 emoji 应该去掉,但
&、%这类符号往往有意义,要先换成单词。 - 在单词中间截断。
slice(0, 50)可能留下半个单词,应该在上限之前最后一个分隔符处截断。 - 以为 slug 一定唯一。 两个标题可能生成同一个 slug。在数据库里加唯一索引,slug 被占用时追加
-2、-3。如果是现有表格或网站迁移,批量 CSV Slug 生成器 会自动处理重复。
按你的规则生成代码
上面的函数用的是一套固定规则。如果你需要不同的设置,比如下划线、德语变音(ä→ae)、西里尔和希腊字母转写、自定义停用词或词数上限,可以在 URL Slug 生成器 里设置好,再打开「获取代码片段」面板。它会生成内置你当前设置的 JavaScript、Python、PHP、Go 或 PostgreSQL slugify 函数,对每个输入都返回和工具完全相同的 slug。想了解什么是好的 slug,请阅读什么是 URL slug。
FAQ
常见问题
为什么我的正则把 café 变成了 caf?
[^a-z0-9]+ 在 é 变成 e 之前就把它删掉了。要先规范化为 NFD,再去掉组合符号。
各语言的代码片段返回的 slug 一样吗?
一样。它们用同一份字符映射和符号词,本站的测试会用同一批输入运行 JavaScript、Python、PHP、Go 和 SQL 版本,并对比输出。
TypeScript 要改代码吗?
不用。JavaScript 函数可以直接在 TypeScript 里用。
代码片段能把中文转成拼音吗?
不能。「中文转拼音」只在本站工具里生效,它需要一份拼音词典,代码片段不带。本页的函数用的是默认设置(开启「智能音译」),会删掉汉字,纯中文标题会得到空 slug。需要拼音 slug 时,可以在 URL Slug 生成器 或 批量 CSV 工具 里转换后直接复制结果。如需保留中文,请在 URL Slug 生成器 里关闭「智能音译」(即保留 Unicode),再从「获取代码片段」面板复制代码:这样生成的 JavaScript/TypeScript、Python、PHP、Go 和 SQL 版本都会保留汉字,只把空格和标点换成连字符(如「如何生成 URL Slug」→ 如何生成-url-slug)。
