GitHub 上の Markdown が TOC(目次) を表示してくれないのでどうしようか → ツール自製したよって話


GitHub は Markdown ファイルを表示する時に TOC(Table Of Contents目次 の意)を自動で挿入してくれない点がイケてないと思います。

私は TOC が無いと死んじゃう病なので何とかするしかありませんでした。結局 intoc という TOC 生成ツールを作りましたが、ここに至るまでに色々ありましたのでまとめたいと思います。

対象読者

以下の方には有益かもしれません。

  • ちょうど TOC 生成ツールを作ろうと思っていた
  • TOC 生成ツールは知っていたが、日本語見出しに対応したものがなくて困っていた
  • TOC 生成ツールの実装例に興味がある
  • GitHub 界隈の TOC 事情をチラ見したい
  • TOC 好きとして TOC に対する思いを垣間見たい

GitHub には TOC 機能が無い

TOC は Wiki では当たり前の機能です。細かい文法は wiki 次第ですが [[PageOutline]] やら #contents やら __TOC__ やら書くことで挿入できます。

そういえば Qiita にも TOC はありますね。画面右に表示されてるやつです。文法を書かなくとも自動で表示してくれますし、今見ている見出しの位置もハイライトしてくれるので非常に見易いです。素晴らしいと思います。

しかし GitHub には TOC がありません。サポートにリクエストしてみたところ、「ツールでできるでしょ?」とのこと。実際、その手のツールは多数存在します

ツールを使えばいいのでは?

「ならばツールを使えばいいのでは?」と思われるかもしれませんが、そうもいかなかったりします。というのも既存ツールには以下問題があったからです。

※これは Windows で日本語テキストを書く 私の場合です。もし UNIX で英語しか書かない人であれば問題にはならないでしょう

問題1: Windows に対応していない

TOC 生成ツールとしては ShellScript など Windows では使えない手段で作られたものが多いです。

問題2: 愛用テキストエディタから使えるプラグインが無い

どうせ TOC を生成するからには、愛用のテキストエディタからサクっと挿入したいものです。

vim用Sublime Text用 のプラグインはあるのですが、私が愛用する秀丸エディタ用はありません。

問題3: 日本語の見出しに対応していない

詳しくは後述しますが、ツールで生成した TOC の中には 日本語の見出しに正しくリンクされない ものも多いです。日本語の見出しにジャンプできるよう変換する処理が入っていないのが原因です。まあ CJK に対応する物好きはそうはいないでしょうから仕方ありません。

問題4: 遅い

TOC を生成するツールの中には GitHub API を用いるものがあります。この API は、Markdown のテキストをデータにして POST すると、HTML でレンダリングされたデータが返ってくるというものです。

このやり方は GitHub 側の機構を利用するため、日本語も含めて確実に処理できますが、REST API を用いるため遅い です。1秒くらいかかります。TOC を更新するたびに1秒くらい待たされるというのはストレスです。

よし、オレオレ TOC 生成ツールを作ろう

というわけで自製することにしました。

[1/3] TOC の仕組み

ツールを作る前に、まずは TOC について調べます。

本節では TOC を生成するために必要な知識を整理しています。

GitHub 上で TOC を実現する仕組み

まず GitHub Flavored Markdown では、HTML でいうところの アンカーリンク に相当する機能があります。

