Reactで、見出しからURL用のスラッグを作る方法

​​React​JavaScript,Playwright,TypeScript

見出しからスラッグを作るには、normalize で文字の形をそろえ、文字と数字以外を - に置き換えます。日本語の見出しは日本語のまま残すので、英数字だけを残す書き方と違い、スラッグが空になりません。

React で使うときは、同じ見出しが2つあっても id が重ならないように、すべての見出しのスラッグを一度に作ります。もう1つ、URL に # を付けて開いたときに見出しの位置までスクロールしなかったので、描画のあとでスクロールする処理を足しました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。tsc で型エラーがないことを確かめ、コンポーネントを StrictMode で包んで esbuild で開発用にビルドし、Playwright で操作しました。サーバー側での描画(SSR)、Next.js などのフレームワークのルーター、Firefox と Safari は試していません。

見出しをスラッグにする関数

ts
// 見出しの文字列から、URL に使うスラッグを作る関数

export function slugify(text: string): string {
  return text
    .normalize('NFKC') // 全角の英数字を半角にする(React → React)
    .toLowerCase()
    .normalize('NFD') // é を e とアクセント記号に分ける
    .replace(/[\u0300-\u036f]/g, '') // ラテン文字のアクセント記号だけを消す
    .normalize('NFC') // 分かれた濁点を元に戻す(テ+結合用の濁点 → デ)
    .replace(/[^\p{L}\p{N}]+/gu, '-') // 文字と数字以外のつながりを、1つの - にする
    .replace(/^-+|-+$/g, ''); // 先頭と末尾の - を消す
}

// 見出しの一覧から、重ならないスラッグの一覧を作る
export function uniqueSlugs(titles: readonly string[]): string[] {
  const used = new Set<string>();
  return titles.map((title) => {
    const base = slugify(title) || 'section'; // 記号だけの見出しは section にする
    let slug = base;
    for (let n = 2; used.has(slug); n++) slug = `${base}-${n}`;
    used.add(slug);
    return slug;
  });
}

slugify は、次の順に文字列を変えていきます。

  1. normalize('NFKC') で、全角の英数字を半角にする
  2. toLowerCase() で、英字を小文字にする
  3. normalize('NFD') でアクセント付きの文字を分け、U+0300〜U+036F のアクセント記号を消す
  4. normalize('NFC') で、分かれたままの文字を合成し直す
  5. 文字(\p{L})と数字(\p{N})以外が続くところを、1つの - にする
  6. 先頭と末尾の - を消す

normalize に渡す4つの形の意味は、MDN の String.prototype.normalize() の説明にまとまっています。\p{L} のような書き方は Unicode 文字クラスエスケープといい、正規表現に u フラグ(または v フラグ)を付けたときだけ使えます。\p{L} はひらがなや漢字も文字として扱うので、日本語の見出しは日本語のまま残ります。

Node.js で動かすと、次のようになりました。

見出し スラッグ
Getting Started getting-started
React の useState とは? react-の-usestate-とは
ReactとTypeScript reactとtypescript
データベースの設計 データベースの設計
Café au lait cafe-au-lait
Node.js 22 の新機能 node-js-22-の新機能
API_KEY を設定する api-key-を設定する
第2章:まとめ 第2章-まとめ
🐱 猫の手 猫の手
C++ と C# c-と-c
★☆★ (空の文字列)

日本語の文字のあいだには空白がないので、「データベースの設計」は1つのまとまりのまま残ります。一方で、記号はすべて消えます。「C++ と C#」が「c-と-c」になるように、記号で区別していた見出しは区別がつかなくなります。「★☆★」のように記号だけの見出しは空の文字列になるので、上のコードの uniqueSlugs で section という名前を付けます(後の節「同じ見出しが2つあるときは、番号を付ける」で説明します)。

英数字だけを残すと、日本語の見出しが消える

英小文字と数字以外をすべて - にする書き方もあります。比べるために、/[^a-z0-9]+/g で置き換える書き方でも試しました。

見出し 英数字だけを残す書き方
Getting Started getting-started
React の useState とは? react-usestate
インストール (空の文字列)
使い方 (空の文字列)

英語の見出しなら同じ結果ですが、日本語だけの見出しは空になりました。英数字だけのスラッグにしたいときは、記事の最後で作る入力欄のように、手で付けられるようにしておきます。

アクセント記号だけを消す理由

「Café」の「é」を「e」にするには、NFD で「e」とアクセント記号に分けてから、記号を消します。このとき、記号を \p{M}(結合文字すべて)で消すと、日本語の濁点まで消えました。

