notebook

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

mdastを使ってすでに存在するMarkdownファイルに対してコンテンツの追加や修正をする

最近はメモや日々の振り返りなどはすべてObsidianを使っていて、DailyNoteになんでも書いて後で切り出していくような流れになっている

それ以前はNotionに振り返りやその他いろんなことを集約させていたが使うツールを変えたので、できればそれまで使っていたデータも引き継ぎたい

また、Obsidian以外のどこかに投稿した内容なども集約したいなどのケースもありそう

  • Notionでの日次振り返りの内容をObsidianのDailyNoteに転記
  • Notionでの週次振り返りの内容をObsidianのWeeklyNoteに転記
  • Notionでの月次振り返りの内容をObsidianのMonthlyNoteに転記
  • Notionでの習慣化記録をObsidianのDailyNoteに転記
  • 特定SlackチャンネルにPostした内容をObsidianのDailyNoteに転記(ObsidianMemosの内容と混ぜる)

など、ケースはいくらでも出てくると思う

今回はそういうケースに対し、mdastを使って既存のMarkdownに対して修正を加えるケースを考える

サンプル

以後使うMarkdownのサンプル、Obsidianで使っている記法も含めて入れているが結構適当

  • sample.md
---
hoge: 1
fuga: 2
---

[[内部リンク]]

hoge_fuga

## Header
- a_aaa!!!
- b#hoge
    - c


## 記号
### エスケープチェック
'hoge'

"fuga"

`piyo`

(aaa)

1 * 2 + 5 / 2 % 3

a < 3

b > 3

https://example.com?hoge=1&fuga=2

## Tasks
- [ ] TaskA
- [ ] TaskB

ASTの構造

どんなNodeがあるかなどは下記

syntax-tree/mdast: Markdown Abstract Syntax Tree format

GitHubのテーブルやfrontmatterなどは拡張でNodeの種類を増やして解釈できるようにしている

サンプルのMarkdownをパースしてみるとこんな感じ

