% cd ..

見出しのアンカー ID、rehype-slug を使わなかったワケ

見出しのアンカー ID、rehype-slug を使わなかったワケ

ブログの各見出しにアンカーID(#section-name で直接ジャンプできるアレ)を付けようと思って調べると、rehype-slug で全自動という解がほぼ一択で出てきます。記事も多いし、実装も .use(rehypeSlug) の 1 行。

でも、このブログでは採用しませんでした。代わりに remark-heading-id 単体で「{#foo} を手書きした見出しだけ」アンカーを生やす、という地味に面倒な方向で実装しています。少数派の選択なので、なぜそうしたのかを書き残しておきます。

圧倒的マジョリティ = rehype-slug 全自動

rehype-slug は unified パイプラインに 1 行足すだけで、全ての見出しに自動で id 属性を付けてくれるプラグインです。

.use(rehypeSlug)

見出しテキストを slug 化(小文字+ハイフン化)して id にする。日本語の見出しもそのまま slug に混ぜてくれます(結果は URL エンコードされる)。

<!-- ## Keystatic とは -->
<h2 id="keystatic-とは">Keystatic とは</h2>

rehype-autolink-headings を足せば見出しの横に # アイコンも出せて、そこから deep link URL をコピーできる。多くのエンジニア向けブログや docs サイトはこのパターンです。MDN、GitHub の README、Next.js のドキュメントもみんなこれ。

めちゃ楽ちんで、とりま動く。

だったら、なぜ採用しなかったのか。

採用しなかった 3 つの理由

1. 多言語ブログでの分断

このブログは日本語記事を AI で英訳して両方公開しています。rehype-slug で自動 id を振ると、同じ節なのに JA/EN でアンカーが別物になる。

/ja/posts/dual-cms#ハマりポイント-1-markdoc-テーブル問題
/en/posts/dual-cms#gotcha-1-markdoc-table-issue

「日本語版のこの節の英訳版」を読みたくても、アンカー付きで直接遷移できない。言語横断の deep link が機能しないので、多言語ブログとしては片手落ちです。

2. 最悪すぎる日本語 URL の見た目

rehype-slug 方式の deep link URL は #keystatic-とは のようにそのまま日本語が入ります。機能的には動きますが、ブラウザや SNS のプレビューに貼ると URL エンコードされて #keystatic-%E3%81%A8%E3%81%AF に化ける。
本来の日本語の見出しの意味は見事に消滅して、まったく意味不明です。

しかも長い。日本語の文字列が UTF-8 バイト列の16進数表現に変換されますので、たった一文字の日本語全角文字が、英数字9文字とか、下手すると12文字に変換されます。(例:「𠮷」→ %F0%A0%AE%B7)
うっかり、ちょっと長めの見出し行をつけたりすると、とんでもない長さの得体の知れない URL が爆誕します。

機能には影響しないとはいえ、SNS にシェアされた時の URL の見た目はめちゃくちゃ気になります。ていうか 醜い
エンジニアブログとしては、なんと言うかこう、「スッキリ見えていたい」という美学に反します。

そういう意味では、これだけでも採用見送りの十分な理由でした。

3. 意図の薄いアンカーが増えすぎる

rehype-slug は 全見出しに機械的に id を振ります。
「ここは deep link されることを想定している」という著者の意図とは無関係に、記事の枝葉の見出しにも、どうでもいいサブセクションにも、全部アンカーが付きます。
これは HTML ソースが散らかるという見た目の問題でもあります。
が、もっと本質的には、深い意味のないアンカーが「意図した」アンカーを埋没させる、という情報設計の問題のような気がします。

見出しに deep link したくなる理由は 3 パターンくらい。

  • 他の記事から「この節を参照して」とリンクしたい
  • SNS で「この部分を読んで」と特定セクションをシェアしたい
  • 読者が自分のブックマークやメモに残したい

どれも「その場所が単独で参照価値を持つ」という文脈が必要です。枝葉の見出しに deep link できたとしても誰も得しません。

「意図したところだけ」アンカーをつける

という訳で行き着いたのが現在の、rehype-slug を使わず、remark-heading-id だけ を入れる方式です。

.use(remarkHeadingId)    // {#id} をパースして heading に id を振る
.use(rehypeAutolinkHeadings, {
  test: (node) => node.tagName === "h2",    // h2 だけに自動でアンカー <a> を足す
  // ...
})

remark-heading-id## Heading {#my-id} という Pandoc 由来の構文をパースして、その見出しにだけ id を振るプラグイン。著者が {#my-id} を書かなければ id は付きません。完全な手動モード。

## Keystatic とは                               // 普通に書く → id なし
## Markdoc テーブル問題 {#markdoc-table-issue}   // 明示 → id="markdoc-table-issue"

これに rehype-autolink-headings を組み合わせて、「id が付いた h2 の横にチェーンアイコンを出す」UI を足しました。アイコンが出るのは自分が deep link を意図した場所だけ。未設定の見出しはクリーンなままです。

<h2>Keystatic とは</h2>                         <!-- アイコン無し、クリーン -->
<h2 id="markdoc-table-issue">
  Markdoc テーブル問題
  <a class="heading-anchor" href="#markdoc-table-issue">
    <span class="heading-anchor-icon"></span>   <!-- チェーンアイコン -->
  </a>
</h2>

これで先ほど挙げた 3 つの不満がすべて解消です。

  • 意図したアンカーだけ存在する(ノイズゼロ)
  • {#my-id} は英語短縮形で書けるので URL は綺麗(#markdoc-table-issue
  • JA/EN 両記事で同じ {#my-id} を書けば言語横断で同じ URLで deep link 可能

実装で引っかかった小ネタ

実装中にいくつか小さな罠と学びがあったのでメモっておきます。

rehype-sanitize が user-content- を勝手に付ける

rehype-sanitize を入れていると、デフォルトで全ての id に user-content- という接頭辞を自動で付けてくれます。 これは hast-util-sanitize の「clobber 防止」機能で、ユーザー投稿 HTML が id="login" のようなページ自身の id と衝突するのを防ぐためのセキュリティ配慮です。
とはいえ自分の Markdown をビルドするだけのブログでは不要なので、スキーマで無効化:

const sanitizeSchema = {
  ...defaultSchema,
  clobberPrefix: "",    // user-content- を無効化
  // ...
};

これを知らないと #markdoc-table-issue と書いたつもりが実際は #user-content-markdoc-table-issue になっていてリンクできてない!という残念な事故が発生します。

Keystatic で編集した時の挙動

このブログは Keystatic(Markdoc ベースの Git-based CMS)も併用していて、Markdown が Keystatic を通ると一部の構文が書き換えられます(GFM テーブルが {% table %} 形式に変換される等、詳しくは別記事)。

Pandoc 由来の {#id} が Keystatic で編集した際にちゃんと残るかは事前にはわからなかったので実地検証しました。 結果は 完全保持。Keystatic のパーサーは {#id} を未知の構文として扱ってテキストそのままパススルーしてくれるようで、安心して使える構文だと確認できました。

翻訳パイプラインでの保護

AI 翻訳で {#my-id} が残るかも保証が必要です。
LLM は「よかれと思って」ブレースの中身まで勝手に翻訳してしまうことがあるんですよね({#markdoc-table-issue}{#markdocテーブル問題} に変えられる等)。 なので、scripts/translate.ts のプロンプトでルールを明示する必要がありました。

Preserve curly-brace annotations verbatim — do not translate their contents.
Examples: heading anchors like `## Heading {#my-id}`,
image/element attributes like `![alt](src){width=400px align=right}`.

画像の {width=... align=...} も同じ Pandoc 系の属性記法なので、ついでに保護対象に含めています。

設計のトレードオフ

一応、デメリットらしき点もあります。
「この記事のココを deep link で参照したい!」という奇特な読者が現れた時、意図したサブセクションにアンカーがない、という事態もあるかもしれません。 ただ、駄文を垂れ流しているだけのこのマイナーなブログでそんな事態はまぁ万が一にも起きないと思いますし、もしもの時には普通に記事にリンクしてもらえば良いだけです。

rehype-slug 全自動が合うケースもあり、どちらを選ぶかはブログの使われ方次第です。

判断軸rehype-slug 全自動向きremark-heading-id 単体向き
言語単一言語のみ多言語、アンカー共有したい
運用検索・SNS 流入が多く、
見出しは公開後ほぼ固定
固定読者で、
公開後も見出しを書き直す
アンカー設計全見出しに自動で振る意図した節だけ手動で振る

このブログはどちらかというと後者寄りです。 さっきも書いたように超マイナーな個人ブログで読者数は底辺ですし、記事は draft の間に見出しを頻繁に書き直すし、JA/EN 両方運用している。
rehype-slug の便利さよりも、{#foo} を書く時の「ここは外部から参照されていいぞ」という意図表明の方が、情報設計として合っています。

まとめ

このあいだ、別の記事でもネタにしたのですが、多くのエンジニアが座右の銘としている格言に「車輪を再発明するな」というのがあります。
が、便利な車輪を「使わない」という判断にもまた、車輪を利用するのと同じくらい、考える価値があると思います。

rehype-slug は優れたプラグインですが、自動化することで失うもの(意図、URL の綺麗さ、言語横断)がある。 そのトレードオフが自分のブログに合っていないと気付いたら、便利さ以外の選択肢も視野に入れていい、というのが今回の結論です。
なんだかスローライフのすすめみたいな話になっちゃいますが…

{#my-id} を毎回手で書くのは少し面倒ですが、「ここは deep link する値のある節だ」と一呼吸置いて判断するプロセスは、記事の情報設計そのものの質を上げる副次効果もあります。
手間ではなく、設計のためのマイルストーンとして考えれば、これもまた良し、です。

ちなみに、この記事にも deep link する価値のある節があれば {#bar} を書こうかな、と思って見返してみたのですが、残念なことに特にありませんでした(爆