スラッグ生成でガイドがカ-イトになる理由 濁点とNFDの落とし穴
記事や商品の URL に使うスラッグを作る関数は、たいてい「NFD で分解して結合文字を消す」という同じ手順でアクセント記号を落とす。Café が cafe になるので、英語やフランス語の見出しではうまく動く。
ところが同じ関数で日本語を残す設定にすると、「ガイド入門」が カ-イト-入門 になる。韓国語の「한국어」は見た目は変わらないのに、URL にすると長さが3倍になる。ドイツ語の Straße は stra-e になる。
この記事では、ハシトシステムの スラッグ生成(slugify) の変換関数で実際にこれが起きることを確かめ、原因を Unicode の正規化の規則から説明する。最後に、ラテン文字以外を壊さないスラッグ関数を作って結果を比べる。
検証環境は Windows 11、Node.js v24.11.1(ICU 77.1、Unicode 16.0)、比較に使ったライブラリは slugify 1.6.6 と @sindresorhus/slugify 3.0.0 である。
ツールの変換関数
スラッグ生成 は、タイトルを小文字の英数字と区切り文字だけの文字列に変えるツールである。2026-11-14 に本番の HTML から変換関数を取り出すと、処理は次の順になっていた。
- 「アクセント記号を除去」がオンなら
normalize('NFD')で分解し、U+0300〜U+036F を消す(初期値はオン) - 「小文字化する」がオンなら
toLowerCase()(初期値はオン) - 「非ラテン文字を残す」がオフなら
[^a-zA-Z0-9]+を、オンなら[^\p{L}\p{N}]+を空白に置き換える(初期値はオフ) - 連続する空白を区切り文字(
-か_)1つにする
U+0300〜U+036F は Unicode の「Combining Diacritical Marks」ブロックで、é を分解したときに出てくる U+0301(アキュートアクセント)などが入っている。
この関数と、npm の slugify(strict: true)、@sindresorhus/slugify に同じ入力を渡した結果が次の表である。「ツール(日本語を残す)」は、アクセント除去と小文字化を初期値のオンのまま、「非ラテン文字を残す」だけをオンにした結果である。
| 入力 | ツール(初期設定) | ツール(日本語を残す) | slugify | @sindresorhus/slugify |
|---|---|---|---|---|
Café Déjà Vu |
cafe-deja-vu |
cafe-deja-vu |
cafe-deja-vu |
cafe-deja-vu |
Straße |
stra-e |
straße |
strasse |
strasse |
Ærøskøbing |
r-sk-bing |
ærøskøbing |
aeroskobing |
a-eroskobing |
Łódź |
odz |
łodz |
lodz |
lodz |
Đà Nẵng |
a-nang |
đa-nang |
dja-nang |
da-nang |
full width |
空文字 | full-width |
空文字 | 空文字 |
ガイド入門 |
空文字 | カ-イト-入門 |
空文字 | 空文字 |
プログラミング |
空文字 | フ-ロク-ラミンク |
空文字 | 空文字 |
한국어 |
空文字 | 한국어(NFD) |
空文字 | 空文字 |
हिन्दी |
空文字 | ह-न-द |
空文字 | 空文字 |
2つのライブラリは日本語や韓国語を残さない。日本語のスラッグが要るなら、ローマ字にするか、記事 ID などで別に決めるのが前提の作りである。ツールには日本語を残すオプションがあるので、そこで上の崩れ方が出る。
濁点が区切り文字になる
「ガ」(U+30AC)は、Unicode の正準分解で「カ」(U+30AB)と U+3099(結合用の濁点)の2文字になる。NFD はこの分解をする正規化なので、'ガ'.normalize('NFD').length は2である。
U+3099 の一般カテゴリは Mn(結合文字)で、\p{L}(文字)にも \p{N}(数字)にも入らない。消す対象の U+0300〜U+036F の外にあるので、アクセント除去でも消えない。その結果、手順3の [^\p{L}\p{N}]+ に一致して空白に置き換わり、手順4で - になる。
「ガイド入門」はこうなる。
1 | |
「が」1文字だけを入れると、末尾の区切りが落ちて か になる。全コードポイントで数えると、ひらがな・カタカナの文字(\p{L})566個のうち58個が、この関数を通すと別の文字に変わった。濁点・半濁点の付いた仮名である。
ヒンディー語の हिन्दी も同じ理由で崩れる。母音記号の U+093F と U+0940 は Mc、ヴィラーマの U+094D は Mn で、どれも \p{L} ではない。こちらは NFD とは関係なく、アクセント除去をオフにしても ह-न-द になる。[^\p{L}\p{N}] が結合文字を残さないことが原因である。
ハングルは分解されたまま残る
「한」(U+D55C)は、NFD で U+1112、U+1161、U+11AB の3つの字母(ジャモ)に分解される。字母は \p{L} なので消えずに残り、画面上では「한」と同じに見える。しかし文字列としては別物で、encodeURIComponent すると次のように長さが変わる。
| 形 | encodeURIComponent の結果の長さ |
|---|---|
한국어(NFC・入力そのもの) |
27文字 |
한국어(ツールの出力・NFD) |
81文字 |
ハングルの音節 11,172 個は、すべてこの関数で NFD の字母列になった。NFC で書かれたリンクと NFD のスラッグは一致しないので、同じ見た目の URL が2つできる。
分解できない文字は消える
ß、ø、æ、ł、đ には Unicode の正準分解が無い。NFD をかけても1文字のままなので、アクセントを消す処理では何も変わらない。初期設定の [^a-zA-Z0-9]+ に一致して区切りになり、Straße は stra-e、Łódź は odz になる。
2つのライブラリは、こうした文字を ss や l に置き換える表を持っている。Đ の置き換え先が dj と d で分かれているように、表の中身はライブラリごとに違う。
全角英字の full も NFD では分解されない。全角を半角にするのは互換分解(NFKC / NFKD)の仕事である。
直し方
原因は3つに分けられる。
- 結合文字を、ラテン文字の上に付いたものだけでなく全部「記号」として扱っている
- NFD で分解したまま返している
- 正準分解の無い文字と全角文字を考えていない
そこで、次の順に処理する。
- 最初に NFKC をかけ、全角英数や
①を通常の文字にする - NFD で分解し、
\p{Script=Latin}の直後に続く結合文字だけを消す ßやøは表で置き換える- NFC に戻してから小文字化する
- 日本語を残す場合は、区切りにする文字から
\p{M}を外す([^\p{L}\p{M}\p{N}])
手順2で消す範囲をラテン文字の上に限るので、濁点や母音記号は残る。手順4で NFC に戻すので、「カ」と U+3099 は「ガ」に、ハングルの字母は音節に戻る。
ツールの関数で見つかった3つの崩れ方(濁点とヒンディー語の母音記号、ハングルの NFD、分解できない文字と全角)は、修正対象として記録した。入力したテキストはブラウザ内で処理され、サーバーには送られない。
URL に日本語をそのまま使うと、リンクを共有したときにパーセントエンコードされた長い文字列になる。この変換の違いは encodeURIComponent と URLSearchParams の記事 に、ドメイン名に日本語を使うときの正規化は Punycode の記事 に書いた。
実際に試す
前提: Node.js(確認は v24.11.1)。外部パッケージは不要である。
上の手順をそのまま関数にした。
1 | |
実行結果は次のとおりである。最後の列は encodeURIComponent した長さである。
1 | |
Ⅻ章 は NFKC で XII章 になり、英数字だけを残す設定では 章 が落ちて xii になる。İstanbul の İ は NFD で I と U+0307 に分かれ、U+0307 がラテン文字の上の記号として消えるので istanbul になる。
元の関数と比べるには、slug.mjs と同じ場所に次のファイルを置いて実行する。日本語を残す設定で、元の関数(アクセント除去オン)と直した関数の出力を並べ、元の出力が NFC かどうかも表示する。
1 | |
1 | |
한국어 は両方とも同じに見えるが、元の関数の出力は NFD である。
まとめ
- NFD で分解して U+0300〜U+036F を消す方法は、ラテン文字のアクセントにしか効かない。
- 日本語を残す設定で
[^\p{L}\p{N}]を区切りにすると、NFD で分かれた濁点(U+3099、Mn)が区切りになり、「ガイド」がカ-イトになる。仮名566文字中58文字が変わった。 - ハングルの音節は NFD で字母に分かれたまま残り、URL の長さが3倍になる。返す前に NFC に戻す。
ßøłなどは正準分解が無いので、アクセント除去では変わらず消える。置き換えの表が要る。- 全角英数は NFD では変わらない。最初に NFKC をかける。
結果は 2026-11-14 時点の上記の環境とバージョンで得たものである。
参考
- Unicode Standard Annex #15「Unicode Normalization Forms」(NFC / NFD / NFKC / NFKD の定義)
- Unicode Character Database — UnicodeData.txt(U+30AC の正準分解、U+3099・U+093F・U+094D の一般カテゴリ)、Blocks.txt(Combining Diacritical Marks U+0300〜U+036F)
- ECMAScript Language Specification —
String.prototype.normalize、正規表現の Unicode プロパティエスケープ(\p{L}\p{M}\p{Script=Latin}) - slugify 1.6.6、@sindresorhus/slugify 3.0.0 のソースと実行結果
このバージョンはその範囲に入るのか
依存の話でいちばん間違えやすいのは ^1.2.3 や ~1.2.3 がどこまでを許すかです(^0.2.3 のようにメジャーが 0 のときは規則が変わります)。範囲とバージョンを貼ると、下限・上限に展開したうえで一致・不一致とその理由を表示するsemver 範囲判定ツールを置いています。ブラウザの中だけで動き、入力はどこにも送信しません。