見出し \p{M} をすべて消す書き方
Café au lait Cafe au lait
データベースの設計 テータヘースの設計

NFD は「デ」を「テ」と濁点に、「ベ」を「ヘ」と濁点に分けます。濁点も結合文字なので、\p{M} で消すと「テータヘース」になります。slugify では、消す範囲をラテン文字などに付くアクセント記号(U+0300〜U+036F)に絞っています。

範囲を絞っても、分けた濁点は「テ」と濁点のまま残っています。最後の normalize('NFC') を省くと、「データベース」のスラッグは、見た目は同じでも長さが8文字になり、NFC で合成し直した6文字の文字列とは別のものになりました。見出しの id と目次のリンクで作り方が少しでも違うと、見た目が同じでも一致しなくなります。

同じ見出しが2つあるときは、番号を付ける

uniqueSlugs は、見出しの一覧を受け取り、2回目に出てきたスラッグに -2、3回目に -3 を付けます。結果は次のとおりです。

見出しの一覧 スラッグの一覧
使い方、使い方、使い方 使い方、使い方-2、使い方-3
例、例、例-2 例、例-2、例-2-2
★、☆、まとめ section、section-2、まとめ

3つ目の「例-2」は、2つ目の見出しに付けた「例-2」と重なるので、さらに -2 を付けて「例-2-2」にしています。番号を付けたあとのスラッグも used に入れ、使われていない名前が見つかるまで番号を進めるので、もとから番号の付いた見出しがあっても重なりません。

目次と見出しに id を付ける

tsx
import { useEffect } from 'react';
import { uniqueSlugs } from './slugify';

type Section = { title: string; body: string };

// 見出しに id を付け、目次からその見出しへ移れるようにした記事
export function Article({ sections }: { sections: readonly Section[] }) {
  // 重なりを調べるため、すべての見出しのスラッグを一度に作る
  const slugs = uniqueSlugs(sections.map((s) => s.title));

  // 見出しを描画したあとで、URL の # が指す見出しへ移る
  useEffect(() => {
    let id = '';
    try {
      id = decodeURIComponent(location.hash.slice(1));
    } catch {
      return; // %E4 のような壊れた形は、何もしない
    }
    if (id) document.getElementById(id)?.scrollIntoView();
  }, []);

  return (
    <article>
      <nav aria-label="目次">
        <ol>
          {sections.map((s, i) => (
            <li key={slugs[i]}>
              <a href={`#${slugs[i]}`}>{s.title}</a>
            </li>
          ))}
        </ol>
      </nav>
      {sections.map((s, i) => (
        <section key={slugs[i]}>
          <h2 id={slugs[i]}>
            {s.title}
            <a className="anchor" href={`#${slugs[i]}`} aria-label={`「${s.title}」へのリンク`}>
              #
            </a>
          </h2>
          <p>{s.body}</p>
        </section>
      ))}
    </article>
  );
}

見出しの # のリンクと、後で作る入力欄の見た目には、次の CSS を使いました。

css
.slug-field {
  display: grid;
  gap: 6px;
  max-width: 360px;
}
.slug-field input {
  font-size: 1rem;
  padding: 6px 8px;
}
.slug-field output {
  margin-top: 8px;
  overflow-wrap: anywhere;
}
h2 .anchor {
  margin-left: 0.4em;
  color: #067a98;
  text-decoration: none;
}

スラッグは、sections のすべての見出しから一度に作っています。slugify は同じ見出しに同じスラッグを返すので、見出しのコンポーネントの中で1つずつ slugify を呼ぶと、同じ見出しの id が重なります。目次のリンクの href と見出しの id は、同じ配列 slugs から取るので、作り方がずれることもありません。

スラッグは見出しの文字だけから決まり、見出しの並び順は使いません(同じ見出しに付ける番号だけは、出てきた順で決まります)。毎回同じ値になり、同じ一覧の中で重ならないので、リストの key にもこのスラッグを使っています。key が兄弟の間で一意で、変わらないものであるべきことは、React の公式ドキュメントにある key のルールに書かれています。

「インストール」「使い方」「React の useState とは?」「使い方」「Q&A」の5つの見出しで描画すると、h2 の id は「インストール」「使い方」「react-の-usestate-とは」「使い方-2」「q-a」になりました。

