CODE Polyglot / よく使う書き方 / 06

URL 用の文字列(slug)を作る

記事のタイトルから「hello-world」のような URL の一部を作ります。ブログや商品ページのアドレスを、見て意味が分かる形にするときに使います。よく見かける書き方は、日本語のタイトルをそのまま URL に入れたり、空の slug を黙って返したりします。

画面にこう出れば正解

どの言語で書いても、同じ入力なら画面に出る文字は1文字も違わない、というのが正解の決め方です(例は Python で動かしたときのコマンドです)。

基本

$ python3 slug.py "Hello, World!"
hello-world

前後と間の空白・記号

$ python3 slug.py "  PHP  &  Go  "
php-go

「_」も「-」にする

$ python3 slug.py user_id--2
user-id-2

日本語だけ

$ python3 slug.py 日本語のタイトル
NG: URL に使える文字がありません

日本語が混ざる

$ python3 slug.py "新しい PHP 8.4 の機能"
php-8-4

絵文字(コードポイントは1つではない)

$ python3 slug.py "I 🍣 Sushi"
i-sushi

小文字にすると2文字になる İ

$ python3 slug.py İstanbul
stanbul

32文字より長い(単語の途中で切る)

$ python3 slug.py "The Quick Brown Fox Jumps Over The Lazy Dog"
the-quick-brown-fox-jumps-over-t

32文字目が「-」

$ python3 slug.py "Python Ruby PHP Java Go Rust JS and more"
python-ruby-php-java-go-rust-js

ちょうど32文字

$ python3 slug.py "Go Rust Swift Kotlin Java Csharp"
go-rust-swift-kotlin-java-csharp

空の文字列

$ python3 slug.py ""
NG: URL に使える文字がありません

記号だけ

$ python3 slug.py "!!! ---"
NG: URL に使える文字がありません

全角の英数字

$ python3 slug.py PHP8
NG: URL に使える文字がありません

改行(\r\n)が入っている

$ python3 slug.py "line1
line2"
line1-line2

10言語のプログラム

どれもそのまま保存して動かせます。毎朝、上の正解の例をすべて実際に動かして確かめています。

slug.php8.4.26 で 14 / 14 件の出力が正解と一致

<?php
// 文字列を URL に使える形(slug)に変える
const MAX = 32;
const NG = "NG: URL に使える文字がありません";
$s = $argv[1];

// strtolower は PHP 8.2 から ASCII の A〜Z だけを変える(İ のような文字はそのまま。mbstring は要らない)
$s = strtolower($s);
// u を付けないので1バイトずつ見るが、[^a-z0-9]+ は多バイトの文字のバイトもまとめて食うので「-」1つになる
$s = trim(preg_replace('/[^a-z0-9]+/', '-', $s), '-');
if (strlen($s) > MAX) {
    // ここまでで半角の英数字と「-」だけになっているので、バイト数=文字数
    $s = rtrim(substr($s, 0, MAX), '-');
}
echo ($s === "" ? NG : $s) . "\n";

動かし方(ファイルを保存したフォルダで)

$ php slug.php "Hello, World!"

よく見かける書き方で試すと

仕事でよく見かける書き方(標準の関数をそのまま使う形や、よく紹介される正規表現など)を、同じ正解表で動かした結果です。言語を出した答えごとにまとめ、赤い字はその言語が実際に出した間違った行です。

入力よく見かける書き方の答え(10言語)
基本"Hello, World!"

✓10言語すべて正解

前後と間の空白・記号" PHP & Go "
  • JavaScriptphp--go
  • PHP・Python・Go・Java・C#・Rust・Ruby・Kotlin・Swift✓ 正解
「_」も「-」にするuser_id--2
  • PHPuser-id--2
  • Python・C#・Kotlinuser_id-2
  • JavaScriptuser_id--2
  • Go・Java・Rust・Ruby・Swift✓ 正解
日本語だけ日本語のタイトル
  • PHP・Go・JavaScript・Java・Ruby・Kotlin(何も出さなかった)
  • Python・C#・Rust・Swift日本語のタイトル
日本語が混ざる"新しい PHP 8.4 の機能"
  • Python・C#・Rust・Swift新しい-php-8-4-の機能
  • JavaScript-php-84-
  • PHP・Go・Java・Ruby・Kotlin✓ 正解
絵文字(コードポイントは1つではない)"I 🍣 Sushi"
  • JavaScripti--sushi
  • PHP・Python・Go・Java・C#・Rust・Ruby・Kotlin・Swift✓ 正解
小文字にすると2文字になる İİstanbul
  • Python・Java・Rust・Ruby・Kotlini-stanbul
  • Go・JavaScriptistanbul
  • C#İstanbul
  • Swifti̇stanbul
  • PHP✓ 正解
32文字より長い(単語の途中で切る)"The Quick Brown Fox Jumps Over The Lazy Dog"
  • PHP・Python・Go・JavaScript・Java・C#・Rust・Ruby・Kotlin・Swiftthe-quick-brown-fox-jumps-over-the-lazy-dog
32文字目が「-」"Python Ruby PHP Java Go Rust JS and more"
  • PHP・Python・Go・JavaScript・Java・C#・Rust・Ruby・Kotlin・Swiftpython-ruby-php-java-go-rust-js-and-more
ちょうど32文字"Go Rust Swift Kotlin Java Csharp"

✓10言語すべて正解

空の文字列""
  • PHP・Python・Go・JavaScript・Java・C#・Rust・Ruby・Kotlin・Swift(何も出さなかった)
記号だけ"!!! ---"
  • PHP・Python・Go・Java・C#・Rust・Ruby・Kotlin・Swift(何も出さなかった)
  • JavaScript----
