画面にこう出れば正解
どの言語で書いても、同じ入力なら画面に出る文字は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
stanbul32文字より長い(単語の途中で切る)
$ python3 slug.py "The Quick Brown Fox Jumps Over The Lazy Dog"
the-quick-brown-fox-jumps-over-t32文字目が「-」
$ 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-line210言語のプログラム
どれもそのまま保存して動かせます。毎朝、上の正解の例をすべて実際に動かして確かめています。
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!"
slug.py3.14.7 で 14 / 14 件の出力が正解と一致
# 文字列を URL に使える形(slug)に変える
import re
import string
import sys
MAX = 32
s = sys.argv[1]
# lower() は Unicode の決まりで変えるので、İ が「i」+点の2文字になる。A〜Z だけを変える表を作る
s = s.translate(str.maketrans(string.ascii_uppercase, string.ascii_lowercase))
# Python の \w は「日本語」にも合う。残す文字を [a-z0-9] と書いて、それ以外を続いている分まとめる
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
if len(s) > MAX:
s = s[:MAX].rstrip("-")
print(s or "NG: URL に使える文字がありません")
動かし方(ファイルを保存したフォルダで)
$ python3 slug.py "Hello, World!"
slug.go1.27.2 で 14 / 14 件の出力が正解と一致
// 文字列を URL に使える形(slug)に変える
package main
import (
"fmt"
"os"
"regexp"
"strings"
)
const maxLen = 32
// 残す文字を [a-z0-9] と書く。否定の文字クラスは RE2(Go の正規表現)にもある
var notSlug = regexp.MustCompile("[^a-z0-9]+")
func main() {
// strings.ToLower は Unicode の決まりで変える(İ が「i」になる)。A〜Z だけを変える
s := strings.Map(func(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + ('a' - 'A')
}
return r
}, os.Args[1])
s = strings.Trim(notSlug.ReplaceAllString(s, "-"), "-")
// ここまでで半角の英数字と「-」だけになっているので、len(バイト数)=文字数
if len(s) > maxLen {
s = strings.TrimRight(s[:maxLen], "-")
}
if s == "" {
fmt.Println("NG: URL に使える文字がありません")
return
}
fmt.Println(s)
}
動かし方(ファイルを保存したフォルダで)
$ go run slug.go "Hello, World!"
slug.js24.15.0 で 14 / 14 件の出力が正解と一致
// 文字列を URL に使える形(slug)に変える
const MAX = 32;
const NG = "NG: URL に使える文字がありません";
// toLowerCase() は Unicode の決まりで変えるので、İ が2文字になる。A〜Z だけを小文字にする
let s = process.argv[2].replace(/[A-Z]/g, (c) => c.toLowerCase());
// \W は [^A-Za-z0-9_] なので「_」が残る。残す文字を [a-z0-9] と書く
s = s.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
if (s.length > MAX) {
s = s.slice(0, MAX).replace(/-+$/, "");
}
console.log(s === "" ? NG : s);
動かし方(ファイルを保存したフォルダで)
$ node slug.js "Hello, World!"
Slug.java21.0.12 で 14 / 14 件の出力が正解と一致
// 文字列を URL に使える形(slug)に変える
import java.util.Locale;
import java.util.regex.Pattern;
public class Slug {
static final int MAX = 32;
public static void main(String[] args) {
// toLowerCase() は動かす場所の言語の設定を使い、İ は2文字になる。A〜Z だけを変える
String s = Pattern.compile("[A-Z]").matcher(args[0])
.replaceAll(m -> m.group().toLowerCase(Locale.ROOT));
// \w は既定では ASCII の [A-Za-z0-9_]。残す文字を [a-z0-9] と書く
s = s.replaceAll("[^a-z0-9]+", "-").replaceAll("^-+|-+$", "");
if (s.length() > MAX) {
s = s.substring(0, MAX).replaceAll("-+$", "");
}
System.out.println(s.isEmpty() ? "NG: URL に使える文字がありません" : s);
}
}
動かし方(ファイルを保存したフォルダで)
$ java Slug.java "Hello, World!"
slug.cs10.0.402 で 14 / 14 件の出力が正解と一致
// 文字列を URL に使える形(slug)に変える
using System.Text.RegularExpressions;
const int Max = 32;
// ToLower() は動かす場所の言語の設定を使う(トルコ語では I が「ı」)。A〜Z だけを変える
var s = Regex.Replace(args[0], "[A-Z]", m => m.Value.ToLowerInvariant());
// C# の \w は Unicode を見るので「日本語」にも合う。残す文字を [a-z0-9] と書く
s = Regex.Replace(s, "[^a-z0-9]+", "-").Trim('-');
if (s.Length > Max)
{
s = s.Substring(0, Max).TrimEnd('-');
}
Console.WriteLine(s == "" ? "NG: URL に使える文字がありません" : s);
動かし方(ファイルを保存したフォルダで)
$ dotnet run slug.cs -- "Hello, World!"
slug.rs1.99.0 で 14 / 14 件の出力が正解と一致
// 文字列を URL に使える形(slug)に変える
const MAX: usize = 32;
fn main() {
let args: Vec<String> = std::env::args().collect();
// 標準に正規表現が無いので1文字ずつ見る。to_lowercase() は Unicode の決まり(İ が2文字)なので使わない
let mut s = String::new();
for c in args[1].chars() {
let c = c.to_ascii_lowercase();
if c.is_ascii_lowercase() || c.is_ascii_digit() {
s.push(c);
} else if !s.ends_with('-') {
// 続いている分はまとめて「-」1つにする
s.push('-');
}
}
let mut s = s.trim_matches('-').to_string();
// ここまでで半角の英数字と「-」だけになっているので、len(バイト数)=文字数
if s.len() > MAX {
s = s[..MAX].trim_end_matches('-').to_string();
}
println!("{}", if s.is_empty() { "NG: URL に使える文字がありません" } else { &s });
}
動かし方(ファイルを保存したフォルダで)
$ rustc slug.rs && ./slug "Hello, World!"
slug.rb4.0.7 で 14 / 14 件の出力が正解と一致
# 文字列を URL に使える形(slug)に変える
MAX = 32
NG = "NG: URL に使える文字がありません"
# downcase は Unicode の決まりで変えるので、İ が2文字になる。tr で A〜Z だけを変える
s = ARGV[0].tr("A-Z", "a-z")
# Ruby の \w は既定では ASCII だけだが、言語によって違う。残す文字を [a-z0-9] と書く
s = s.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
s = s[0, MAX].sub(/-+\z/, "") if s.length > MAX
puts s.empty? ? NG : s
動かし方(ファイルを保存したフォルダで)
$ ruby slug.rb "Hello, World!"
slug.kt2.4.21 で 14 / 14 件の出力が正解と一致
// 文字列を URL に使える形(slug)に変える
const val MAX = 32
fun main(args: Array<String>) {
// lowercase() は Unicode の決まりで変えるので、İ が2文字になる。A〜Z だけを変える
var s = args[0].replace(Regex("[A-Z]")) { it.value.lowercase() }
// \w は既定では ASCII の [A-Za-z0-9_]。残す文字を [a-z0-9] と書く
s = s.replace(Regex("[^a-z0-9]+"), "-").trim('-')
if (s.length > MAX) {
s = s.substring(0, MAX).trimEnd('-')
}
println(if (s.isEmpty()) "NG: URL に使える文字がありません" else s)
}
動かし方(ファイルを保存したフォルダで)
$ kotlinc slug.kt -include-runtime -d slug.jar && java -jar slug.jar "Hello, World!"
slug.swift6.3.3 で 14 / 14 件の出力が正解と一致
// 文字列を URL に使える形(slug)に変える
let maxLen = 32
// lowercased() は Unicode の決まりで変えるので、İ が2文字になる。1文字ずつ見て ASCII だけを変える
var s = ""
for ch in CommandLine.arguments[1] {
// isLetter は「あ」にも true を返す。isASCII と合わせて半角の英数字だけを残す
let c = ch.isASCII && ch.isUppercase ? Character(ch.lowercased()) : ch
if c.isASCII && (c.isLowercase || c.isNumber) {
s.append(c)
} else if !s.hasSuffix("-") {
// 続いている分はまとめて「-」1つにする(絵文字のように複数の値でできた文字も1つ)
s += "-"
}
}
if s.hasPrefix("-") { s.removeFirst() }
if s.hasSuffix("-") { s.removeLast() }
if s.count > maxLen {
s = String(s.prefix(maxLen))
if s.hasSuffix("-") { s.removeLast() }
}
print(s.isEmpty ? "NG: URL に使える文字がありません" : s)
動かし方(ファイルを保存したフォルダで)
$ swift slug.swift "Hello, World!"
よく見かける書き方で試すと
仕事でよく見かける書き方(標準の関数をそのまま使う形や、よく紹介される正規表現など)を、同じ正解表で動かした結果です。言語を出した答えごとにまとめ、赤い字はその言語が実際に出した間違った行です。
| 入力 | よく見かける書き方の答え(10言語) |
|---|---|
基本"Hello, World!" | ✓10言語すべて正解 |
前後と間の空白・記号" PHP & Go " |
|
「_」も「-」にするuser_id--2 |
|
日本語だけ日本語のタイトル |
|
日本語が混ざる"新しい PHP 8.4 の機能" |
|
絵文字(コードポイントは1つではない)"I 🍣 Sushi" |
|
小文字にすると2文字になる İİstanbul |
|
32文字より長い(単語の途中で切る)"The Quick Brown Fox Jumps Over The Lazy Dog" |
|
32文字目が「-」"Python Ruby PHP Java Go Rust JS and more" |
|
ちょうど32文字"Go Rust Swift Kotlin Java Csharp" | ✓10言語すべて正解 |
空の文字列"" |
|
記号だけ"!!! ---" |
|
全角の英数字PHP8 |
|
改行(\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 strtolower | stanbul |
| Python・Java・Rust・Ruby・Kotlin | i-stanbul |
| JavaScript・Go | istanbul |
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つの「-」です。