Markdown を HTML に変換するツールとして知られる Marked.js は、その軽量性、高速性、拡張性により、多くの JavaScript プロジェクトで使用されています。
本記事では、Marked.js
の基本から高度な使い方までを解説します。
基本的な使い方
インストール
npm install marked
もしくは CDN を利用します。
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
初歩的なコード
以下は、シンプルなMarkdownをHTMLに変換する例です。
<div id="content"></div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
document.getElementById("content").innerHTML = marked.parse(
"# 見出し\n\n**太字のテキスト**"
);
</script>
セキュリティの注意点
Marked.js は、出力された HTML をサニタイズしません。そのため、未検証のデータを処理する場合は、XSS 攻撃を防ぐために必ずフィルタリングを行いましょう。
DOMPurifyを使用した例
DOMPurify.sanitize(
marked.parse(`<img src="x" onerror="alert('危険!')">`)
);
Marked.js をカスタマイズ
前提知識
Marked.js をカスタマイズする上で必要な前提知識を説明します。
-
Marked.js の役割
Markdown
で記述された文章は Markdown パーサ(HTML 形式に変換するプログラム)を通じて、ブラウザ上で表示可能なHTML
形式に変換します。Marked.js
もパーサのひとつです。 -
Markdown パーサの仕様
Markdown パーサの仕様はさまざま存在します。CommonMark と CommonMark を拡張した GitHub Flavored Markdown (GFM) が有名です。 -
ブロックレベルとインラインレベル
Markdown は、2 つの主要な要素タイプで構成されています、- ブロックレベル: 見出しや段落、リストなど文章全体の構造を表します。
- インラインレベル: テキスト内で使う装飾(例: リンク、太字、画像)を表します。
これらの要素は階層構造をとります。ブロック要素の中にブロックレベル・インラインレベルを、インラインレベルの中に他のインラインレベルをもつことができます。(インラインレベルの中にブロックレベルをもつことはありません。)
const inlineHtml = marked.parseInline("**太字** _イタリック_"); console.log(inlineHtml); // '<strong>太字</strong> <em>イタリック</em>'
インラインレベルのみを HTML に変換したい場合は
marked.parseInline
を使用します。 -
Marked が Markdown から HTML に変換する流れ
-
marked.parse
は文字列を受け取ると、 -
Lexer
クラスがtokenizer
クラスを利用してトークンという単位に分割し、その後、トークンをネストされたツリー構造に変換し、 -
walkTokens
関数は、ツリー内のすべてのトークンを探索し、トークン内容の最終調整を行い、 -
Parser
クラスがRenderer
クラスを利用してトークンツリーを HTML に変換します。
-
オプションの設定
以下は主要なオプションの一部です。
オプション | デフォルト | 説明 |
---|---|---|
gfm | true | GitHub Flavored Markdown を使用 |
breaks | false | 単一の改行(\n )を <br> に変換 |
tokenizer | new Tokenizer() | Markdown で記述された文章を token に変換 |
renderer | new Renderer() | token を HTMLに変換 |
walkTokens | null | 各トークンの最終調整 |
marked.use({ gfm: true, breaks: true });
console.log(marked.parse("Hello\nWorld"));
Renderer
Parser
がトークンツリー全体を管理し、Renderer
が(Parser
から渡された)各 token を実際に HTML へ変換する役割を持ちます。
既存の Renderer をカスタマイズする際は token がどのようなプロパティを持つか確認する必要があります。例えば、Heading の場合は type
、raw
、depth
、text
、tokens
を持ちます。詳しくは markedjs- Tokens を参照してください。
// Override function
const renderer = {
heading(token) {
const text = this.parser.parseInline(token.tokens);
const escapedText = text.toLowerCase().replace(/[^\w]+/g, "-");
return `
<h${token.depth}>
<a name="${escapedText}" class="anchor" href="#${escapedText}">
<span class="header-link"></span>
</a>
${text}
</h${token.depth}>`;
},
// hr(token) {
// ...
// },
// ...
};
marked.use({ renderer });
- token には ブロックレベルとインラインレベル があります。heading の場合は、子要素(tokens)はインラインレベルなため、子要素は
parseInline
メソッドを使用しています。 - 複数回オーバーライドすると、最後に割り当てられたバージョンが優先されます。
Renderer
のカスタマイズには markedjs- Renderer のコードが参考になります。
Tokenizer
Lexer
が入力された文字列全体を管理し、Tokenizer
が実際に文字列を Token へ変換する役割を持ちます。
// Override function
const tokenizer = {
codespan(src) {
const match = src.match(/^\$+([^\$\n]+?)\$+/);
if (match) {
return {
type: 'codespan',
raw: match[0],
text: match[1].trim()
};
}
// return false to use original codespan tokenizer
return false;
},
// blockquote(src) {
// ...
// },
// ...
};
marked.use({ tokenizer });
- 基本的に、引数は
src
だけでいいですが、-
reflink
メソッドはsrc
とlinks
を引数にとります。 -
emStrong
メソッドはsrc
とmaskedSrc
、prevChar
を引数にとります。
-
- 返り値の Token の型は整合性を保つために、markedjs- Tokens のコードと同じにする必要があります。
- 複数回オーバーライドすると、最後に割り当てられたバージョンが優先されます。
詳しくは markedjs- Tokenizer を参照してください。
walkTokens
Tokenize と Render の間で、トークン内容の最終調整したい場合は、walkTokens
を利用します。
// Override function
const walkTokens = (token) => {
if (token.type === 'heading') {
token.depth += 1;
}
};
marked.use({ walkTokens });
- token の
type
から最終調整する token を判断しています。type
については以下を参照してください。
Extensions
marked.use({extensions: []})
を使用すると、独自の Markdown 記法を追加することができます。
Extension を行うには、以下のプロパティを使用する必要があります。
-
name
:- トークンを識別するために使用される文字列。
-
level
- この拡張のトークナイザーを実行するタイミングを指定する文字列。
-
'block'
または'inline'
のどちらかを指定する必要があります。
-
start(string src)
- トークンの開始位置を示すインデックスを返す関数。
-
tokenizer(string src, array tokens)
- テキストを読み込み、トークンを返す関数
-
tokens
には、これまでにlexer
によって生成されたトークンの配列が含まれています。
-
renderer(object token)
- トークンを読み込み、HTML 文字列を返す関数。
オプションで childTokens
プロパティも使用できます。
const youTubeExtension = {
name: 'youtube',
level: 'block',
start(text) {
const match = /^::youtube\[([^\]]+)\]$/.exec(text);
return match ? match.index : undefined;
},
tokenizer(text) {
const match = /^::youtube\[([^\]]+)\]$/.exec(text);
if (match) {
const lineCount = match[0].split('\n').length;
return {
type: 'youtube',
text: match[1],
length: lineCount - 2,
raw: match[0],
tokens: []
};
}
},
renderer(token) {
return renderYouTube(token.text);
}
}
function renderYouTube(url) {
let videoId = null;
if (url.startsWith('https://www.youtube.com/watch?v=')) {
videoId = url.split('&')[0].split('/watch?v=')[1];
} else if (url.startsWith('https://youtu.be/')) {
videoId = url.slice(17).split('?')[0];
} else if (url.startsWith('https://www.youtube.com/shorts/') && url.length > 32) {
videoId = url.slice(31).split('?')[0];
} else if (url.startsWith('https://m.youtube.com/shorts/') && url.length > 31) {
videoId = url.slice(29).split('?')[0];
}
// videoId は escape 処理をした方がよい
if (videoId && videoId.length < 15) return `<iframe width="100%" height="100%" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allowfullscreen></iframe>`;
return '';
}
const extensions = [youTubeExtension]
marked.use({ extensions });
document.getElementById("content").innerHTML = marked.parse(
"# Heading\n\n::youtube[https://www.youtube.com/watch?v=w6zgUcQnhIs]"
);
詳しくは Using Pro - Marked Documentation を参照してください。
おわりに
本記事で紹介したように、Marked.js はカスタマイズ性に優れており、自由に Markdown 記法を拡張できます。
今回紹介しなかった機能に Hooks
と Workers
があります。Marked.js のカスタマイズ性をさらに高めるための強力な機能です。ぜひ公式ドキュメントを参照してください。