全角の英数字PHP8
  • PHP・Go・JavaScript・Java・Ruby・Kotlin(何も出さなかった)
  • Python・C#・Rust・Swiftphp8
改行(\r\n)が入っている"line1 line2"

✓10言語すべて正解

この書き方の決まり

  • 引数は文字列1つ。URL に使える形にして1行で出す
  • 半角の A〜Z だけを a〜z にする(ほかの文字の大小は変えない)
  • 半角の a〜z と 0〜9 はそのまま残す。それ以外の文字は、続いている分をまとめて「-」1つにする(「_」も「-」にする)
  • 先頭と末尾の「-」を取る
  • そのあと32文字より長ければ32文字で切り、末尾に「-」が残ったら取る
  • 残る文字が無ければ「NG: URL に使える文字がありません」

解説

日本語のタイトルが、そのまま slug になる

日本語のタイトル(c004)を渡すと、よく見かける書き方のうち 10言語中4言語(Python・C#・Rust・Swift)が 日本語のタイトル をそのまま返しました。全角の PHP8(c013)も、同じ4言語が php8 にしました。残りの6言語は空の文字列を返しました。

よく見かける書き方日本語のタイトル の答え
決まりNG: URL に使える文字がありません
Python \W+・C# \W+日本語のタイトル
Rust is_alphanumeric()・Swift isLetter日本語のタイトル
PHP・JavaScript・Go・Java・Kotlin・Ruby(空の文字列)

\w(単語を作る文字)と「英数字か」を調べる関数が、言語によって Unicode 全体を見ます。Python と C# の \w、Rust の is_alphanumeric()、Swift の isLetter は、ひらがなや漢字にも合います。残す文字を [a-z0-9] と書いて決めれば、どの言語でも同じ答えになります(01 スネークケースとキャメルケースを変換する と同じ問題です)。

空になったときに、黙って空を返す

空の文字列(c011)と記号だけの !!! ---(c012)では、よく見かける書き方の 10言語すべてが空の行を出しました。日本語だけのタイトルでも、上の6言語は空になります。

slug が空のまま保存されると、/posts/ のような URL ができたり、同じ空の slug が2件目でぶつかったりします。この書き方では、残る文字が無いことを「NG」として返しています。呼ぶ側で日付や ID を足すなど、別の名前を決められるようにするためです。

小文字にすると、文字数が変わることがある

İstanbul(c007。トルコ語の「点の付いた大文字の I」)1つで、よく見かける書き方の答えが 5通りに割れました。

よく見かける書き方İstanbul の答え
決まり(A〜Z だけを小文字に)stanbul
PHP strtolowerstanbul
Python・Java・Rust・Ruby・Kotlini-stanbul
JavaScript・Goistanbul
C# ToLower()İstanbul
Swift lowercased()i̇stanbul

Unicode の決まりでは、İ(U+0130)を小文字にすると「i」+「上に付く点」の2文字になります。Python・Java・Rust・Ruby・Kotlin はこの決まりどおりに変えるので、点が許可していない文字になり、i と stanbul の間に「-」が入りました。JavaScript と Go は1文字の i にします。C# の ToLower() は İ のまま変えず、\W が文字とみなしたので残りました。Swift は2文字になったものを「1つの文字」として扱うので、点の付いた i がそのまま残っています。

PHP の strtolower は PHP 8.2 から ASCII の A〜Z だけを変えるので、決まりと同じ答えになりました(よく見かける書き方で c007 を通したのは 10言語中 PHP だけです)。

「_」は URL では「-」にする

user_id--2(c003)では、よく見かける書き方の 10言語中5言語が決まりと違う答えを返しました。4言語は「_」をそのまま残し、PHP は続いた「--」を残しました。\w と \W は、Unicode を見る言語でも見ない言語でも「_」を単語の文字に入れます。

よく見かける書き方user_id--2 の答え
決まりuser-id-2
Python・C# \W+user_id-2
JavaScript [^\w-]user_id--2
Kotlin \W+user_id-2
PHP [^A-Za-z0-9-]+user-id--2

PHP でよく貼られる preg_replace('/[^A-Za-z0-9-]+/', '-', $s) は、許す文字に「-」を入れているので、もともと続いていた -- がそのまま残ります。「残す文字」と「区切りに使う文字」は分けて考えます。

長さの上限

The Quick Brown Fox Jumps Over The Lazy Dog(c008)のような長いタイトルでは、よく見かける書き方の 10言語すべてが43文字の slug をそのまま返しました。データベースの列の幅や、URL の読みやすさのために、上限を決めて切ります。

切る位置が「-」に当たると(c009。32文字目が「-」)、python-ruby-php-java-go-rust-js- のように末尾に「-」が残ります。切ったあとにもう一度、末尾の「-」を取ります。

空白を先に「-」にすると、「-」が増える

JavaScript でよく見かける「空白を - にして、残りの記号を消す」書き方は、記号の分だけ「-」が増えます。 PHP & Go (c002)は php--go、I 🍣 Sushi(c006)は i--sushi になりました。新しい PHP 8.4 の機能(c005)では 8.4 の「.」が消えて -php-84- です。

先に「残さない文字」を1つの「-」にまとめ、そのあとで前後の「-」を取る順番にすると、この問題は起きません。

どの言語でも同じ長さで数えられる

32文字を数えるところでは、文字の数え方の違い(絵文字を何文字と数えるか)が問題になりません。「-」にまとめたあとは半角の英数字と「-」だけになっていて、バイト数も、文字数も、Unicode の値の数も同じになるからです。Go と Rust は len(バイト数)、PHP は strlen、Swift は count で数えています。

I 🍣 Sushi(c006)のように、絵文字は何文字と数えても1つの「-」にまとまります。line1\r\nline2(c014)の改行2文字も同じく1つの「-」です。