Home About Contact
Markdown , Node.js , JavaScript

markdown テキストをパースしてあれこれしたい (markdown-to-ast 編)

markdown で記述されたテキストをパースしてあれこれしたい場合。 markdown-to-ast が便利そうなので、使ってみた。 これはすごい便利。

2022-04-04 更新: commonmark 編を書きました。

プロジェクト作成

$ mkdir mast
$ cd mast
$ npm init -y

依存するライブラリをインストール:

$ npm install @textlint/markdown-to-ast
$ npm install underscore

index.js を書く:

const parse = require("@textlint/markdown-to-ast").parse;
const markdownText = "It's a *text*.";
const ast = parse(markdownText)
console.log(ast);

実行する:

$ node index.js

標準出力:

{
  type: 'Document',
  children: [
    {
      type: 'Paragraph',
      children: [Array],
      loc: [Object],
      range: [Array],
      raw: "It's a *text*."
    }
  ],
  loc: { start: { line: 1, column: 0 }, end: { line: 1, column: 14 } },
  range: [ 0, 14 ],
  raw: "It's a *text*."
}

与えたマークダウンテキストがASTとしてオブジェクト化されました。

次に、2つのパラグラフのあるマークダウンテキストをパースしてみます。

Hello, World!

Hello, *World*!

index.js :

const parse = require("@textlint/markdown-to-ast").parse;
const markdownList = [];
markdownList.push("Hello, World!");
markdownList.push("");
markdownList.push("Hello, *World*!");

const markdownText = markdownList.join("\n");
const ast = parse(markdownText)
console.log(ast);

実行:

{
  type: 'Document',
  children: [
    {
      type: 'Paragraph',
      children: [Array],
      loc: [Object],
      range: [Array],
      raw: 'Hello, World!'
    },
    {
      type: 'Paragraph',
      children: [Array],
      loc: [Object],
      range: [Array],
      raw: 'Hello, *World*!'
    }
  ],
  loc: { start: { line: 1, column: 0 }, end: { line: 3, column: 15 } },
  range: [ 0, 30 ],
  raw: 'Hello, World!\n\nHello, *World*!'
}

children の部分で2つの Paragraph が出現しています。

次に、 parseDoc という ドキュメント構造を調べる関数を書きます。

const _ = require('underscore');

const parseDoc = (ast)=> {
    console.log("- " + ast.type);
    if( ast.type == 'Paragraph' ){
        _.each(ast.children, (it)=>{
            console.log("  - " + it.type);
        });
    }
    else {
        _.each(ast.children, (it)=>{ parseDoc(it); });
    }
};


const markdownList = [];
markdownList.push("Hello, World!");
markdownList.push("");
markdownList.push("Hello, *World*!");

const markdownText = markdownList.join("\n");

const parse = require("@textlint/markdown-to-ast").parse;
const ast = parse(markdownText)
parseDoc(ast);

実行する:

$ node index.js
- Document
- Paragraph
  - Str
- Paragraph
  - Str
  - Emphasis
  - Str

ドキュメントの中にパラグラフが2つあり、パラグラフの中に Str や Emphasis が出現する、という雰囲気がわかりました。

これを XMLというか HTML っぽい出力にしてみましょう。

const _ = require('underscore');

const parseEmp = (node, list)=> {
    if( node.type == 'Str' ){
        list.push('<str>')
        list.push(node.value);
        list.push('</str>')
    }
};

const parseStrOrEmp = (node, list)=> {
    if( node.type == 'Str' ){
        list.push('<str>')
        list.push(node.value);
        list.push('</str>')
    }
    else if( node.type == 'Emphasis' ){
        list.push('<emp>')
        _.each(node.children, (it)=>{ parseEmp(it, list); });
        list.push('</emp>')
    }
};

const parsePara = (node, list)=> {
    if( node.type == 'Paragraph' ){
        list.push("<p>");
        _.each(node.children, (it)=>{ parseStrOrEmp(it, list); });
        list.push("</p>");
    }
};

const parseDoc = (node, list)=> {
    if( node.type == 'Document' ){
        list.push("<doc>");
        _.each(node.children, (it)=>{ parsePara(it, list); });
        list.push("</doc>");
    }
};


const markdownList = [];
markdownList.push("Hello, World!");
markdownList.push("");
markdownList.push("Hello, *World*!");

const markdownText = markdownList.join("\n");

const parse = require("@textlint/markdown-to-ast").parse;
const ast = parse(markdownText)
const list = [];
parseDoc(ast, list);
console.log( list.join('') );

を前提としてパースする関数を作成した。

実行すると以下の文字列が得られます:

<doc><p><str>Hello, World!</str></p><p><str>Hello, </str><emp><str>World</str></emp><str>!</str></p></doc>

リファクタリング

コードを良く見てみると同じコードを繰り返しているだけなので、そこをまとめる形でリファクタリングした。

const _ = require('underscore');

const parseNode = (node)=> {
    // _.foldl で使う関数:
    const f = (acc, subNode) => {
        return acc + parseNode(subNode);
    };

    if( node.type == 'Document' ){
        return '<doc>' + _.foldl(node.children, f, '') + '</doc>';
    }
    else if( node.type == 'Paragraph' ){
        return '<p>' + _.foldl(node.children, f, '') + '</p>';
    }
    else if( node.type == 'Emphasis' ){
        return '<emp>' + _.foldl(node.children, f, '') + '</emp>';
    }
    else if( node.type == 'Str' ){
        return '<str>' + node.value + '</str>';
    }
};

const markdownList = [];
markdownList.push("Hello, World!");
markdownList.push("");
markdownList.push("Hello, *World*!");

const markdownText = markdownList.join("\n");

const parse = require("@textlint/markdown-to-ast").parse;
const ast = parse(markdownText)
const xml = parseNode(ast);
console.log( xml );

parseNode 関数で再帰的に AST を調べて適切な文字列を出力している。

Document, Paragraph, Emphasis はコンテナ要素なのでサブノードがある(children が存在)。 したがって、 foldl で子要素が作り出す文字列を全部足し合わせている。

Str はリーフ要素(末端)で文字列が node.value に入っているだけなので、それを文字列として返すだけ。

実行した結果:

<doc><p><str>Hello, World!</str></p><p><str>Hello, </str><emp><str>World</str></emp><str>!</str></p></doc>

当たり前だが先ほどと同じ結果になる。

まとめ

単にmarkdownテキストをHTMLに変換したいだけならば かならずしも markdow-to-ast を使う必要はありませんが、 自在に出力をカスタマイズしたい場合には重宝します。