書き方は [Usageのセクションにジャンプしますね](#usage) こんな感じです。これで usage というアンカーにジャンプするリンクになります。なお、アンカー自体は # Usage と書いた見出しに自動的で付与されます(GitHub が HTML にレンダリングする時に付与します)。

TOC は、この仕組みを用いて作ります。つまりは目次として「各見出しにジャンプするリンクを、リスト表記で並べる」ことになります。以下のイメージです。

# TOC
- [Installation](#installation)
- [Usage](#usage)
- [License](#license)

# Installation
...

# Usage
...

# License
...

アンカー名生成アルゴリズム

ここで問題となるのが Markdownの見出し名からどんなアンカー名が生成されるか(GitHubはどう変換しているのか) ということです。「え?そのまま使うんじゃないの?」と思われるかもしれませんが、そうはなりません。

以下にクイズを挙げます。 部分はどうなると思いますか?

  • # installationinstallation
  • # Installationinstallation
  • # Installation!!
  • # In sta llation
  • # いんすとーる
  • # ★インストール★

厄介なことに、この部分の仕様が実はわかっていません(私の情報収集能力が足りないだけでしょうが )。サポートに聞いてみたところ、GitHub Flavored Markdown Spec を見よと言われたのですが、見た感じ、この部分の仕様は記述されていないように見えます。

二種類のアプローチ

上記を踏まえた上で、TOC を生成するためのアプローチは二つあります。

  • (方法1) GitHub API を使う
    • 確実に変換できる
    • REST API で通信するため遅い
  • (方法2) 自力で Markdown ファイルをパースして TOC を作る
    • 速い
    • アンカー名生成アルゴリズムの実装が必要

[2/3] 作成したツール intoc について

上記を踏まえ作成したのが intoc です。名前は安直に INsert TOC から取っています。とりあえず自分用なので色々作り込みとか英語とか甘いでしょうが、見逃してあげてください。でもツッコミも歓迎です。

  • 特徴
    • Windows + 秀丸エディタ + Python2.7
    • GitHub API を使ってないので速い
    • 日本語の見出しにも対応している
    • 自動挿入(<!-- toc --> と書いた行の次行に TOC を挿入する)

本節では細かい実装内容や検討内容をつらつら書きます。

GitHub API は使わない

遅いのには耐えられないので、自力でパースする道を選びました。

アンカー名生成アルゴリズムの実装

「GitHub API を使わない」となると、自力でアルゴリズムを調べねばなりません。私は実験用のリポジトリ stakiran/test_anchor_rendering を作ってゴリ押しで調べました。

実装したコードについては、intoc でいうと intoc/intoc.py at d541b893f36fedf3f772fdbacdd6cd8c77891889 ・ stakiran/intoc このあたりになります。

調べた結果を簡単に書いておくと、

  • lowercase 化される
  • ハイフンとアンダースコア以外の半角記号は削除される
  • スペースはハイフンに置換される
  • 全角記号は削除される
  • それ以外はそのまま使用する
    • ただし既に同名のアンカーが存在する場合は -1, -2, ...というふうに末尾に数字を付与する

という感じでした。

当該部分のコードも貼りつけておきます。

def sectionname2anchor(sectionname, duplicator):
    ret = sectionname

    ret = ret.lower()
    ret = ret.replace(' ', '-')

    # remove ascii marks excxept hypen and underscore.
    remove_targets = '[!"#$%&\'\\(\\)\\*\\+,\\./:;<=>?@\\[\\\\\\]\\^`\\{\\|\\}~]'
    ret = re.sub(remove_targets, '', ret)

    # remove Japanese marks
    remove_targets = u'[、。,.・:;?!゛゜´`¨^ ̄_ヽヾゝゞ〃仝々〆〇‐/\~∥|…‥‘’“”()〔〕[]{}〈〉《》「」『』【】+-±×÷=≠<>≦≧∞∴♂♀°′″℃¥$¢£%#&*@§☆★○●◎◇◆□■△▲▽▼※〒→←↑↓〓∈∋⊆⊇⊂⊃∪∩∧∨¬⇒⇔∀∃∠⊥⌒∂∇≡≒≪≫√∽∝∵∫∬ʼn♯♭♪ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩαβγδεζηθικλμνξοπρστυφχψωАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя─│┌┐┘└├┬┤┴┼━┃┏┓┛┗┣┳┫┻╋┠┯┨┷┿┝┰┥┸╂。「」、・ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙゚①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ㍉㌔㌢㍍㌘㌧㌃㌶㍑㍗㌍㌦㌣㌫㍊㌻㎜㎝㎞㎎㎏㏄㎡ ㍻〝〟№㏍℡㊤㊥㊦㊧㊨㈱㈲㈹㍾㍽㍼≒≡∫∮∑√⊥∠∟⊿∵∩∪]'
    uret = re.sub(remove_targets, u'', ret.decode('utf-8'))

    # In GFM, do numbering if there is a duplicated anchor name.
    #
    # Ex.
    #   # section1
    #   # section1
    #   # section1
    #       VVV
    #   href="#section1"
    #   href="#section1-1"
    #   href="#section1-2"
    ret = uret.encode('utf8')
    dup_count = duplicator.add(ret)
    if dup_count>0:
        ret = ret + '-{0}'.format(dup_count)

    return ret

日本語アンカー名のエンコーディング

実を言うと、- [インストール][#インストール] のようにアンカー名を日本語で指定するのは、仕様上は正しくありません。正しく動くのはブラウザが賢く振る舞っているからです。

- [インストール][#%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB] のように URL Encode した文字列を指定するのが正しい書き方 です。

しかし、最近のブラウザなら日本語で書いていても問題無く解釈してくれる と思うので、日本語のままでも問題無いでしょう。とりあえず手元の Firefox 54 では解釈してくれました。

見出し表記のパース

TOC を作るためには、Markdown ファイルの中身をパースして見出し部分を取り出さねばなりません。

しかし愚直に # から始まる行を取り出せばいい、というわけではありません。もっというと コードハイライト部分を除外してやる 必要があります。

以下は Python のコード例ですが、Python はコメントを # comment と書くので、愚直に拾うとこいつらも見出しとして取り出されてしまいます。

# -*- coding: utf-8 -*-   ★この行は見出しではない

import os
import re
import sys

...

TOC を自動で挿入する

出来れば TOC は自動で挿入されてほしいです。いちいちコピペするのはだるすぎます。

これをローカルのテキストエディタ上で実現するには、方法は二つしかありません。

  • 方法1: エディタの機能を使う(プラグインなど)
  • 方法2: 元ファイルをツール側で更新&エディタ側で再読込する

私の場合、秀丸エディタを使っているのですが、方法1 は無理そうだったため、方法2にしました(以下秀丸エディタマクロの細かい話が続きます)。

流れとしては以下のイメージ。

  • (1)秀丸エディタから intoc を呼び出す
  • (2)intoc が TOC を自動挿入する
  • (3)挿入後、秀丸エディタ上でファイル変更を検知してくれるので、再読込させる(ちなみに秀丸エディタの設定により確認ダイアログ無しで読み込ませることもできたと思います)

これを実現するため、intoc を起動する秀丸マクロを作り、

$program = "python " + currentmacrodirectory + "\\..\\..\\..\\intoc\\intoc.py";
$args = "--edit -i %f";
run $program + " " + $args;

これをマクロ登録して、秀丸エディタから呼び出せるようにしました。

現在、私の秀丸エディタでは

  • TOC を入れたい Markdown ファイルに <!-- toc --> の目印を書く
  • 秀丸エディタのツールバーボタンから intoc ボタンを押す

これだけで TOC を挿入できるようになっています。一度目印を書けば、あとはクリック一発で挿入できます。

TOC の更新

TOC 挿入処理で悩ましいのが、既に TOC が挿入されていた場合はどうするねん? ということです。愚直に append するだけでは、n 回生成を行うと n 個の TOC が並んでしまうため論外です。

アイデアとしては 「既存の TOC があれば先にそれを削除する」、その後で生成した TOC を挿入する、となります。ベターな実装方法は

<!-- toc start -->

<!-- toc end -->

のように「挿入範囲の始点と終点を、TOC を入れたい Markdown ファイルに書きなさい」とすることです。これなら始点と終点の間に TOC があるわけですから、既存の TOC を消すのも簡単です。この範囲内を丸々消せばいいだけです。

が、これ、いちいち Markdown ファイルに書くのが面倒くさいんですよね。なので私は

<!-- toc -->

この一行だけで済むようにしました。この時、問題となるのが「TOC 部分の終点をどうやって検出するん?」ということですが、これは 「リスト表記が終わったら」 にしました。

# intoc
Generate a markdown TOC for a README or any GFM files.

<!-- toc -->
- [intoc](#intoc)
  - [Install](#install)
  - [Requirement](#requirement)
  - [Usage](#usage)
  - [CLI](#cli)
  - [License](#license)
  - [Author](#author)   ★ ここが TOC の終わり

## Install
...

Python で書けば、行単位でパースしているとして if line.lstrip()[0:1]=='-': みたいなイメージになります。

[3/3] やりのこしたこと

intoc に実装し損ねたことを簡単に書いておきます。

TOC のリアルタイム反映

今の intoc では、ユーザー側がコマンドを叩いて始めて TOC 生成が走ります。叩き忘れたら更新されません。TOC だけ更新するの忘れてコミットしちゃった、なんてことがよくあるので、これは何とかしたいところです。が、秀丸エディタ上では仕様的に難しそう。

UTF-8以外のファイルも扱えるようにする

今の intoc は input も output も UTF-8 しか想定していません。現状困ってはいませんが、本格的に他者向けに公開したいなら他の文字コードでも扱えるようにした方が優しいでしょうね。

- list 表記だけでなく * list 表記もサポートする

今の intoc は私の好みで - list 表記しかサポートしてません。これもあまり優しくないですね。

二行見出し表記の対応

今の intoc は私の好みで二行見出し、

intoc
=====

↑こんな書き方の見出しに対応していません。二行見出し、個人的には嫌いなのですが(エディタ等でハイライトできなくない?)、使っている方は多いです。対応した方が優しいでしょうね。

おわりに

とりあえず最低限 TOC を表示させる術は整えられたと思っています。まだ微妙に不便なので、引き続き改善していきたいです。

それはそうと、GitHub さん、早く TOC をサポートしてくれないかなぁ……。