marked v17 で Markdown を HTML に変換する
marked v17 系で Markdown → HTML を最小コードでパースする方法、GFM 拡張、DOMPurify 併用のサニタイズ。Lexer → Parser → Renderer 3 段パイプラインと拡張ポイント、breaks: true で挙動が変わる罠、async: true で Promise になる API 差まで実装メモ。
検証日: 2026-05-26
使用バージョン:
marked@17.0.3(2026-05 時点でlatest)対象: ブラウザ / Node で MD → HTML 変換が必要な場面、軽量 Markdown プレビューを実装したい人
marked v17 で Markdown → HTML 変換 を最小コードで動かすパターン。GFM 拡張、サニタイゼーション(DOMPurify 併用)、カスタムレンダラ、同期 vs 非同期、を動く demo で確認します。
触って試す
セットアップ(npm 1 行 / ESM only / 型同梱)
npm i marked
# サニタイズ用に DOMPurify を一緒に
npm i dompurify
npm i -D @types/dompurify
| 項目 | 値 |
|---|---|
| 公開バージョン | 17.0.3 |
| Node 必要バージョン | 18+(v15 系から ESM only に移行済み) |
| TypeScript 型 | 本体同梱(@types/marked は不要) |
| バンドル | 30KB 前後(min+gzip)、tree-shake で renderer のみ抜き出し可 |
| サニタイズ | デフォルトでサニタイズしない設計(関心の分離)。dompurify 等を併用必須 |
| ライセンス | MIT |
marked v15 以降は ESM only。require("marked") で読もうとすると Node でも ERR_REQUIRE_ESM が出る。CommonJS のままにしたい古いプロジェクトは v14 系で止める判断もあり(ただしセキュリティ更新は v15+ のみ)。
最小サンプル(marked v17、3 行で動かす)
marked.parse(src) で Markdown 文字列を HTML 文字列に変換するだけ。ブラウザでも Node でも同じ API:
import { marked } from "marked";
const html = marked.parse("# Hello\n\n**bold** _italic_");
// "<h1>Hello</h1>\n<p><strong>bold</strong> <em>italic</em></p>"
ブラウザでも Node でも同じ API。
marked 内部の 3 段パイプライン (Lexer → Parser → Renderer)
marked.parse() の中身は 3 段のパイプ。各段で hook できるので、カスタマイズしたい時にどこに割り込むかが見えやすい。
実用例:
- link を全部
target="_blank" rel="noopener"にしたい → renderer 上書き(後述) :::noteのようなカスタム記法を追加 → lexer 拡張で新トークンを定義- AST だけ欲しい(HTML 不要) →
marked.lexer(src)で tokens を直接取得
GFM 拡張 4 機能(table / strike / checkbox / autolink)
オプションで GFM テーブル / ストライクスルー / タスクリスト / autolink を有効化:
const html = marked.parse(src, { gfm: true, breaks: true });
| オプション | 効果 |
|---|---|
gfm: true | Table、-[x] チェックリスト、autolink を有効化 |
breaks: true | 単純改行を <br> に変換(GitHub の挙動) |
pedantic: false | true にすると Markdown.pl 厳密、false の方が一般的 |
サニタイゼーション 1 行(DOMPurify 併用)
marked.parse() は HTML をそのまま透過する(<script> も含む)。ユーザー入力をパースする時は サニタイズ必須。
import { marked } from "marked";
import DOMPurify from "dompurify";
const html = DOMPurify.sanitize(marked.parse(userInput) as string);
container.innerHTML = html;
カスタムレンダラ
marked.Renderer を継承して特定要素の出力 HTML を上書き。link に rel="noopener" を強制する例:
const renderer = new marked.Renderer();
renderer.link = ({ href, text }) => `<a href="${href}" rel="noopener noreferrer" target="_blank">${text} ↗</a>`;
marked.use({ renderer });
特定の要素だけ書き換えたい時に使う。リンクに rel="noopener" を強制する、画像に loading="lazy" を付けるなど。
同期 vs 非同期 — 2 つの API
デフォルトは同期で文字列を返す。async プラグイン(remote fetch する transformer 等)を使う場合だけ async: true で Promise になる:
// 同期(デフォルト、文字列を返す)
const html = marked.parse(src) as string;
// 非同期(Promise を返す、async プラグインを使う時)
const html = await marked.parse(src, { async: true });
カスタム async プラグインを使わない通常用途は同期で OK。
Shiki で 4 言語のコードハイライト後付け
marked 自体はコードハイライトしない(<pre><code class="language-ts">...</code></pre> を生成するだけ)。Shiki / Prism / highlight.js を marked.use({ async, walkTokens }) で噛ませる:
import { marked } from "marked";
import { createHighlighter } from "shiki";
const highlighter = await createHighlighter({
themes: ["github-light", "github-dark"],
langs: ["ts", "tsx", "bash", "json"],
});
marked.use({
async: true,
walkTokens: async (token) => {
if (token.type === "code") {
const lang = (token as any).lang || "text";
// text トークンを HTML 文字列で上書き(renderer の code を別途定義してもよい)
(token as any).text = highlighter.codeToHtml(token.text, {
lang,
themes: { light: "github-light", dark: "github-dark" },
});
}
},
});
const html = await marked.parse(src); // 非同期になる
walkTokens で個別 token を書き換える方が、renderer.code を丸ごと上書きするより副作用が少ない。Shiki を入れると bundle は +1MB 程度(言語数で増減)、SSR で highlighter を作って dist に焼く方が速い。
6 つの落とし穴と直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
<script>alert(1)</script> がそのまま出る | marked は デフォルトで HTML を素通し | DOMPurify.sanitize(marked.parse(input)) で必ずサニタイズしてから innerHTML |
await marked.parse(src) の戻り値が string 型エラー | async: true で Promise<string> | const html = await marked.parse(src, { async: true }) で型を合わせる |
| 段落内の改行が無視される | デフォルトは CommonMark 準拠(2 スペース + 改行で <br>) | breaks: true で GitHub 風に。ただし他の MD と挙動がズレるので注意 |
| GFM table が table にならない | セル境界の **` | ` 周囲スペース** 不足 |
require("marked") で ERR_REQUIRE_ESM | v15+ は ESM only | import { marked } from "marked" に置換、または v14 系で固定 |
Vite で marked の側で Buffer is not defined | Node API を bundler が解決 | optimizeDeps.include: ["marked"] を vite.config に追加、または marked/lib/marked.esm.js を直接 import |
評価
| 観点 | 評価 | コメント |
|---|---|---|
| 学習コスト | ◎ | API は parse(text, options) 中心 |
| 速度 | ◎ | ベンチで markdown-it と僅差、十分速い |
| サニタイズ | △ | 自前で DOMPurify 等を噛ませる |
| 拡張 | ○ | renderer / extensions / hooks で柔軟 |
| バンドルサイズ | ○ | 30KB 前後(min+gzip) |
向く / 向かないケース
- 向く: ブラウザ内 MD プレビュー、Node での HTML 生成、軽量 docs viewer
- 向かない: HTML / MDX の expression が必要(MDX 自体を使う)、AST 操作が中心(remark / unified を直接使う)
- 向かない: 高度なサニタイズ要件 → markdown-it + DOMPurify か rehype-sanitize 系
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
JavaScriptによるはじめてのアルゴリズム入門 | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
Python と JavaScriptではじめるデータビジュアライゼーション | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際 | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
はじめてのWebデザイン&プログラミング : HTML、CSS、JavaScript、PHPの基本 | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。
フロントエンドの知識地図ーー 一冊でHTML/CSS/JavaScriptの開発技術が学べる本 | tech-book.net
関連テーマ・学習ロードマップから次の一冊へ。