---
title: 見出しに id と permalink を付けた
description: セクションの見出しすべてに id を振って、末尾に常時表示の # リンクを置くようにした。id の決め方は GitHub 風を目指したのだが、3 言語で同じ規則を書く都合で、結果的には GitLab 式のカテゴリー判定に落ち着いた。
prev: 2026-10-24-alerts.md
next: 2026-10-25-preview.md
---

見出しの右に `#` リンクを付けた（[issue #69](https://github.com/podhmo/html-sandbox/issues/69)）。この記事の見出しにも付いているはずなので、試しに長押しや右クリックしてみてほしい。スマホだと hover が効かないので、常時表示の `#` を選んでいる。GitHub 流の「hover したら出る」はモバイルで届かない。

## なんで今更

`prev` / `next` で記事同士を繋いだとき、`#見出し` へのフラグメントを書ける道は以前からあった。ところが飛び先がなかった。tinymarkdown の `Section` は `<section>` と `<hN>` を出すだけで、どこにも id がなかったのだ。id を振って、それをコピーできる入口も欲しかった。

## id の決め方、案の比較

最初に 5 案を整理した。

- **GitHub 風 slug**: タイトルを小文字化して空白を `-` に、記号を除去。`MoonBit の話` → `moonbit-の話`。読めるし意味を持つ。
- **ASCII だけの slug**: non-ASCII を落とす。このブログは日本語の見出しが主体なので、空の id が続出すると見込んで却下。
- **連番** `sec-1` など: 実装は一番軽いが、見出しの挿入で番号がずれてリンクが壊れる。
- **ハッシュ**: 一意だが URL が読めない。
- **`{#id}` 記法**: 一番安定。だが tinymarkdown の構文拡張が要り、3 実装+テストのミラーが全部増える。

`{#id}` は「翻訳間でも id を保つ」なら本命なのだけど、今のところこのブログは 1 言語しか書いていない。機能拡張として Future work に回して、まずは自動採番を入れた。

## GitHub 風のつもりが GitLab 式になった

ここが紆余曲折その一。「github-slugger 互換」を掲げたのだが、実装に入って気づいた。github-slugger 本体は、除去対象文字を巨大な正規表現に列挙したもので、それを Go / MoonBit / TypeScript の 3 言語に写すのは正確さも保守も厳しい。しかも MoonBit の `Char` には Unicode の小文字化も `is_alphabetic` もなく、`is_numeric` / `is_whitespace` / ASCII 判定しかない。

そこで逆に、残すものを定義する GitLab 17.0 以降の式に寄せた。「文字・数字・`-`・`_` を残し、それ以外の記号・絵文字を落とす」。Unicode のカテゴリー判定が使えない MoonBit でも書けるよう、落とす文字の範囲テーブル（絵文字・CJK 句読点・罫線・矢印・ラテン1補助記号など）を 1 枚持って、3 実装が同じ表をなめる形にした。結果的に「文字種で決める GitLab 式」であり、通常の見出しでは GitHub と結果が変わらない。小文字化も ASCII と全角 `Ａ-Ｚ` だけに絞って、3 言語で完全に同じルールにした。

## 空白は除去じゃなくて `-` 変換だった

紆余曲折その二。自分の整理では「空白も記号と一緒に除去」と書いていたのだが、GitHub の実際は空白を `-` に変換する。指摘を受けて直した。変換対象は Unicode の空白全般（全角スペースも）。連続空白は潰さず `a  b` → `a--b` になる、これも GitHub に合わせた。

細かいところで、JavaScript の `\s` は Unicode の White_Space と微妙に集合が違う（U+0085 を含まず U+FEFF を含む）。Go の `unicode.IsSpace` と MoonBit の `is_whitespace` は White_Space 準拠なので、TS 版だけは正規表現に頼らず範囲で書いて揃えた。

## その他の決定

- **対象は h1〜h5 全部**: 当初「とりあえず h3 まで」だったが、絞る理由も薄かったので全部にした。
- **絵文字は除去**: `😀 Emoji` は `-emoji` になる。先頭の `-` が残るのは GitHub 互換。
- **重複は `-1`, `-2`**: dedup も github-slugger と同じで、生成した id も登録する。`foo` が 2 回現れて `foo`, `foo-1` になったあと、タイトルがそのまま `foo-1` の見出しが来ると `foo-1-1` になる。
- **id は `<hN>` に**: `<section>` ではなく、GitHub と同じく見出し側に。
- **パーマリンクの `#` は Raw ノード**: テキスト描画（メタ情報用の plain text 変換）が拾わないので、タイトルの整形が崩れない。

## レビューで拾われた抜け

Devin Review が、front matter だけでタイトルが付くページを指摘した。本文に見出しがない場合、タイトルを補完して `<h1>` を出す経路があり、そこは id もリンクも付いていなかった。ページの一番上、一番リンクしたい場所なので対応して、3 実装とも補完 h1 も同じ gen で採番するようにした。`docs/notes` のようなファイル名タイトルでも id が届く。

## 確認

いつも通り Go / MoonBit / ts で規則をミラーし、slugify のテストケースも 3 実装で同じものを回している。最後に Go サーバーと wasm ビルドの実ページで本文を総当たり diff して、バイト一致を見た。
