最近はメモや日々の振り返りなどはすべて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をパースできるようにする
ほぼ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の内容と混ぜる)」するスクリプトは下記