スラッグのこだわり
スラッグとは URL の一部分のこと(主に末尾)を指す。
例えば、この記事の場合、URL は https://kkhys.me/posts/p1y4nft
でスラッグは p1y4nft
ということになる。
気にならない人にとってはただの文字列だが、気になる人にとってはこだわりポイントでもある。 私は小さいことでもこだわってしまう性分なので、例に漏れず悩んでこの形式にした。
こだわりポイント
私がスラッグに求めるものは以下の 3 つ。
- 自分で決めなくて良い
- 美しい文字列
- 打ち間違いをしにくい
自分で決めなくて良い
コンテンツは MDX で書いており、それをアプリケーションで使用できるデータオブジェクトに変換する際に、Contentlayer を使用している。
コンテンツを自前でビルドする人であればディレクトリ名またはファイル名をそのままスラッグにするパターンが多い。 逆にそれ以外を選択している人は 10 % ぐらいではないかな。
私の場合はディレクトリ名に作成日時(2024-01-26
みたいな)を入れたいのでこの形式はまず取らなかった。
そうすることで時系列にコンテンツが並ぶため、あとあと便利。
このサイトでは以下のディレクトリ構造にしている。
.
|-- life
| `-- 2023-12-31
| `-- index.mdx
| `-- 01.jpg
| `-- 02.jpg
`-- tech
|-- 2024-01-20
| `-- index.mdx
`-- 2024-01-26
`-- index.mdx
Contentlayer ではビルドするとき、よしなにメタデータを生成する機能がある。 そのタイミングでスラッグを生成する処理を挟み込んだ。
lib/contentlayer/definitions/Post.ts
export const Post = defineDocumentType(() => ({
...
computedFields: {
slug: {
description: 'Generate post slug from id',
type: 'string',
resolve: ({ _id }) => generateSlug(_id),
},
}
})
関数 generateSlug
はあとで説明するとして、ここでは実引数の _id
が重要である。
_id
は Contentlayer で必ず生成され、ユニークな値として利用できる。
この記事の場合だと posts/life/2024-01-26/index.mdx
となる。
一意な値があるのなら話は早い。これをそのまま使えばスラッグを自分で決めなくても良い。 しかし、そのまま使うとあんまりカッコ良くないので別の方法を考える。
書き忘れていたが、自分でスラッグを命名したくないのは単純に面倒くさいからだ。
この記事だと particular-about-slug
みたいに付けられるが、記事によっては無駄に悩むことが想定される。
あんまりそういったことで悩みたくないので自動生成するようにした。
美しい文字列
さて、一意な値は手に入れられたのでそれを美しい文字列に変換する必要がある。 美しい文字列というと人それぞれ違うが、ここでは暗号学的ハッシュ関数で生成された文字列と定義する。 なぜそれが美しいのかについては長くなるので、また別の記事でまとめようと思う。
先ほど出てきた関数 generateSlug
は以下のようになっている。
lib/contentlayer/utils.ts
/**
* Generates a unique slug based on the given data.
*
* @param data - The data used to generate the slug.
* @returns The generated slug.
*/
export const generateSlug = (data: crypto.BinaryLike) => {
const hashAlgorithm = 'sha512';
const encoding = 'hex';
const slugLength = 7;
const prefix = 'p';
const hashValue = crypto.createHash(hashAlgorithm).update(data).digest(encoding);
const buffer = Buffer.from(hashValue, encoding);
const words = bech32m.toWords(buffer);
return bech32m.encode(prefix, words, 1024).slice(0, slugLength);
};
const id = 'posts/life/2024-01-26/index.mdx';
console.log(generateSlug(id)); // p1y4nft
ハッシュアルゴリズムには SHA-512
を使っている。
パスワードではないため、MD5
でも何でも良いのだが個人的な好みで選んだ。
文字数は素数が良かったので 7 文字にした。桁数を少なくしてもハッシュ値の衝突が起こる確率は限りなく低いため重複チェックは行っていない。
打ち間違いをしにくい
ハッシュ関数を経ることでどんな文字列でも、衝突しない一意の無意味な文字列に変換できた。 ただ、16 進数では味気ないので別の文字列に変換したい。 一般的なのは Base64 だが、その文字列には落とし穴がある。以下の例を見てほしい。
https://kkhys.me/posts/0oO1IlL
極端な例だが紛らわしいアルファベットが並んでいる。 今はコピペをする人が大多数なので直接タイピングする人は少ないだろうが、仮に打ち込むとして多くの人が 404 ページを開くことになるだろう。
打ち間違いをして存在しないページを開くだけであれば大した問題はない。 だが、少しのミスが大きな問題になるケースはいくつかある。そのうちの 1 つがビットコインアドレスである。 誤ったアドレスに送金してしまうと二度と返ってくることはない。
誤記を生じさせないようにビットコインアドレスを生成する際には Base58 を挟んでいる。
Base58 は Base64 の改良版で、似た文字や数字(0
, O
, l
, I
)を排除した人間に優しいエンコード方式である。
どんなデータでも以下の 58 文字で表せる。
123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz
これで良さそうに思えるが、実は Base58 にも欠点がある。
- QR コード生成時に英数字モードを使えないため、QR コードのサイズが大きくなる
- 大文字と小文字が混在しているため、メモしたり、モバイルキーボードで打ち込んだり、読み上げたりするときに不便
その欠点を克服するために作られたのが Bech32 という方式である。 現在では Segwit のアドレスや Nostr の id 生成に導入されている。
Bech32 では英数字から 1
(セパレータを除く), b
, i
, o
という文字を除いた 32 文字の文字セットで構成されている。
興味のある人は草案を読んでほしい(BIP-173)。
使われている文字は以下の 32 文字のみ。
qpzry9x8gf2tvdw0s3jn54khce6mua7l
Bech32 は後になってから欠陥が明らかになったため(Issue)、改良版の Bech32m という方式が登場した(BIP-350)。 エンコードでしか使用していないこのサイトでは特に影響はないが、新しいものが好きなので Bech32m を使っている。
かなりシンプルになった。 そんなこんなでスラッグの作成方法は以上になる。
まとめると以下のような方法でスラッグを生成している。
# id (Generated by Contentlayer)
posts/life/2024-01-26/index.mdx
↓
# SHA-512
256695b8af9372eb6484a04d906b5675ff56bf845116adcb2594ca2e7d834bd982e26c000161902ae259f7b509e12458843b1abaee8dae2061f8b6723d4ae301
↓
# Bech32m (prefix is "p")
p1y4nftw90jdewkeyy5pxeq66kwhl4d0uy2yt2mje9jn9zulvrf0vc9cnvqqqkryp2ufvl0dgfuyj93ppmr2awardwypsl3dnj849wxqgr38kz4
↓
# Cut out the first 7 characters
p1y4nft
Bech32m の prefix が p
とあるが、これは任意で決められる文字列である(ビットコインの場合は bc
)。
1
をセパレータとして可読部分とデータ部分とに分けられる。
posts
の slug なので p
とした。今後新たなカテゴリを作った場合はその頭文字を入れる予定である。
最後に、この実装の欠点としては id に依存していることがあげられる。 今後の Contentlayer のアップデートで出力値が変わることはないとは思うが、変わった際はその処理を自分で書けば解決する。 というか、あまりライブラリに依存した処理を書くべきではないからそのうち書き換えようかな。