現在修正しているプログラムにマークダウンを解析する箇所があります。
これまではmarkdown-itやmarkedを使ったことがありました。
ただこれらではより複雑な制御がしづらく、他にライブラリを探していたところ unifiedを見つけました。
ちょっと触れただけなのですがメモです。
マークダウンをHTMLに変換する基本的な例。
import { unified } from "unified"
import remarkParse from "remark-parse"
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
const markdown = `
# Hello World
- aaa
- bbb
- ccc
## xxx
test test
`
(async () => {
const html = await unified().use(remarkParse).use(remarkRehype).use(rehypeStringify).process(markdown);
console.log(html.toString())
})();
これを実行すると以下のようになHTMLを取得できます。
<h1>Hello World</h1>
<ul>
<li>aaa</li>
<li>bbb</li>
<li>ccc</li>
</ul>
<h2>xxx</h2>
<p>test test</p>
重要なのがこの部分です。
const html = await unified().use(remarkParse).use(remarkRehype).use(rehypeStringify).process(markdown);
markdownはマークダウン文字列を代入した単なる変数です。
unified()でインスタンスを生成します。
そのインスタンスに対し、use()でプラグインを追加していきます。
process()にマークダウンを渡すことで変換が可能になります。
ではuse()に渡したプラグインは何なんでしょう!?
ここが難しくもあり重要です。
remarkParseはマークダウンの文字列をmdastに変換します。
AST(Abstract Syntax Tree)というのは抽象構文木のことで、MDASTは単にマークダウンを構文木にしたものです。
構文木というのはヘッダーやリスト、コードなどを解析してツリー構造にしたものだと思ってください。
remarkRehypeはマークダウンの構文木をhast(HTMLの構文木)に変換します。
rehypeStringifyはhastをHTML文字列に変換します。
このように、マークダウン文字列 → マークダウン構文木 → HTML構文木 → HTML文字列 の順に変換していくプラグインをuse()で追加してます。
後はprocess()にマークダウン文字列を渡せばプラグインを順に実行してくれます。
マークダウン構文木を取得したい、HTML構文木を取得したい、ということもできます。
名前 | 呼び出す関数 | 動作例 |
---|---|---|
parse(解析) | parse() | マークダウン文字列 → マークダウン構文木 |
transforms(変換) | run() | マークダウン構文木 → HTML構文木 |
compiler(コンパイル) | stringify() | HTML構文木 → HTML文字列 |
process | process() | マークダウン文字列 → HTML文字列まで一連の流れをまとめておこなう |
公式では以下のように説明してあります。
| ........................ process ........................... |
| .......... parse ... | ... run ... | ... stringify ..........|
+--------+ +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
+--------+ | +----------+
X
|
+--------------+
| Transformers |
+--------------+
これはどういうことか、以下の例でみてみます。
(async () => {
const mdast = unified().use(remarkParse).parse(markdown);
const hast = await unified().use(remarkRehype).run(mdast);
const html = unified().use(rehypeStringify).stringify(hast);
console.log('mdast');
console.log(mdast);
console.log('hast');
console.log(hast);
console.log('html');
console.log(html);
})();
mdast
{type: 'root', children: Array(4), position: Object}
children
:
Array(4)
0
:
{type: 'heading', depth: 1, children: Array(1), position: Object}
1
:
{type: 'list', ordered: false, start: null, spread: false, children: Array(3), …}
2
:
{type: 'heading', depth: 2, children: Array(1), position: Object}
3
:
{type: 'paragraph', children: Array(1), position: Object}
length
:
4
[[Prototype]]
:
Array(0)
position
:
{start: Object, end: Object}
type
:
"root"
[[Prototype]]
:
Object
hast
{type: 'root', children: Array(7), position: Object}
children
:
Array(7)
0
:
{type: 'element', tagName: 'h1', properties: Object, children: Array(1), position: Object}
1
:
{type: 'text', value: '\n'}
2
:
{type: 'element', tagName: 'ul', properties: Object, children: Array(7), position: Object}
3
:
{type: 'text', value: '\n'}
4
:
{type: 'element', tagName: 'h2', properties: Object, children: Array(1), position: Object}
5
:
{type: 'text', value: '\n'}
6
:
{type: 'element', tagName: 'p', properties: Object, children: Array(1), position: Object}
length
:
7
[[Prototype]]
:
Array(0)
position
:
{start: Object, end: Object}
type
:
"root"
[[Prototype]]
:
Object
html
<h1>Hello World</h1>
<ul>
<li>aaa</li>
<li>bbb</li>
<li>ccc</li>
</ul>
<h2>xxx</h2>
<p>test test</p>
マークダウン文字列をMDASTに変換すると、「heading」「list」「heading」「paragraph」にパース(parse())されていることがわかります。
それをHASTに変換すると「h1」「text」「ul」「text」「h2」「text」「p」に変換(run())されていることがわかります。
さらにHTMLにコンパイル(stringify())されています。
パーサー、フォーマッター、コンパイラを独自に定義してみる。
動作確認が目的のためまったく意味のないプラグインを作成します。
例えばマークダウン文字列の代わりにカンマ区切りにされた以下の文字列があったとします。
'1, 2, 3, 5, 7, 11, 13'
パーサーによりカンマで切り分けられトリミングされ、配列に変換します。これをマークダウンの構造木に見立てます。
['1', '2', '3', '5', '7', …]
トランスフォーマーにより、配列を数値変換し、二乗し、Setオブジェクトに変換します。これをHTML構造木に見立てます。
Set(7) {1, 4, 9, 25, 49, 121, …}
コンパイラーにより、Setオブジェクトを+区切りの文字列に変換します。これをHTML文字列に見立てます。
'1 + 4 + 9 + 25 + 49 + 121 + 169'
ではそれぞれの定義を見ていきます。
(async () => {
function myParse()
{
self = this;
console.log('USE myParse')
self.parser = (document: string) =>
{
// ['1', '2', '3', '5', '7', '11', '13']
const arr = document.split(',').map(p => p.trim())
console.log(arr)
// type必須。
return { type: 'root', arr }
}
}
function myFormatter()
{
self = this;
console.log('USE myFormatter')
return async (tree, file) =>
{
// Set(7) {1, 4, 9, 25, 49, 121, …}
const powSet = new Set(tree.arr.map(p => Number.parseInt(p)).map(p => p * p));
console.log(powSet)
// type必須
return { type: 'root', powSet };
}
}
function myCompiler()
{
self = this;
console.log('USE myCompiler')
self.compiler = (tree: Set<string>) =>
{
const str = [...tree.powSet.values()].join(" + ");
console.log(str);
return str;
}
}
const x = await unified().use(myParse).use(myFormatter).use(myCompiler).process('1, 2, 3, 5, 7, 11, 13');
console.log("================")
console.log(x.toString());
})();
まずuse()が先に実行されているのがわかります。
その後、パーサー、トランスフォーマー、コンパイラーの順で変換がすすんでいることがわかります。
USE myParse
USE myFormatter
USE myCompiler
(7) ['1', '2', '3', '5', '7', …]
Set(7) {1, 4, 9, 25, 49, 121, …}
1 + 4 + 9 + 25 + 49 + 121 + 169
================
1 + 4 + 9 + 25 + 49 + 121 + 169
パーサーとコンパイラで重要なことは、関数として定義することです。
thisを捕捉しないといけないのでアロー関数で定義すると失敗します。
このthisはunified()からのオブジェクトです。
パーサーはparserプロパティに、コンパイラーはcompilerプロパティにコールバックを引っかけることで実現していることがわかります。
オプションを渡す
function myParse(options)
{
self = this;
console.log('USE myParse')
console.log(options)
self.parser = (document: string) =>
{
// ['1', '2', '3', '5', '7', '11', '13']
const arr = document.split(',').map(p => p.trim())
console.log(arr)
// type必須。
return { type: 'root', arr }
}
}
unified().use(myParse, { myOption: 'Hello Options!' }).parse('1, 2, 3, 5, 7, 11, 13')
use()の第二引数でオプションを渡すことができます。
USE myParse
{myOption: 'Hello Options!'}
(7) ['1', '2', '3', '5', '7', …]
まだまだ使えるかわかりませんが今日はここまで。