Markdownで長い文書を書くと、見出しが増えるほど目次の手直しがふくらんでいきます。今回は見出しから目次(TOC)を自動生成するCLIツールを、PythonのTyperとmarkdown-it-pyで作ってみました。previewで内容を確認し、insertでファイルへ冪等に書き込み、sampleでお試し用の文書も用意できます。
実際に5回のコマンドで動かし、すべて終了コード0で正常に動作しました。トークン解析やアンカー生成の勘所を、初心者でも順を追って追体験できるように解説します。
Typerの基本概念、要件定義、実装、動作確認までを順番に学べる構成です。動画は目次から確認したい場面へ移動でき、本文だけでも手順と考え方が完結します。
動画の内容をテキストで確認する
オープニング。Typerとmarkdown-it-pyを使ってMarkdown目次生成CLIを作るカリキュラムを始めます。概要紹介。
Typerとmarkdown-it-pyの役割と使い方を学ぶMarkdown目次生成CLIの要件を整理する 完成コードと実行結果を確認する 最後に実コマンドとファイル状態で完成挙動を確かめる 具体的にやること。
sampleコマンドで見出し付きのサンプルMarkdownを書き出すpreviewコマンドで生成した目次を標準出力へ表示するinsertコマンドで目次をファイルのマーカー区間へ書き込む --min-levelと--max-levelで目次に含める見出しレベルを指定する 見出しテキストからアンカーを生成してリンク付き目次を作る 実装環境・必須アプリ。
OS:Windows 11 Pro Python:3.13.3シェル:PowerShell 5.1必須アプリ:コードエディター、ターミナル、エクスプローラー パッケージ:typer、markdown-it-py Typerとmarkdown-it-pyとは。
Typer:Typerは、Pythonの関数をそのままコマンドとして呼び出せるようにするCLI構築ライブラリですmarkdown-it-py:markdown-it-pyは、Markdownを意味のある単位であるトークンへ分解して扱う解析ライブラリです Typerで目次生成CLIを作る際の重要ポイント。
build_tocは最も浅いレベルを字下げの基準にする 字下げはレベル差の分だけ空白で作るslugifyは記号除去と空白のハイフン化を行う Markdown目次生成CLIの要件定義。
5回のコマンドがすべて終了コード0で完了するsampleが見出し10件を含むサンプルを書き出すpreviewが入れ子の箇条書き目次を表示するinsertがマーカー付きの目次をファイル先頭へ挿入するinsertの再実行で目次が更新され二重化しない 確認1/4: サンプルのMarkdownを作成する。
sampleコマンドでお試し用の見出し付きMarkdownを作成し、作成先と見出し件数を確認します。実行2/4: 生成した目次をプレビューする。sampleで作った文書に対してpreviewを実行し、入れ子になった目次が画面に出ることを確かめます。
確認2/4: 生成した目次をプレビューする。sampleで作った文書に対してpreviewを実行し、入れ子になった目次が画面に出ることを確かめます。実行3/4: 目次をファイルへ挿入する。
insertで目次を生成してファイルへ書き込み、挿入結果のメッセージと生成された目次を確認します。確認3/4: 目次をファイルへ挿入する。insertで目次を生成してファイルへ書き込み、挿入結果のメッセージと生成された目次を確認します。
実行4/4: 再実行で目次が更新されることを確認する。insertをもう一度実行し、目次が増えずに更新へ切り替わることを確認します。確認4/4: 再実行で目次が更新されることを確認する。
insertをもう一度実行し、目次が増えずに更新へ切り替わることを確認します。学習内容のまとめ。
typer.Typer()でアプリ本体を1つ用意するMarkdownIt()で解析器を作るbuild_tocは最も浅いレベルを字下げの基準にするmarkdown-it-pyのトークン解析でコードブロック内の#を見出しと誤検出しない 小さく実行確認しながら完成状態まで段階的に組み立てる エンディング。Python研修はCodeCampでご確認ください。
Typerとmarkdown-it-pyとは
今回使用する主要なライブラリについて、役割と使い分けを順番に確認します。
CLIを作るライブラリのTyper
Typerは、Pythonの関数をそのままコマンドとして呼び出せるようにするCLI構築ライブラリです。関数に型ヒントを付けるだけで、引数の受け取りや値の検証、--helpの自動生成までまかせられます。
今回のツールではtyper.Typer()でアプリ本体を作り、sample・preview・insertの3関数へ@app.command()を付けてサブコマンドにしました。argparseを手書きするより記述量が減り、コマンドの意図がコードから読み取りやすくなる点も学習に向いています。
Typerで3つのサブコマンドを組み立てるときに使った主なAPIと実行の形は次のとおりです
- typer.Typer()でアプリ本体を1つ用意する
- @app.command()で関数をサブコマンド化する
- typer.Argumentで必須の位置引数を定義する
- typer.Optionで--min-levelなどの任意オプションを定義する
- add_completion=Falseで補完機能を省く
- サブコマンドと引数を渡してコマンドを実行する
Markdownをトークンへ解析するmarkdown-it-py
markdown-it-pyは、Markdownを文字列として力任せに探すのではなく、意味のある単位であるトークンへ分解して扱う解析ライブラリです。MarkdownIt().parse()に文書を渡すと、見出しの開始を表すheading_openや本文を持つinlineといったトークンが順番に並んだ一覧が返ります。
このツールではheading_openのtagからh2のようなレベルを読み取り、直後のinlineから見出しの文字列を取り出しました。正規表現で#を数える方式と違い、コードブロックの中の#を見出しと取り違えない堅さが得られます。
markdown-it-pyで見出しを取り出すまでに登場する解析APIとトークンの見方は次のとおりです
- MarkdownIt()で解析器を作る
- parse(text)でトークンの一覧を得る
- heading_openのtagはh1からh6を表す
- int(token.tag[1])でレベルの数値を取り出す
- 直後のinlineのcontentが見出し文字列になる
Python・Typerで開発する場合の環境構築
この記事のセットアップ手順と掲載コードは、Windows 11 Pro、PowerShell 5.1、Python 3.13.3、Typer 0.27.0で動作確認しています。仮想環境を有効化せず、その中のPythonを直接指定するため、以下のコマンドはPowerShellとコマンドプロンプト(cmd)の両方で使えます。
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install typer markdown-it-py
macOS・Linuxでは仮想環境内のPythonパスが異なります。今回の動作確認環境とは異なるため、以下は環境差分を補う参考手順です。
python3 -m venv .venv
./.venv/bin/python -m pip install typer markdown-it-py
- pipのパッケージ名はmarkdown-it-pyですが、コード内のimportはmarkdown_itです。ハイフンとアンダースコアの違いでImportErrorになりやすい点に注意します。
- Typerはインストールするだけで色付きヘルプが使え、関数に@app.command()を付けるだけでサブコマンドを追加できます。
Markdown目次生成CLIの要件定義
目的は、PythonのTyperとmarkdown-it-pyで、Markdownの見出しから目次を自動生成するCLIツールを作れるようになることです。
対象者として、PythonでのCLI開発とMarkdown解析を、手を動かしながら実践的に学びたい人を想定しています。
完成物は、preview・insert・sampleの3コマンドを備えたMarkdown目次生成CLIツールです。
実装へ入る前に、機能・品質・受け入れ条件を分けて確認します。
機能要件
- sampleコマンドで見出し付きのサンプルMarkdownを書き出す
- previewコマンドで生成した目次を標準出力へ表示する
- insertコマンドで目次をファイルのマーカー区間へ書き込む
- --min-levelと--max-levelで目次に含める見出しレベルを指定する
- 見出しテキストからアンカーを生成してリンク付き目次を作る
- 見出しレベルの差を字下げにして入れ子の目次を組み立てる
非機能要件
- markdown-it-pyのトークン解析でコードブロック内の#を見出しと誤検出しない
- マーカー区間を置き換えるので再実行しても目次が増殖しない
- slugifyが日本語の見出しをそのまま残しリンク先と一致させる
- ファイルの読み書きをUTF-8で統一する
- add_completion=Falseでシェル補完機能を無効化する
- previewはファイルを変更せず表示だけ行う
実装方針
今回はTyperとmarkdown-it-pyの基本動作を追いやすくするため、Markdown目次生成CLI本体を1つのPythonファイルへまとめます。
入力、判定、結果表示の役割を分け、実行結果を確認しながら機能を積み上げます。
Markdown目次生成CLIを安全に組み立てるための実装方針は次のとおりです。
- markdown-it-pyのトークン解析でコードブロック内の#を見出しと誤検出しない
- マーカー区間を置き換えるので再実行しても目次が増殖しない
- slugifyが日本語の見出しをそのまま残しリンク先と一致させる
- ファイルの読み書きをUTF-8で統一する
- add_completion=Falseでシェル補完機能を無効化する
- previewはファイルを変更せず表示だけ行う
完成と判断する条件
- 5回のコマンドがすべて終了コード0で完了する
- sampleが見出し10件を含むサンプルを書き出す
- previewが入れ子の箇条書き目次を表示する
- insertがマーカー付きの目次をファイル先頭へ挿入する
- insertの再実行で目次が更新され二重化しない
Typerで目次生成CLIを作る際の重要ポイント
このツールの心臓部は、抽出した見出しの一覧から目次を組み立て、ファイルへ安全に書き戻す一連の処理です。build_tocは見出しレベルの差を字下げへ変換し、入れ子になったMarkdownの箇条書きを作ります。slugifyでは見出しテキストを小文字化して記号を取り除くので、GitHub風のアンカーが得られ、目次のリンクが実際の見出しへ正しく飛びます。
apply_tocはマーカー区間だけを置き換えるため、何度実行しても目次が二重に増える心配はありません。
目次の組み立てとアンカー生成、そしてファイル書き込みで押さえたい処理の勘所は次のとおりです
- build_tocは最も浅いレベルを字下げの基準にする
- 字下げはレベル差の分だけ空白で作る
- slugifyは記号除去と空白のハイフン化を行う
- 文字クラスが日本語を含むので日本語見出しも残る
- apply_tocは戻り値で挿入か更新かを知らせる
見出しレベルで字下げする入れ子目次の作り方
build_tocは、対象の見出しの中で最も浅いレベルを基準(base)として選びます。各見出しは、自分のレベルとbaseの差の分だけ空白で字下げされるので、階層が視覚的に伝わる箇条書きになります。min_levelとmax_levelの範囲に合う見出しが1件も無いときは、空の文字列を返す設計です。
build_tocが入れ子の箇条書きを作るときの規則は次のとおりです
- min_levelとmax_levelで見出しを絞り込む
- 1段深いごとに空白2つ分だけ字下げする
- 対象が無ければ空文字を返す
見出しからアンカーを作るslugifyの処理
目次のリンクが正しく飛ぶには、見出しに対応するアンカー文字列が必要です。slugifyはテキストを小文字にし、記号を取り除いてから、連続する空白をハイフンへ置き換えます。文字クラスが日本語を含むため、日本語の見出しでもリンク先が実際の見出しと一致します。
slugifyがアンカー文字列を整える手順は次のとおりです
- 先に小文字化してから記号を取り除く
- 連続する空白を1つのハイフンにまとめる
- 英数字と日本語と空白とハイフンを残す
マーカー区間へ冪等に目次を書き込む仕組み
apply_tocは、TOC_BEGINとTOC_ENDで挟まれた区間を正規表現で探します。すでにマーカーがあれば中身だけを新しい目次へ置き換え、無ければ本文の先頭へ目次ブロックを足す仕組みです。この判定のおかげで、何度実行しても目次は二重にならず、常に最新の見出し構成へ更新されます。
apply_tocが冪等に書き込むための判定は次のとおりです
- マーカー区間を正規表現で探す
- 既存マーカーは中身だけ差し替える
- 無ければ本文の先頭へ目次を追加する
- 戻り値で挿入か更新かを知らせる
PythonでMarkdown目次生成CLIの完成コード
動画の内容をテキストで確認する
INTRO: Typerを使ったCLI開発を開始。コードを1行ずつ入力し、節目ごとに実行結果を確かめながら完成させます。LINE 001: モジュールの概要説明。
このファイル全体が何をするプログラムなのかを、三重引用符で書くモジュールの説明文(ドックストリング)の冒頭で示しています。Markdownの見出しから目次を作るコマンドラインツールだと宣言している部分です。LINE 003: 使うライブラリの予告。
説明文の続きで、コマンドの仕組みをTyperで組み立て、Markdownの解析にはmarkdown-it-pyというライブラリを使うことを述べています。この後のコードに登場する道具をあらかじめ伝えている行です。LINE 004: 処理の流れの説明。
h1からh6までの見出しを取り出し、リンク用のアンカーを付けた目次を組み立てる、という処理全体の流れを説明しています。ツールが目指すゴールを一文でまとめた部分です。LINE 005: 説明文の終わり。
三重引用符でモジュールの説明文を閉じています。ここまでがファイル全体の概要で、この後から実際のコードが始まります。LINE 007: 型注釈の遅延評価を有効化。
この一文を書くと、型ヒントがその場で評価されず文字列として扱われるようになります。list[dict]のような新しい書き方を、古いPythonでも安全に使えるようにするためのおまじないです。LINE 009: 正規表現モジュールの取り込み。
標準ライブラリのreを読み込んでいます。後半でアンカーから記号を取り除いたり、目次の差し込み位置を探したりするときに使います。LINE 010: パス操作クラスの取り込み。
pathlibからPathを読み込んでいます。ファイルの読み書きを、OSの違いを気にせず扱えるようにするためのクラスです。LINE 012: Typer本体の取り込み。
コマンドラインツールを手軽に作れるライブラリTyperを読み込んでいます。この後のサブコマンドや引数の定義で使います。LINE 013: Markdownパーサーの取り込み。
markdown-it-pyからMarkdownItクラスを読み込んでいます。Markdownの文章を解析して見出しを取り出す、中心的な部品になります。LINE 015: Typerアプリの作成。
typer.Typerでコマンドの入れ物となるアプリを作っています。add_completion=Falseで補完機能を無効にし、helpにツールの説明文を設定しています。LINE 017: マーカーの目的コメント。
次の二行で決める目印について、目次を書き込む範囲を示すためのものだと説明しています。同じ場所を何度でも安全に書き換える、冪等な再生成を実現するための工夫です。LINE 018: 目次開始マーカーの定義。
目次の始まりを表す目印を定数として決めています。Markdownのコメント形式なので、表示上は見えずに範囲だけを示せます。LINE 019: 目次終了マーカーの定義。
目次の終わりを表す目印を定数として決めています。開始と終了の二つで挟まれた区間が、後で目次を差し替える対象になります。LINE 022: 見出し抽出セクションの区切り。
ここから見出しを取り出す処理を書く、という区切りを示すコメントです。コードのまとまりを読みやすく整理するための目印になっています。LINE 023: 見出し抽出関数の定義。
Markdownの文字列を受け取り、見出しの一覧を辞書のリストとして返すextract_headings関数を定義しています。目次作りの最初の工程を担う関数です。LINE 024: 関数説明文の冒頭。
関数の役割を説明するドックストリングの始まりです。markdown-it-pyで解析して見出しの一覧を返す、という目的を一文で示しています。LINE 026: 解析の仕組みの説明。
説明文の続きで、heading_openというトークンから見出しの深さを、その直後のinlineトークンから見出しの文字を取ることを述べています。この後のコードの下準備を言葉で示した部分です。LINE 027: トークン解析を選ぶ理由。
見出しの文字を取り出すことに加えて、文字を単純に探すのではなくトークンとして解析していることに触れています。次の行で説明する利点につながる部分です。LINE 028: 誤検出を防ぐ説明。
コードブロックの中に書かれた「#」を見出しと勘違いしない、という利点を説明しています。トークン解析だからこそ得られる正確さを伝える一文です。LINE 029: 関数説明文の終わり。
三重引用符で関数の説明文を閉じています。ここから、実際に見出しを取り出すコードが始まります。LINE 030: パーサーの生成。
MarkdownItを呼び出して、解析を行うパーサーの実体を作っています。この後この部品にMarkdownを渡して中身を調べます。LINE 031: Markdownをトークンへ解析。
作ったパーサーのparseに文章を渡し、見出しや段落などの構成要素を表すトークンの並びに変換しています。この一覧をもとに見出しを探していきます。LINE 033: 見出しリストの初期化。
取り出した見出しをためていく空のリストを用意しています。型注釈で、辞書を並べたリストであることを明示しています。LINE 034: トークンを順に走査。
enumerateを使い、トークンを何番目かを表す添字付きで一つずつ取り出しています。この添字は、直後のトークンを参照するために後で使います。LINE 035: 見出し開始トークンの判定。
今見ているトークンが見出しの始まりを表すheading_openかどうかを調べています。当てはまるときだけ、このあとの取り出し処理を行います。LINE 036: 見出しレベルの取得。
タグが「h2」のような文字列なので、二文字目の数字を取り出して整数に変換し、見出しの深さを求めています。コメントのとおり「h2」なら2になります。LINE 037: 本文トークンの取得。
見出しの文字は直後のトークンに入っているため、添字に1を足して次のトークンを取り出しています。これが本文にあたるinlineトークンです。LINE 038: 見出しテキストの整形。
取り出したトークンのcontentから実際の見出し文字を取り、stripで前後の余分な空白を取り除いています。きれいな見出し文字列に整える工程です。LINE 039: 見出し情報の追加。
深さと文字をまとめた辞書を作り、最初に用意したリストへ追加しています。これを繰り返して、見出しを一件ずつ集めていきます。LINE 040: 見出し一覧を返す。
集め終わった見出しのリストを、関数の呼び出し元へ返しています。この一覧が、後で目次を組み立てる材料になります。RUN 1/7: 見出しの抽出を確認する。
extract_headingsが見出しのレベルとテキストを取り出せるか、小さなMarkdownで件数と中身を確認します。CHECK 1/7: 途中実行に成功。3 [{'level
コードはextract_headings・slugify・build_toc・apply_tocという4つの関数と、Typerのサブコマンドで構成しています。関数はそれぞれ方針の4段に対応します。
処理の中心はextract_headingsです。markdown-it-pyが返すトークン列を順に見て、heading_openが現れたら見出しの深さと直後のテキストを取り出します。
組み立てた目次はapply_tocがファイルへ反映します。<!-- TOC -->と<!-- /TOC -->のマーカーを目印にするため、何度実行しても目次が増えません。
このセクションの用語
- トークン
- 文章を解析して得られる最小単位の部品です。
markdown-it-pyは見出しや段落をトークンの列として返します。 - 正規表現
- 文字列のパターンを記号で表す書き方です。
reモジュールで検索や置換に使います。 - スラッグ
- URLやアンカーに使う、記号を除いた短い識別文字列です。見出しのテキストから機械的に作ります。
- アンカー
- ページ内の特定の見出しへ飛ぶためのリンク先の目印です。
#に続く文字列で位置を指定します。
"""Markdownの見出しから目次(TOC)を生成するCLIツール。
Typerでサブコマンドを定義し、markdown-it-pyでMarkdownを解析して
見出し(h1〜h6)を抽出し、アンカー付きの目次を組み立てる。
"""
from __future__ import annotations
import re
from pathlib import Path
import typer
from markdown_it import MarkdownIt
app = typer.Typer(add_completion=False, help="Markdownの見出しから目次を生成するツール")
# 目次を書き込む範囲を示すマーカー(冪等な再生成のため)
TOC_BEGIN = "<!-- TOC -->"
TOC_END = "<!-- /TOC -->"
# 見出しの抽出
def extract_headings(markdown_text: str) -> list[dict]:
"""markdown-it-pyでMarkdownを解析し、見出しの一覧を返す。
heading_open トークンで深さ(h1=1〜h6=6)を、直後の inline トークンで
見出しテキストを取得する。トークン解析なのでコードブロック内の
「#」を見出しと誤検出しない。
"""
md = MarkdownIt()
tokens = md.parse(markdown_text)
headings: list[dict] = []
for index, token in enumerate(tokens):
if token.type == "heading_open":
level = int(token.tag[1]) # "h2" -> 2
inline = tokens[index + 1] # 直後の inline トークンが本文
text = inline.content.strip()
headings.append({"level": level, "text": text})
return headings
# アンカーの生成
def slugify(text: str) -> str:
"""見出しテキストからGitHub風のアンカー文字列を作る。
小文字化・記号除去・空白のハイフン化を行う。\\w が日本語を含むため、
日本語の見出しもそのまま残り、リンク先が実際の見出しと一致する。
"""
slug = text.strip().lower()
slug = re.sub(r"[^\w\s-]", "", slug) # 記号を除去(英数字・日本語・空白・-は残す)
slug = re.sub(r"\s+", "-", slug) # 連続する空白をハイフンへ
return slug
# 目次の組み立て
def build_toc(headings: list[dict], min_level: int, max_level: int) -> str:
"""見出しの一覧から、入れ子のMarkdown箇条書き目次を組み立てる。"""
selected = [h for h in headings if min_level <= h["level"] <= max_level]
if not selected:
return ""
base = min(h["level"] for h in selected) # 最上位の見出しを基準に字下げ
lines: list[str] = []
for h in selected:
indent = " " * (h["level"] - base)
anchor = slugify(h["text"])
lines.append(f"{indent}- [{h['text']}](#{anchor})")
return "\n".join(lines)
# ファイルへの挿入(冪等)
def apply_toc(markdown_text: str, toc: str) -> tuple[str, bool]:
"""マーカー区間に目次を差し込む。既存マーカーがあれば中身を置換する。
戻り値は (新しい本文, 既存の目次を更新したか)。再実行しても目次は
増殖せず、常に最新の見出し構成へ置き換わる。
"""
block = f"{TOC_BEGIN}\n\n{toc}\n\n{TOC_END}"
pattern = re.compile(
re.escape(TOC_BEGIN) + r".*?" + re.escape(TOC_END),
re.DOTALL,
)
if pattern.search(markdown_text):
return pattern.sub(block, markdown_text), True
return f"{block}\n\n{markdown_text}", False
# サンプル文書の作成
@app.command()
def sample(path: str = typer.Argument(..., help="作成するMarkdownファイルのパス")):
"""見出し付きのサンプルMarkdownを書き出す(お試し用)。"""
content = (
"# プロジェクトガイド\n\n"
"## はじめに\n\n### 目的\n\n"
"## セットアップ\n\n### 必要なもの\n\n### インストール手順\n\n"
"## 使い方\n\n### 基本コマンド\n\n### よくある質問\n\n"
"## ライセンス\n"
)
Path(path).write_text(content, encoding="utf-8")
count = len(extract_headings(content))
typer.echo(f"サンプルを作成しました: {path}(見出し {count} 件)")
# 目次のプレビュー(ファイルは変更しない)
@app.command()
def preview(
path: str = typer.Argument(..., help="対象のMarkdownファイル"),
min_level: int = typer.Option(1, "--min-level", help="目次に含める最小レベル"),
max_level: int = typer.Option(3, "--max-level", help="目次に含める最大レベル"),
):
"""目次を生成して標準出力に表示する。"""
text = Path(path).read_text(encoding="utf-8")
headings = extract_headings(text)
toc = build_toc(headings, min_level, max_level)
typer.echo(toc if toc else "対象レベルの見出しが見つかりませんでした。")
# ファイルへ目次を挿入(冪等)
@app.command()
def insert(
path: str = typer.Argument(..., help="対象のMarkdownファイル"),
min_level: int = typer.Option(1, "--min-level", help="目次に含める最小レベル"),
max_level: int = typer.Option(3, "--max-level", help="目次に含める最大レベル"),
):
"""目次を生成し、マーカー区間へ冪等に書き込む。"""
file = Path(path)
text = file.read_text(encoding="utf-8")
headings = extract_headings(text)
toc = build_toc(headings, min_level, max_level)
new_text, updated = apply_toc(text, toc)
file.write_text(new_text, encoding="utf-8")
action = "更新" if updated else "挿入"
typer.echo(f"{path} に目次を{action}しました(見出し {len(headings)} 件)")
typer.echo(toc)
if __name__ == "__main__":
app()
コード全文は上の折り畳みに入れてあるので、全部を上から読む必要はありません。ここでは特に重要な部分だけを抜き出して、何をしているのか順番に見ていきます。
MarkdownIt().parseでトークン列にする
md = MarkdownIt()
tokens = md.parse(markdown_text)markdown-it-pyのMarkdownIt()を作り、parseでMarkdown全体をトークンの列に変換します。文字列を直接検索しないので、コードブロック内の記号に惑わされにくくなります。
heading_openから見出しの深さを読む
if token.type == "heading_open":
level = int(token.tag[1]) # "h2" -> 2
inline = tokens[index + 1] # 直後の inline トークンが本文
text = inline.content.strip()見出しの開始を表すheading_openを見つけたら、token.tagがh2のような文字列なので2文字目を数字にして深さにします。見出しの文字は、すぐ後ろにあるinlineトークンのcontentから取り出します。
slugifyでGitHub風アンカーを作る
slug = text.strip().lower()
slug = re.sub(r"[^\w\s-]", "", slug) # 記号を除去(英数字・日本語・空白・-は残す)
slug = re.sub(r"\s+", "-", slug) # 連続する空白をハイフンへ見出しを小文字化し、記号をre.subで取り除いてから空白をハイフンに変えます。\wは日本語も含むため、日本語の見出しでもアンカーとリンク先が一致します。
build_tocで階層を字下げする
base = min(h["level"] for h in selected) # 最上位の見出しを基準に字下げ
lines: list[str] = []
for h in selected:
indent = " " * (h["level"] - base)
anchor = slugify(h["text"])
lines.append(f"{indent}- [{h['text']}](#{anchor})")最上位の見出しの深さをbaseにして、各見出しとの差だけ半角スペースで字下げします。行は- [見出し](#アンカー)の形にして、リンク付きの箇条書きに整えます。
apply_tocでマーカー区間を置換する
if pattern.search(markdown_text):
return pattern.sub(block, markdown_text), True
return f"{block}\n\n{markdown_text}", Falseマーカーが既にあればpattern.subで中身を置き換え、なければ本文の先頭へ差し込みます。この分岐のおかげで、再実行しても目次が増えず常に最新の構成へ更新されます。
app.commandでサブコマンドを公開する
@app.command()
def sample(path: str = typer.Argument(..., help="作成するMarkdownファイルのパス")):Typerでは関数に@app.command()を付けるだけで、その関数名がそのままサブコマンドになります。引数はtyper.Argumentで受け取り、--helpの説明文もここで指定します。
参考:
©Typer公式ドキュメントTyper is a library for building CLI applications that users will love using and developers will love creating.
Markdown目次生成CLIの動作確認
実際にこのCLIツールを、5回のコマンドで動かしました。いずれも終了コード0で正常に完了しています。
まずsampleでお試し用のguide.mdを作り、previewで目次を画面に表示して内容を確かめました。続けてinsertを2回実行し、再実行でも目次が二重にならないことを確認しています。
最後に--max-level 2を付けてpreviewを実行し、深い見出しを省いた粗い目次も得られました。粒度の指定が効いていることは、目次の行数の違いから読み取れます。
このセクションの用語
- 終了コード
- プログラムが終わるときに返す数値です。0は正常終了を意味し、それ以外は異常を示します。
今回動かした5回のコマンドは、次のとおりです。
-
python subject.py sample guide.mdでサンプルを作成 -
python subject.py preview guide.mdで目次を確認 -
python subject.py insert guide.mdで目次を挿入 -
python subject.py insert guide.mdを再実行して冪等性を確認 -
python subject.py preview guide.md --max-level 2で粒度を変更





PythonのTyperとmarkdown-it-pyのエラー対処
ここでは、環境や入力の違いで一般に起こりやすい実行エラーをまとめます。今回の5回の実行はすべて正常でしたが、初めて動かすときにつまずきやすい点を先に押さえておきましょう。
このセクションの用語
- pip
- Pythonのライブラリを導入するためのコマンドです。
pip installに続けてライブラリ名を書きます。
| エラー例 | 原因 | 対処 |
|---|---|---|
| ModuleNotFoundError: No module named 'typer' |
typerやmarkdown-it-pyが未インストール |
pip install typer markdown-it-pyで導入する |
| FileNotFoundError | 指定したMarkdownファイルのパスが存在しない |
sampleで先にguide.mdを作るか正しいパスを渡す |
| IndexError: list index out of range |
heading_openの直後にinlineが無い特殊な入力 |
見出し行の書き方を見直して入力を整える |
| 目次に何も出力されない |
--min-levelと--max-levelの範囲に見出しが無い |
レベル指定を広げるか見出しの階層を確認する |
| 日本語アンカーがリンク切れ | 閲覧側がGitHub式のアンカーに未対応 | 表示先の仕様に合わせてアンカー規則を調整する |
Markdown目次生成CLIで注意したい点
トークン解析は誤検出に強い一方で、見出しの取り出し方に独特のクセがあります。heading_openの直後にinlineが並ぶ前提が崩れると、テキストをうまく拾えません。
目次の字下げは、選ばれた見出しの中で最も浅いレベルを基準にします。そのため--min-levelを変えると、同じ文書でも字下げの起点が動きます。
書き込みの冪等性は、マーカーのコメントが本文に残っていることが前提です。マーカーを消してしまうと、insertは先頭へ新しい目次を作り直してしまいます。
ポイントとしては、トークンの並び・字下げの基準・マーカーの扱いという3点を押さえると、挙動を読み違えにくくなります。
字下げ基準:最上位見出しに合わせる
冪等化の鍵:TOCマーカーで囲む
粒度指定:maxlevelで深さを絞る
Markdown目次生成ツールが活躍する場面
見出しの多いMarkdownを扱う場面ほど、この目次生成ツールの効果が出ます。手作業の目次づくりを減らし、更新のたびに最新へ保てるのが利点です。
このセクションの用語
- README
- プロジェクトの概要を書く入口の文書です。GitHubではリポジトリの先頭に表示されます。
| 使える場面 | 具体的な使い方 |
|---|---|
| 技術ブログやドキュメント | 記事の冒頭にinsertで目次を入れ、更新のたびに再生成する |
| GitHubのREADME整備 |
<!-- TOC -->マーカーを置き、insertで見出し一覧を自動反映する |
| 社内マニュアルの俯瞰 |
--max-level 2で粗い目次にし、章立てだけを見渡せるようにする |
| レビュー前の下書き点検 |
previewで目次だけを出し、見出し構成の抜け漏れを確認する |
Markdown目次生成CLI開発のまとめ
今回はTyperとmarkdown-it-pyを組み合わせ、Markdownの見出しから目次を作るCLIツールを実装しました。解析・アンカー生成・組み立て・書き込みの4段に分けたことで、各処理の見通しがよくなりました。
トークン解析による誤検出の少なさと、マーカーを使った冪等な書き込みが、この方針の要でした。--min-levelと--max-levelで粒度も選べるため、用途に合わせて目次の細かさを変えられます。
実際に5回のコマンドで動かし、すべて終了コード0で正常に動作しました。まずはsampleで作ったguide.mdから、previewとinsertの挙動を試してみてください。
参考にした一次情報
- ^ Typer公式ドキュメント. https://typer.tiangolo.com/, (参照26-07-21).
- ^ markdown-it-py公式ドキュメント. https://markdown-it-py.readthedocs.io/, (参照26-07-21).
- ^ markdown-it-py(GitHubリポジトリ). https://github.com/executablebooks/markdown-it-py, (参照26-07-21).
※内容は執筆時点のものです。ライブラリやサイトの仕様は変わる可能性があるため、公式ドキュメントもあわせてご確認ください。
ITやプログラミングに関するコラム
【CSS】notで複数の件を除外する方法
【Git】remote設定を変更する方法
【VBA】コメントアウトを設定する方法
x86とx64の違いを分かりやすく解説
マークダウンで改行する方法
CapsLockキーを解除する方法
GitLabとGitHubの違いを解説
Linuxで環境変数を確認する方法
UbuntuのIPアドレスを確認する方法
パソコンのメモリの目安を用途別に選ぶ方法
ITやプログラミングに関するニュース
VercelがAI GatewayにSeedream 5.0 Proを追加、AI SDKのモデル指定で画像生成と編集が可能に
AWSがAmazon LocationのPlaces APIを強化、住所表記の指定と移動手段別の検索が可能に
VercelがトレースにTree・Waterfallビューを追加、ログ画面で処理の階層と所要時間を確認可能に
Googleがエージェント評価の再考を提唱、難易度を情報量で測るDiscovery Benchを解説
Google CloudがCloud Runサンドボックスを公開プレビューで提供、サービスヘルスは一般提供に
Google Cloud EMEAが英国金融の重要第三者に指定、イングランド銀行・PRA・FCAの直接監督下に
AWS DMS Schema ConversionがSQL Serverのオフライン変換に対応、ソースDBへ接続せずスキーマを変換可能に
EC2 G7インスタンスが米国東部(バージニア北部)で利用可能に、G6比でAI推論性能が最大4.6倍
SageMaker HyperPodが継続プロビジョニングでのAMIベース構成に対応、S3のスクリプト管理なしでSlurmクラスターを作成可能に
AWSがEMR on EKSでSparkトラブルシューティングエージェントに対応、失敗ジョブの原因分析を自然言語で依頼可能に
