見出しに id と permalink を付けた #

見出しの右に # リンクを付けた(issue #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 と全角 A-Z だけに絞って、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 して、バイト一致を見た。