スラッグ生成でガイドがカ-イトになる理由 濁点と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 から変換関数を取り出すと、処理は次の順になっていた。

  1. 「アクセント記号を除去」がオンなら normalize('NFD') で分解し、U+0300〜U+036F を消す(初期値はオン)
  2. 「小文字化する」がオンなら toLowerCase()(初期値はオン)
  3. 「非ラテン文字を残す」がオフなら [^a-zA-Z0-9]+ を、オンなら [^\p{L}\p{N}]+ を空白に置き換える(初期値はオフ)
  4. 連続する空白を区切り文字(- か _)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
2
3
4
ガイド入門
→ NFD: カ U+3099 イ ト U+3099 入 門
→ 手順3: カ 空白 イ ト 空白 入 門
→ 結果: カ-イト-入門

「が」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 で分解したまま返している
  • 正準分解の無い文字と全角文字を考えていない

そこで、次の順に処理する。

  1. 最初に NFKC をかけ、全角英数や ① を通常の文字にする
  2. NFD で分解し、\p{Script=Latin} の直後に続く結合文字だけを消す
  3. ß や ø は表で置き換える
  4. NFC に戻してから小文字化する
  5. 日本語を残す場合は、区切りにする文字から \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
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
// slug.mjs(Node.js v24.11.1 で確認・外部パッケージ不要)
// 使い方: node slug.mjs
// 結合文字を消すのはラテン文字の上だけにし、最後に NFC に戻すスラッグ関数
const FOLD = { 'ß': 'ss', 'ẞ': 'SS', 'æ': 'ae', 'Æ': 'AE', 'œ': 'oe', 'Œ': 'OE',
'ø': 'o', 'Ø': 'O', 'ł': 'l', 'Ł': 'L', 'đ': 'd', 'Đ': 'D', 'ð': 'd', 'Ð': 'D',
'þ': 'th', 'Þ': 'TH', 'ı': 'i' };

export function slugify(str, { keepNonLatin = false, sep = '-' } = {}) {
let s = str.normalize('NFKC'); // 全角英数・①・Ⅻ を通常の文字へ
s = s.normalize('NFD')
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1') // ラテン文字に付いた記号だけ消す
.replace(/[ßẞæÆœŒøØłŁđĐðÐþÞı]/g, (c) => FOLD[c]) // 分解できない文字は表で置き換える
.normalize('NFC') // ガ・한 などを合成済みの形に戻す
.toLowerCase();
s = keepNonLatin
? s.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ') // 結合文字(濁点・母音記号)は残す
: s.replace(/[^a-z0-9]+/g, ' ');
return s.trim().replace(/\s+/g, sep).normalize('NFC');
}

import { fileURLToPath } from 'node:url';
if (process.argv[1] === fileURLToPath(import.meta.url)) {
for (const x of ['Café Déjà Vu', 'Straße', 'Ærøskøbing', 'Łódź', 'Đà Nẵng', 'İstanbul', 'full width', 'Ⅻ章']) {
console.log(x.padEnd(14), slugify(x));
}
for (const x of ['ガイド入門', 'プログラミング', '한국어', 'हिन्दी']) {
const out = slugify(x, { keepNonLatin: true });
console.log(x.padEnd(14), out, out === out.normalize('NFC') ? 'NFC' : 'not NFC', encodeURIComponent(out).length);
}
}

実行結果は次のとおりである。最後の列は encodeURIComponent した長さである。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ node slug.mjs
Café Déjà Vu cafe-deja-vu
Straße strasse
Ærøskøbing aeroskobing
Łódź lodz
Đà Nẵng da-nang
İstanbul istanbul
full width full-width
Ⅻ章 xii
ガイド入門 ガイド入門 NFC 45
プログラミング プログラミング NFC 63
한국어 한국어 NFC 27
हिन्दी हिन्दी NFC 54

Ⅻ章 は NFKC で XII章 になり、英数字だけを残す設定では 章 が落ちて xii になる。İstanbul の İ は NFD で I と U+0307 に分かれ、U+0307 がラテン文字の上の記号として消えるので istanbul になる。

元の関数と比べるには、slug.mjs と同じ場所に次のファイルを置いて実行する。日本語を残す設定で、元の関数(アクセント除去オン)と直した関数の出力を並べ、元の出力が NFC かどうかも表示する。

1
2
3
4
5
6
7
8
9
10
// compare.mjs(slug.mjs と同じ場所に置く)
import { slugify } from './slug.mjs';
function original(str) {
let s = str.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
return s.replace(/[^\p{L}\p{N}]+/gu, ' ').trim().replace(/\s+/g, '-');
}
for (const x of ['ガイド入門', 'プログラミング', '한국어', 'हिन्दी']) {
const a = original(x), b = slugify(x, { keepNonLatin: true });
console.log(x.padEnd(10), 'original:', a, a === a.normalize('NFC') ? 'NFC' : 'NFD', '/ fixed:', b);
}
1
2
3
4
5
$ node compare.mjs
ガイド入門 original: カ-イト-入門 NFC / fixed: ガイド入門
プログラミング original: フ-ロク-ラミンク NFC / fixed: プログラミング
한국어 original: 한국어 NFD / fixed: 한국어
हिन्दी original: ह-न-द NFC / fixed: हिन्दी

한국어 は両方とも同じに見えるが、元の関数の出力は 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 のソースと実行結果

スラッグ生成でガイドがカ-イトになる理由 濁点とNFDの落とし穴
https://blog.hashito.biz/2026/11/14/slugify-nfd-dakuten-split-hangul-jamo-latin-only-strip-nfc/
著者
hashito
作成日
2026年11月14日
著作権

このバージョンはその範囲に入るのか

依存の話でいちばん間違えやすいのは ^1.2.3 や ~1.2.3 がどこまでを許すかです(^0.2.3 のようにメジャーが 0 のときは規則が変わります)。範囲とバージョンを貼ると、下限・上限に展開したうえで一致・不一致とその理由を表示するsemver 範囲判定ツールを置いています。ブラウザの中だけで動き、入力はどこにも送信しません。