0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Markdown→HTML 変換の API を Slim 4 で 200 行で作った

0
Posted at

きっかけ

チーム内で 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> を埋め込んでも &lt;script&gt; にエスケープされる。安全側に倒すことで、呼び出し側が "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 が最小構成で最も自然な選択肢だろう。

GitHub: https://github.com/sen-ltd/markdown-api

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?