きっかけ
チーム内で Markdown を HTML に変換する処理が散らばっていた経験がある。Laravel アプリにも Node のプレビューサービスにも社内 Wiki にもそれぞれ別のライブラリが入っていて、GFM テーブルが動くところと動かないところ、safe モードの ON/OFF がバラバラ。3 か月もすれば確実に乖離する。
解決策は単純で、1 エンドポイントの HTTP サービスを作ること。Markdown を投げれば HTML が返る。ライブラリは 1 つ、ルールも 1 つ、アップグレードも 1 箇所。
作ったもの
markdown-api -- PHP 8.2 + Slim 4 + league/commonmark の Markdown 変換マイクロサービス。
GitHub: https://github.com/sen-ltd/markdown-api
-
POST /render-- JSON で Markdown を受け取り、HTML + ワードカウント + 見出しリストを返す -
POST /render/html-- raw HTML を返す(フロントエンドから直接 innerHTML に挿入するユースケース用) -
GET /render?text=...-- デバッグ用の GET 版 -
GET /health-- ヘルスチェック + league/commonmark のバージョン -
GET /-- ライブプレビューのデモページ - safe モードがデフォルト ON(XSS 防止)
- 52 MB の Docker イメージ
技術的なポイント
Renderer: safe モードの設計
html_input => escape + allow_unsafe_links => false をデフォルトにした。Markdown に <script>alert(1)</script> を埋め込んでも <script> にエスケープされる。安全側に倒すことで、呼び出し側が "safe": false を明示しない限り XSS は発生しない。
final class Renderer
{
public function __construct(string $flavor = self::FLAVOR_GFM, bool $safe = true)
{
$config = [
'html_input' => $safe ? 'escape' : 'allow',
'allow_unsafe_links' => !$safe,
];
$env = new Environment($config);
$env->addExtension(new CommonMarkCoreExtension());
if ($flavor === self::FLAVOR_GFM) {
$env->addExtension(new GithubFlavoredMarkdownExtension());
}
$this->converter = new MarkdownConverter($env);
}
}
safe モードはコンストラクタ引数であり、レンダリング時ではない。一度決めたら不変。リクエストごとに設定を切り替えるようなバグの温床を断つ設計。
見出し抽出
レンダリング済み HTML から正規表現で <h1>〜<h6> を抽出し、level / text / anchor を返す。重複する見出しには -1, -2 のサフィックスを付ける(GitHub や Docusaurus と同じ挙動)。スラッグは Unicode 対応で、# Cafe ☕ → café(絵文字は除去、アクセント付き文字は保持)。
$anchor = self::slugify($text);
$base = $anchor; $counter = 1;
while (isset($seen[$anchor])) {
$anchor = "{$base}-{$counter}";
$counter++;
}
テスト: 本物の Slim アプリでテスト
モックではなく、ServerRequestFactory で実リクエストを作り App::handle() に渡す。ルーティング、ミドルウェア順序、Content-Type のバグまで拾える。
public function testSafeModeEscapesByDefault(): void
{
$req = (new ServerRequestFactory())->createServerRequest('POST', '/render');
$req = $req->withBody(streamOf('{"markdown":"<script>alert(1)</script>"}'))
->withHeader('Content-Type', 'application/json');
$res = self::$app->handle($req);
$data = json_decode((string) $res->getBody(), true);
$this->assertStringNotContainsString('<script>', $data['html']);
}
29 テストが約 50 ms で完走する。
30 秒で試す
docker build -t markdown-api .
docker run --rm -p 8000:8000 markdown-api
curl -X POST http://localhost:8000/render \
-H "Content-Type: application/json" \
-d '{"markdown": "# Hello\n\nThis is **bold**.\n\n- [x] done\n- [ ] todo"}'
おわりに
Markdown のレンダリングは「どこでも必要だけど、散らばると管理コストが急上昇する」典型的な処理だと思う。1 エンドポイントに集約するだけで、アップグレードもルール統一も劇的に楽になる。PHP チームなら Slim 4 + league/commonmark が最小構成で最も自然な選択肢だろう。
