何を作ったか
ぱんだツールズの一機能として「Markdown→HTML変換ツール」を作った。Markdown を貼り付けると、HTML ソースとプレビューを切り替えながら確認でき、そのまま .html ファイルとしてダウンロードできるツール。
実装的にはほぼ marked 1本でできるが、dangerouslySetInnerHTML を使うプレビュー機能は DOMPurify で必ずサニタイズする という最低限の安全対策を入れている。
ツールの概要
- 入力: Markdown テキスト(CommonMark + GFM)
- 出力: HTML ソース文字列 / レンダリングプレビュー /
.htmlファイルダウンロード - ソースとプレビューはタブで切り替え
- すべてブラウザ内処理。サーバー送信なし
GFM(GitHub Flavored Markdown)に対応しているのでテーブル・タスクリスト・取り消し線も変換できる。
入力 Markdown と HTML ソース表示。<h1> <strong> <table> などのタグが生で見える状態。
「プレビュー」タブに切り替えると、ブラウザのデフォルトスタイル + Tailwind の prose クラスで実際の見た目が確認できる。
技術スタック
実装のポイント
marked の初期設定
marked は gfm: true を立てるだけで GitHub Flavored Markdown が有効になる。
import { marked } from 'marked'
marked.setOptions({
gfm: true,
})
marked.parse(input) の戻り値は同期版だと string、非同期版だと Promise<string>。v5 以降はデフォルトが同期で、async: true を渡すと Promise になる。今回は重い処理ではないので同期版でそのまま使う。
ただし型定義上は string | Promise<string> の Union が返るので、TypeScript で扱うときは as string のキャストか型ガードが必要。
const html = marked.parse(input) as string
プレビューに DOMPurify は必須
ここがこのツールで一番重要な部分。marked の出力には、入力 Markdown 由来の HTML タグがそのまま入る可能性がある。CommonMark 仕様では「Markdown 中に書かれた HTML はそのまま通す」のがデフォルト挙動。
たとえば入力に <script>alert(1)</script> と書かれていたら、marked.parse はそれを通常の HTML として出力する。これを dangerouslySetInnerHTML でそのまま貼ると XSS が成立する。
// ❌ 危険な書き方
<div dangerouslySetInnerHTML={{ __html: output }} />
// ✅ DOMPurify で必ず通す
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(output) }} />
DOMPurify はデフォルト設定で <script> <iframe> <object> などの危険なタグと、onerror onload などのイベントハンドラ属性を除去してくれる。設定の追加なしで Web の現実的なユースケースのほとんどをカバーする。
このツールは「自分が書いた Markdown を変換する」のが主用途だが、ブラウザ完結ツールであっても プレビュー機能を持つ以上はサニタイザは必須 という方針にしている。
ダウンロード機能はサニタイズしない
「ダウンロード用 HTML」はサニタイズしていない、生の marked 出力をそのまま .html ファイルにする。
function handleDownload() {
if (!output) return
const blob = new Blob([output], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'converted.html'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
理由は「ユーザーが入力した Markdown に意図的に <script> を埋め込んでいた場合、それを残すかどうかはユーザーの判断に委ねるべき」だから。ダウンロード後のファイルを自分のサーバーに配置するならその時点で別途サニタイズすべきだし、ローカルで開いて確認するだけならスクリプトが残っていても直接的な危険はない。
サニタイザを通すと「marked がそのまま通したはずの HTML が消える」ことになり、ツールとしての透明性が落ちる。プレビュー(ブラウザ内で実行される)にはサニタイザ必須、ダウンロード(実行コンテキストが別)にはサニタイザ不要、という割り切り。
コードブロックには language-xxx クラスが付く
marked はフェンス付きコードブロックの言語指定を class="language-xxx" として出力する。
入力:
```javascript
console.log('hello')
```
出力:
<pre><code class="language-javascript">console.log('hello')</code></pre>
このクラス名は highlight.js / Prism.js / shiki の標準形式 なので、ダウンロードした HTML にこれらのライブラリを CDN 経由で読み込ませれば、シンタックスハイライトがそのまま効く。ツール側でハイライタを抱える必要がないのでバンドルが軽くなる。
Blob + a.click() でクライアントダウンロード
ファイルダウンロードは定番の Blob + アンカータグの組み合わせ。
const blob = new Blob([output], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'converted.html'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
URL.revokeObjectURL を呼ばないと Object URL がメモリに残り続けるので、即座に解放しておく。
Markdown パーサーの選択肢比較
JavaScript 製の主要パーサーは以下の3つ。
| パーサー | 強み | 弱み |
|---|---|---|
marked |
最速・APIシンプル・GFM標準対応 | プラグイン拡張は他より少なめ |
markdown-it |
プラグインエコシステムが豊富 | 設定の幅が広いぶん習熟必要 |
remark |
AST ベースで他ツールと統合しやすい | 単純変換だとオーバースペック |
このツールは「貼り付け → 変換 → 表示」の単純なフローで、AST を触る予定もないので marked を選んだ。バンドルサイズは gzip 後で 15KB 程度と軽い。
markdown-it は GitHub README プレビューや Vue のドキュメントサイトで使われていて、絵文字・脚注・コンテナなどのプラグインが充実している。複雑なドキュメントサイトを作るなら markdown-it のほうが選択肢が広い。
学び
- CommonMark 仕様は HTML をそのまま通す。Markdown だから安全、というのは誤解で、HTML を埋め込めるのが Markdown の基本仕様
-
dangerouslySetInnerHTMLを使う場面では DOMPurify が事実上必須。Markdown 由来でもユーザー入力由来でも同じ - サニタイズの範囲は「ブラウザで実行されるかどうか」で決める。プレビューは実行されるからサニタイズ、ダウンロードは実行コンテキストが別なのでユーザー判断、という線引きが妥当
まとめ
Markdown→HTML 変換は marked 1本でほぼ完結する。重要なのは プレビュー用と保存用でサニタイズを使い分ける こと、それと marked が吐き出す language-xxx クラスを意識しておくと外部ハイライタとの連携で便利。
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理などの開発者向けツールを 80 個以上公開中。全部無料・登録不要・ブラウザ完結で使える。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。