{
  type: 'root',
  children: [
    {
      type: 'yaml',
      value: 'hoge: 1\nfuga: 2',
      position: {
        start: { line: 1, column: 1, offset: 0 },
        end: { line: 4, column: 4, offset: 23 }
      }
    },
    {
      type: 'heading',
      depth: 1,
      children: [
        {
          type: 'text',
          value: 'title',
          position: {
            start: { line: 6, column: 3, offset: 27 },
            end: { line: 6, column: 8, offset: 32 }
          }
        }
      ],
      position: {
        start: { line: 6, column: 1, offset: 25 },
        end: { line: 6, column: 8, offset: 32 }
      }
    },
    {
      type: 'paragraph',
      children: [
        {
          type: 'text',
          value: '[[内部リンク]]',
          position: {
            start: { line: 8, column: 1, offset: 34 },
            end: { line: 8, column: 10, offset: 43 }
          }
        }
      ],
      position: {
        start: { line: 8, column: 1, offset: 34 },
        end: { line: 8, column: 10, offset: 43 }
      }
    },
    {
      type: 'paragraph',
      children: [
        {
          type: 'text',
          value: 'hoge_fuga',
          position: {
            start: { line: 10, column: 1, offset: 45 },
            end: { line: 10, column: 10, offset: 54 }
          }
        }
      ],
      position: {
        start: { line: 10, column: 1, offset: 45 },
        end: { line: 10, column: 10, offset: 54 }
      }
    },
    {
      type: 'heading',
      depth: 2,
      children: [
        {
          type: 'text',
          value: 'Header',
          position: {
            start: { line: 12, column: 4, offset: 59 },
            end: { line: 12, column: 10, offset: 65 }
          }
        }
      ],
      position: {
        start: { line: 12, column: 1, offset: 56 },
        end: { line: 12, column: 10, offset: 65 }
      }
    },
    .....
    .....
    .....
    .....

こんな感じになっている

Markdownのデータを取り出すだけなら、children以下から対象のNodeを抽出して利用する

構造としてはroot以下にNodeが並んでいて、各Nodeで子のNodeを持っている場合はchildren以下にNode[]が存在する

Positionを除く

ASTを出力したが、そのままだと分量が多いのでpositionだけ抜いてみるとわかりやすいかも

下記のような関数を用意してconsole出力時にかませる

const removePositionFromAst = (node) => {
  if (node.children) {
    node.children.map(node => removePositionFromAst(node));
  }
  delete node.position;

  return node;
}

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

さっきよりは把握しやすいと思う

{
  type: 'root',
  children: [
    { type: 'yaml', value: 'hoge: 1\nfuga: 2' },
    {
      type: 'heading',
      depth: 1,
      children: [ { type: 'text', value: 'title' } ]
    },
    {
      type: 'paragraph',
      children: [ { type: 'text', value: '[[内部リンク]]' } ]
    },
    {
      type: 'paragraph',
      children: [ { type: 'text', value: 'hoge_fuga' } ]
    },
    {
      type: 'heading',
      depth: 2,
      children: [ { type: 'text', value: 'Header' } ]
    },
    {
      type: 'list',
      ordered: false,
      start: null,
      spread: false,
      children: [
        {
          type: 'listItem',
          spread: false,
          checked: null,
          children: [
            {
              type: 'paragraph',
              children: [ { type: 'text', value: 'a_aaa!!!' } ]
            }]
        },
        {
          type: 'listItem',
          spread: false,
          checked: null,
          children: [
            {
              type: 'paragraph',
              children: [ { type: 'text', value: 'b#hoge' } ]
            },
            {
              type: 'list',
              ordered: false,
              start: null,
              spread: false,
              children: [
                {
                  type: 'listItem',
                  spread: false,
                  checked: null,
                  children: [
                    {
                      type: 'paragraph',
                      children: [ { type: 'text', value: 'c' } ]
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    },
    .....
    .....
    .....

サンプルコード

ここからは実際のサンプルコードをもとに説明していく

下記は## Taskの上に## Contentsヘッダと、動物のリストを入れこむ場合のサンプル

import {fromMarkdown} from 'mdast-util-from-markdown'
import {frontmatter} from 'micromark-extension-frontmatter'
import {toMarkdown} from 'mdast-util-to-markdown'
import {frontmatterFromMarkdown, frontmatterToMarkdown} from 'mdast-util-frontmatter'
import * as fs from 'fs'

const createContentsAst = (contents) => {
  const items = contents.map((item) => {
    return {
      type: 'listItem',
      spread: false,
      checked: null,
      children: [
        {
          type: 'paragraph',
          children: [
            {
              type: 'text',
              value: item.name,
            },
          ],
        },
      ],
    }
  });

  return {
    type: 'list',
    spread: false,
    children: items,
  }
}

const contentsHeadingAst = {
  type: "heading",
  depth: 2,
  children: [
    {
      type: "text",
      value: "Contents",
    },
  ],
};

const contents = [
  {name: 'Seal'},
  {name: 'Zebra'},
  {name: 'Bear'},
]

// Markdownのパース
const ast = fromMarkdown(fs.readFileSync('sample.md'), {
  extensions: [frontmatter(['yaml'])],
  mdastExtensions: [frontmatterFromMarkdown(['yaml'])]
});

// コンテンツを入れ込む対象のIndexを特定
const targetHeaderIndex = ast.children.findIndex(node => node.type === 'heading' && node.children[0]?.value === 'Tasks');

// 新たに加えるコンテンツのASTを生成
const contentsAst = createContentsAst(contents);

// コンテンツを入れ込んだあとの新たなASTのchildrenを生成
const children = targetHeaderIndex === -1 ? [...ast.children, contentsHeadingAst, contentsAst] : [
  ...ast.children.slice(0, targetHeaderIndex),
  contentsHeadingAst,
  contentsAst,
  ...ast.children.slice(targetHeaderIndex),
]

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

// 書き出し
const options = {
  bullet: '-',
  extensions: [frontmatterToMarkdown(['yaml'])]
}

const replacer = (str) => {
  return str.replace(/\\\[/g, '[').replace(/\\_/g, '_').replace(/\\&/g, '&').replace(/\\\*/g, '*');
}

fs.writeFileSync('sample_stored.md', replacer(toMarkdown(afterAst, options)));

パース

frontmatterも使っているのでmicromark-extension-frontmatter,mdast-util-frontmatterを読み込んでfrontmatterをパースできるようにする

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

ほぼREADME通り

自分のケースではtomlは不要だったので除いた

Nodeタイプがtype: yamlで判断されるようになる

あと書き戻した場合にもしっかりfrontmatterの体をなした状態で書き出せる

コンテンツを入れ込む対象のIndexを特定

何かしらコンテンツを挿入するなり、編集するなりする場合、目印が必要

## Taskの上にコンテンツを挿入するケースなので

childrenの中からNodeのtypeがheadingかつ、そのテキストがTasksのNodeのIndexを取得してそれを元に操作していく

これはASTの中身見ながらやる

新たに加えるコンテンツのASTを生成

呼び出した関数の先でリストのASTを生成している(createContentsAst)

サンプルなので簡単なリストを用意した(contents)

ここはケースに合わせて自分が入れたいコンテンツに合わせたASTを生成する

既存のコンテンツを修正したい場合は既存のNodeを渡してよしなにしてASTを生成する

コンテンツを入れ込んだあとの新たなASTのchildrenを生成

入れ込む対象のIndexが見つからなかった場合のことも考慮して、ASTの最後に追加した

入れ込む対象のIndexを境にして既存のASTを分割し、間に今回生成した変数(contentsHeadingAst, contentsAst)を入れる

const children = targetHeaderIndex === -1 ? [...ast.children, contentsHeadingAst, contentsAst] : [
  ...ast.children.slice(0, targetHeaderIndex),
  contentsHeadingAst,
  contentsAst,
  ...ast.children.slice(targetHeaderIndex),
]

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

書き出し

const options = {
  bullet: '-',
  extensions: [frontmatterToMarkdown(['yaml'])]
}

const replacer = (str) => {
  return str.replace(/\\\[/g, '[').replace(/\\_/g, '_').replace(/\\&/g, '&').replace(/\\\*/g, '*');
}

fs.writeFileSync('sample.md', replacer(toMarkdown(afterAst, options)));

これもほとんどREADME通り

replacerはエスケープされてしまった文字列をもとに戻す処理をしている

単純に出力するとエスケープしなくて良い文字列をエスケープしてしまっていた、toMarkdownのオプションで回避できるかと調べたり試したりしてみたがうまくいかなかったので愚直ではあるもののこういう処理を挟んでいる

今回のケースでは入力が信頼できないものではないのでエスケープしたものを戻す処理をしている

オプション以外に実現できるかみたいなところは調べられてないのでもし他の方法のほうが良いなどあれば教えてくれたら嬉しいです

まとめ

mdastを用いて、すでに存在するMarkdownに対して新たにヘッダとリストを挿入するサンプルコードを書いた

既存のコンテンツに対して変更を加えたい場合などはIndexの探し方とコンテンツの生成、挿入位置を調整してうまい具合に入れ込んであげれば良い

追加したいコンテンツのデータ取得と整形は毎度書く必要があるが、ある程度流れは一緒かなというところまで持っていけた

本記事に載せたコードはサンプルとして下記にあるので良ければ参考にして見てください

sandbox/javascript/markdown-modify/sample_insert.js at master · swfz/sandbox

冒頭記載した「特定SlackチャンネルにPostした内容をObsidianのDailyNoteに転記(ObsidianMemosの内容と混ぜる)」するスクリプトは下記

swfz/slack-to-obsidian-memos-merge