目次の「React の useState とは?」と2つ目の「使い方」のリンクを押すと、どちらもその見出しが画面の上端に来ました。「React の useState とは?」を押したあとの location.hash は #react-%E3%81%AE-usestate-%E3%81%A8%E3%81%AF でした。日本語の部分は、ブラウザがパーセントエンコードしています。この形は、encodeURIComponent でスラッグを変えた結果と同じです。href には日本語のまま書いておけば、エンコードはブラウザが行います。

URL の # で開いたときに、見出しまでスクロールしない

useEffect の部分がないと、#使い方-2 を付けた URL を新しいタブで開いても、ページは先頭のままでした。

Playwright で調べると、ページの load イベントの時点では、h2 が1つもありませんでした。この検証のページは、createRoot で見出しを描画しています。見出しが画面に出たのは、load イベントよりあとでした。

そこで、見出しを描画したあとの useEffect で、location.hash が指す見出しを探して scrollIntoView でその位置までスクロールします。location.hash はパーセントエンコードされた形で入っているので、decodeURIComponent で日本語に戻してから getElementById に渡します。decodeURIComponent は %E4 のように壊れた形を渡すと URIError を投げるので、try で囲んでいます。

useEffect を足したあとは、#%E4%BD%BF%E3%81%84%E6%96%B9-2 のようにエンコードした URL でも、#使い方-2 と日本語のまま書いた URL でも、開くと「使い方-2」の見出しが画面の上端に来ました。#%E4 で開いたときは、エラーを出さずにページの先頭のままでした。

タイトルからスラッグを作る入力欄

記事の URL に使うスラッグは、タイトルから作ったものをそのまま使うとは限りません。タイトルを打つとスラッグが入り、手で直したらそちらを使う入力欄を作りました。

tsx
import { useId, useState } from 'react';
import { slugify } from './slugify';

// タイトルを打つとスラッグが入り、手で直したらそれを使う入力欄
export function SlugField() {
  const titleId = useId();
  const slugId = useId();
  const [title, setTitle] = useState('');
  // 手で直したスラッグ。null のあいだは、タイトルから作る
  const [customSlug, setCustomSlug] = useState<string | null>(null);
  const slug = customSlug ?? slugify(title);

  return (
    <div className="slug-field">
      <label htmlFor={titleId}>タイトル</label>
      <input id={titleId} value={title} onChange={(e) => setTitle(e.target.value)} />
      <label htmlFor={slugId}>スラッグ</label>
      <input id={slugId} value={slug} onChange={(e) => setCustomSlug(e.target.value)} />
      <button type="button" onClick={() => setCustomSlug(null)} disabled={customSlug === null}>
        タイトルから作り直す
      </button>
      <output>https://example.com/posts/{encodeURIComponent(slug)}</output>
    </div>
  );
}
「タイトル」の欄に「React の useState とは?」、「スラッグ」の欄に「react-の-usestate-とは」が入っている。その下の「タイトルから作り直す」ボタンは灰色で押せず、さらに下に、日本語の部分がパーセントエンコードされた URL が出ている
タイトルを入れたところ。スラッグはタイトルから作られ、下に URL の形で出る

スラッグの欄に出す値は、state に入れずに、描画のたびに customSlug ?? slugify(title) で決めています。公式ドキュメントの「そのエフェクトは不要かも」にあるとおり、ほかの state から計算できるものは、state に入れずに描画中に計算します。useEffect でタイトルの変化を見てスラッグの state を書き換える必要はありません。

手で直したかどうかは、customSlug が null かどうかで分けます。Playwright で操作した結果は次のとおりです。

操作 スラッグの欄 「タイトルから作り直す」
タイトルに「React の useState とは?」と入れる react-の-usestate-とは 押せない
スラッグを「react-usestate」に直す react-usestate 押せる
タイトルを「React の useState とは?(改訂版)」に変える react-usestate 押せる
「タイトルから作り直す」を押す react-の-usestate-とは-改訂版 押せない
タイトルを「React の useState 入門」に変える react-の-usestate-入門 押せない

スラッグを手で直したあとは、タイトルを変えてもスラッグは変わりませんでした。ボタンを押すと customSlug が null に戻り、またタイトルに合わせて変わるようになりました。

欄の下には、encodeURIComponent で変えた URL を出しています。「react-の-usestate-とは」は、URL に入れると react-%E3%81%AE-usestate-%E3%81%A8%E3%81%AF の43文字になりました。日本語のスラッグは、パーセントエンコードした形では長くなります。英数字の URL にしたいときは、スラッグの欄で手で直します。

​Posted by 86