1
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変換ツールを marked + DOMPurify で作った(ブラウザ完結・GFM対応)

1
Posted at

何を作ったか

ぱんだツールズの一機能として「Markdown→HTML変換ツール」を作った。Markdown を貼り付けると、HTML ソースとプレビューを切り替えながら確認でき、そのまま .html ファイルとしてダウンロードできるツール。

実装的にはほぼ marked 1本でできるが、dangerouslySetInnerHTML を使うプレビュー機能は DOMPurify で必ずサニタイズする という最低限の安全対策を入れている。

ツールの概要

  • 入力: Markdown テキスト(CommonMark + GFM)
  • 出力: HTML ソース文字列 / レンダリングプレビュー / .html ファイルダウンロード
  • ソースとプレビューはタブで切り替え
  • すべてブラウザ内処理。サーバー送信なし

GFM(GitHub Flavored Markdown)に対応しているのでテーブル・タスクリスト・取り消し線も変換できる。

入力 Markdown と HTML ソース表示。<h1> <strong> <table> などのタグが生で見える状態。

Markdown→HTML変換ツールの「HTMLソース」タブ表示。入力Markdownの下に変換結果のHTMLが<h1>ぱんだツールズ</h1> <p><strong> <table> などのタグ付きで表示されている

「プレビュー」タブに切り替えると、ブラウザのデフォルトスタイル + Tailwind の prose クラスで実際の見た目が確認できる。

「プレビュー」タブ表示。Markdownがレンダリングされ、見出し「ぱんだツールズ」、太字、箇条書きリストなどがブラウザで実際に見える形で表示されている

技術スタック

  • Next.js 16 (App Router) + TypeScript
  • marked v18 系(Markdown パーサー)
  • DOMPurify v3 系(HTML サニタイザー)

実装のポイント

marked の初期設定

markedgfm: 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 にも同じ内容を投稿しています。

1
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
1
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?