每个 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 六步法

  1. 01先把有意义的符号换成单词,比如 & 换成 and,% 换成 percent,否则它们会被直接删掉。
  2. 02手动展开不能分解的字母。 ß、æ、œ、ø、đ、ł 没有可拆分的重音符号,要手动映射成 ss、ae、oe、o、d、l。
  3. 03规范化为 NFD 并去掉组合符号。 Unicode NFD 会把 é 拆成 e 加一个重音符号,再用正则 \p{M}(或组合符号范围 [\u0300-\u036f])去掉重音。
  4. 04转成小写并删掉撇号,让 don't 变成 dont,而不是 don-t。
  5. 05用 [^a-z0-9]+ 把每一段非字母数字字符替换成一个连字符。 空格、标点和 emoji 一次处理完。
  6. 06去掉首尾的连字符;如果需要长度上限,就在上限之前最后一个连字符处截断,不切断单词。

JavaScript / TypeScript

这个版本用的是 String.prototype.normalize 和 Unicode 属性转义,所有现代浏览器、Node.js、Deno 和 Bun 都支持。CHAR_MAP 覆盖了不能分解的字母,以及西里尔和希腊字母的转写。TypeScript 里也能直接用。

slugify.jsJavaScript / 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 modules

Python

Python 标准库自带 unicodedata,不用装任何包。这个函数和 JavaScript 版用同一份字符映射和符号表,返回的 slug 完全一样。

slugify.pyPython
# 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 检查。

slugify.phpPHP
<?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 完全一样。

slugify.goGo
// 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.sqlPostgreSQL
-- 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)。