notebook

都内でWEB系エンジニアやってます。

ASTを経由してMarkdownのparse、compileを行う

毎週、毎月ある程度テンプレート化された記事を公開する場合

毎週、毎月の変動するデータをもとに自動でテンプレートに当て込みMarkdownを生成したい

この処理をある程度自動で行えたら良いなと思い調べてやってみた

完結にするためテンプレートは下記のようなMarkdownとする

  • sample1.md
## サブタイトル1

## サブタイトル2

## サブタイトル3

この手のことをやろうとした場合、自分の中で真っ先に選択肢として挙がったのがremarkだった

remarkを使っても良かったが

  • MarkdownをparseしてASTに変換する
  • ASTからMarkdownを生成する

の2パターンだけが必要だったのでremarkの内部で使っているmdast-util-from-markdown,mdast-util-to-markdownを使うことにした

テーブル表示に関しては別途モジュールを追加する必要があった(mdast-util-gfm-table)

install

yarn add mdast-util-from-markdown mdast-util-gfm-table micromark-extension-gfm-table
  • package.json
"main": "sample.js",
+ "type": "module",
"scripts": {

サンプルのコードには載せていないが最終的にdynamic importを使ったためサンプルでも"type": "module"を追加している

parse

MarkdownをASTに変換する処理

import * as fs from 'fs'
import {fromMarkdown} from 'mdast-util-from-markdown'
import {gfmTable} from 'micromark-extension-gfm-table'
import {gfmTableFromMarkdown} from 'mdast-util-gfm-table'

const markdown = fs.readFileSync('sample1.md');

const ast = fromMarkdown(markdown,{
  extensions: [gfmTable],
  mdastExtensions: [gfmTableFromMarkdown]
});

console.dir(ast, {depth: null})

ASTの内容

{
  type: 'root',
  children: [
    {
      type: 'heading',
      depth: 2,
      children: [
        {
          type: 'text',
          value: 'サブタイトル1',
          position: {
            start: { line: 1, column: 4, offset: 3 },
            end: { line: 1, column: 11, offset: 10 }
          }
        }
      ],
      position: {
        start: { line: 1, column: 1, offset: 0 },
        end: { line: 1, column: 11, offset: 10 }
      }
    },
    {
      type: 'heading',
      depth: 2,
      children: [
        {
          type: 'text',
          value: 'サブタイトル2',
          position: {
            start: { line: 3, column: 4, offset: 15 },
            end: { line: 3, column: 11, offset: 22 }
          }
        }
      ],
      position: {
        start: { line: 3, column: 1, offset: 12 },
        end: { line: 3, column: 11, offset: 22 }
      }
    },
    {
      type: 'heading',
      depth: 2,
      children: [
        {
          type: 'text',
          value: 'サブタイトル3',
          position: {
            start: { line: 5, column: 4, offset: 27 },
            end: { line: 5, column: 11, offset: 34 }
          }
        }
      ],
      position: {
        start: { line: 5, column: 1, offset: 24 },
        end: { line: 5, column: 11, offset: 34 }
      }
    }
  ],
  position: {
    start: { line: 1, column: 1, offset: 0 },
    end: { line: 7, column: 1, offset: 36 }
  }
}

行、列、位置の情報までのっている

compile

ASTからMarkdownを生成する処理

サンプルでは上記で取得したASTからpositionの情報だけ削除して定義した

import * as fs from 'fs'
import {toMarkdown} from 'mdast-util-to-markdown'
import {gfmTableToMarkdown} from 'mdast-util-gfm-table'

const ast = {
  type: 'root',
  children: [
    {
      type: 'heading',
      depth: 2,
      children: [
        {
          type: 'text',
          value: 'サブタイトル1',
        }
      ],
    },
    {
      type: 'heading',
      depth: 2,
      children: [
        {
          type: 'text',
          value: 'サブタイトル2',
        }
      ],
    },
    {
      type: 'heading',
      depth: 2,
      children: [
        {
          type: 'text',
          value: 'サブタイトル3',
        }
      ],
    }
  ],
}

fs.writeFileSync('sample2.md', toMarkdown(ast, {bullet: '-', extensions: [gfmTableToMarkdown()]}));
$ cat sample2.md
## サブタイトル1

## サブタイトル2

## サブタイトル3

bullet: '-'をオプションで指定しているのは自分がいつもMarkdownでリストを書くときは*ではなく-を用いているため

他にもいじれるオプションがあるので下記を参考にすれば良い

syntax-tree/mdast-util-to-markdown: mdast utility to serialize markdown

ASTをいじる

import * as fs from 'fs'
import {fromMarkdown} from 'mdast-util-from-markdown'
import {toMarkdown} from 'mdast-util-to-markdown'
import {gfmTable} from 'micromark-extension-gfm-table'
import {gfmTableFromMarkdown, gfmTableToMarkdown} from 'mdast-util-gfm-table'

const markdown = fs.readFileSync('sample1.md');

const ast = fromMarkdown(markdown,{
  extensions: [gfmTable],
  mdastExtensions: [gfmTableFromMarkdown]
});

const itemAst = [{
  type: 'list',
  ordered: false,
  start: null,
  spread: false,
  children: [
    {
      type: 'listItem',
      spread: false,
      checked: null,
      children: [
        {
          type: 'paragraph',
          children: [
            {
              type: 'text',
              value: 'list item1',
            }
          ],
        }
      ],
    },
    {
      type: 'listItem',
      spread: false,
      checked: null,
      children: [
        {
          type: 'paragraph',
          children: [
            {
              type: 'text',
              value: 'list item2',
            }
          ],
        }
      ],
    },
    {
      type: 'listItem',
      spread: false,
      checked: null,
      children: [
        {
          type: 'paragraph',
          children: [
            {
              type: 'text',
              value: 'list item3',
            }
          ],
        }
      ],
    }
  ],
}];

const heading = 'サブタイトル2';

const children = ast.children.reduce((acc, item) => {
  if(item.type === 'heading' && item.children[0].value === heading) {
    return [...acc, item, ...itemAst];
  }

  return [...acc, item];
}, [])

const afterAst = {...ast, ...{children}}

fs.writeFileSync('sample3.md', toMarkdown(afterAst, {bullet: '-', extensions: [gfmTableToMarkdown()]}));

特定Header(今回はサブタイトル2)の直下に事前生成したASTを入れ込むことでコンテンツ追加を可能にした

  • 実行結果
$ cat sample3.md
## サブタイトル1

## サブタイトル2

-   list item1
-   list item2
-   list item3

## サブタイトル3

事前生成するASTをどうするかというのは今のところparseの項でASTを出力したように、こんな感じのMarkdownを生成したいというのをMarkdownで書いてからParseしてconsole.dirした結果をみてこういうASTにすればよいのねって感じで組んでいくという愚直な感じでやってみた

まとめ

  • テンプレートをもとに、特定ヘッダ以下にあらかじめ生成したコンテンツを追加できた
    • Markdownをmdastでparseした
    • ASTからMarkdownの生成を行った
    • ASTの中身を書き換える処理を書いた

コンテンツの生成はBigQueryなりJSONなりにデータをあらかじめ入れておいて時期によって値が変わるようにすればある程度定形な記事は自動化できる

自分は毎月やっているじぶんリリースノートの定形内容を出力できるようにした

自動で生成できる部分が増えれば今まで色々なレポートやサイトから引っ張ってきていた手作業をなくせるので効率が上がるはず、しばらく運用してみる

余談

自分のケースではMarkdown記事にfrontmatterを使用していないので使わなかったが、Markdownで記事書くのであればだいたいfrontmatter使っていると思われるので下記も使うことになりそう

syntax-tree/mdast-util-frontmatter: mdast extension to parse and serialize frontmatter (YAML, TOML, etc)

github.com

また、今回調べていく中で

textlint -> remark -> unified -> mdast

といった感じで次々しらない単語が出てきて勉強にはなった(正直まだそんなに理解できてない)

参考