unified を使って Markdown を拡張する

8 min read読了の目安(約7600字

unified の一番の旨味である、文法を拡張する方法を書きます。ちなみに以前の記事で unified の使い方や parser および compiler の作り方を紹介しました。

わからない言葉がある場合は unified における言葉の定義をまとめた記事を参照してください。

transformer プラグイン

unified では transformer プラグインを定義することで様々なことが可能です。今回の目的である Markdown の拡張も transformer プラグインで実現します。

何もしないプラグインの実装例は次のとおりとなります。

nop.ts
import unified from "unified";
import { Node } from "unist";
import { VFileCompatible } from "vfile";

const nop: unified.Plugin = () => {
  return (tree: Node, file: VFileCompatible) => {
    // nop
  };
};

export default nop;

自分でプラグインを作る場合は // nop のコメントの部分を実装してください。

プラグイン開発に有用なプラグイン

現在の AST の状態を表示する print プラグインは次のようになります。

print.ts
import unified from "unified";
import { Node } from "unist";
import { VFileCompatible } from "vfile";
import { inspect } from "unist-util-inspect";

const print: unified.Plugin = () => {
  return (tree: Node, file: VFileCompatible) => {
    console.log(inspect(tree));
  };
};

export default print;

プラグイン開発では、常に AST がどうなっているかを把握する必要があるため、このプラグインを使って適宜気になるところを表示させましょう。

transformer プラグインの適用順

なお、プラグインの適用順序には気をつけてください。

const processor = unfied()
  .use(A)
  .use(print)
  .use(B)

print を先ほど紹介した print プラグインとして、このように適用した場合、プラグイン A が適用された直後の AST が表示されます。

意図通りの結果が得られないときは各プラグインの処理内容を比較し、順番が間違っていないか確認してください。

Zenn のメッセージ記法を解釈できるように拡張する

ここでは Zenn のオリジナル記法であるメッセージ記法を実現する transformer を書いてみましょう。

方針

Markdown を HTML に変換する場合、次の図の流れをたどります。

変換の流れ

今回は次の方針でプラグインを作成してみます。

  1. mdast を解析しメッセージ記法の部分を message ノードに変換する
  2. mdast から hast へ変換する際、 message ノードを msg class を持つ div ノードに変換する

Zenn のメッセージ記法

Zenn 公式のリファレンスによると、メッセージは次の記法で書けるようです。

:::message
これはメッセージです
:::

また、これを Zenn で表示させると、次の HTML となるようです。

<div class="msg">これはメッセージです</div>

この変換が可能となるプラグインを作っていきましょう。

基礎となるコード

まず Markdown から HTML を生成させるコードを書きましょう。

import unified from "unified";
import parser from "remark-parse";
import toHast from "remark-rehype";
import compiler from "rehype-stringify";

const plugin: unified.Plugin = () => {
  return (tree: Node, _file: VFileCompatible) => {
    // ここを実装していく
  }
}

const processor = unified()
  .use(parser)
  .use(plugin)
  .use(toHast)
  .use(compiler)

const data = `
:::message
これはメッセージです
:::
`

console.log(processor.processSync(data).toString())

parser は Markdown を mdast へ変換する remark-parse 、 transformer は mdast を hast へ変換する remark-rehype 、 compiler は hast を HTML へ変換する rehype-stringify です。

mdast を確認する

print プラグインを使って今の mdast を表示してみます。

root[1] (1:1-5:1, 0-27)
└─0 paragraph[1] (2:1-4:4, 1-26)
    └─0 text ":::message\nこれはメッセージです\n:::" (2:1-4:4, 1-26)

paragraph ノードの直下に text ノードがあり、改行も含めてひとつのテキストとして解釈されているようです。

メッセージ記法で書かれているところを探す

まず、メッセージ記法を探す処理を書きましょう。

import { isParent, isText, isParagraph } from "./util";

const MESSAGE_BEGGINING = ":::message\n";
const MESSAGE_ENDING = "\n:::";

function isMessage(node: unknown): node is Paragraph {
  if (!isParagraph(node)) {
    return false;
  }

  const { children } = node;

  const firstChild = children[0];
  if (!(isText(firstChild) && firstChild.value.startsWith(MESSAGE_BEGGINING))) {
    return false;
  }

  const lastChild = children[children.length - 1];
  if (!(isText(lastChild) && lastChild.value.endsWith(MESSAGE_ENDING))) {
    return false;
  }

  return true;
}

isParentisTextisParagraph の定義は GitHub に上げていますので参照してください。

ここではノードがメッセージ記法で書かれているかを判断するために、次の 3 つを判断しています。

  1. Paragraph 型であるノードである
  2. 子ノードの一番最初がテキストであり、 :::message\n ではじまっている
  3. 子ノードの一番最後がテキストであり、 \n::: で終わっている

2 番と 3 番をまとめて /^:::message\n.*\n:::/ のような正規表現でマッチできないことに注意してください。次のような文字列を渡された場合を想定する必要があります。

:::message
これは**重要**メッセージです
:::

この文字列に対応する mdast は次になります。

root[1] (1:1-5:1, 0-33)
└─0 paragraph[3] (2:1-4:4, 1-32)
    ├─0 text ":::message\nこれは" (2:1-3:4, 1-15)
    ├─1 strong[1] (3:4-3:10, 15-21)
    │   └─0 text "重要" (3:6-3:8, 17-19)
    └─2 text "メッセージです\n:::" (3:10-4:4, 21-32)

paragraph の子ノードはひとつとは限らないわけですね。

さて、定義した isMessage をどう使うかですが、 unist は unist-util-visit という Visitor パターンを実現するユーティリティーを提供しています。これを使いましょう。

import unified from "unified";
import { Node, Parent } from "unist";
import { VFileCompatible } from "vfile";
import visit from "unist-util-visit";
import { Paragraph } from "mdast";
import { inspect } from "unist-util-inspect";

function visitor(
  node: Paragraph,
  index: number,
  parent: Parent | undefined
) {
  // ここに変換処理を書く
}

const plugin: unified.Plugin = () => {
  return (tree: Node, _file: VFileCompatible) => {
    visit(tree, isMessage, visitor);
  };
};

これで、メッセージ記法の抽出ができました。

メッセージ記法と判断した箇所を message ノードへ変換する

次は変換処理です。先ほどの visitor 関数を定義していきます。

function processFirstChild(children: Array<Node>, identifier: string) {
  const firstChild = children[0];
  const firstValue = firstChild.value as string;
  if (firstValue === identifier) {
    children.shift();
  } else {
    children[0] = {
      ...firstChild,
      value: firstValue.slice(identifier.length),
    };
  }
}

function processLastChild(children: Array<Node>, identifier: string) {
  const lastIndex = children.length - 1;
  const lastChild = children[lastIndex];
  const lastValue = lastChild.value as string;
  if (lastValue === identifier) {
    children.pop();
  } else {
    children[lastIndex] = {
      ...lastChild,
      value: lastValue.slice(0, lastValue.length - identifier.length),
    };
  }
}

function visitor(node: Paragraph, index: number, parent: Parent | undefined) {
  if (!isParent(parent)) {
    return;
  }

  const children = [...node.children];
  processFirstChild(children, MESSAGE_BEGGINING);
  processLastChild(children, MESSAGE_ENDING);

  parent.children[index] = {
    type: "message",
    children,
  };
}

processFirstChildprocessLastChild については、そのまま必要な処理を書き下しています。

ノードの置き換えをする場合は、 parent.children を操作する必要があることに注意してください。

さて、これで message ノードへの変換は終わりです。

message ノードから msg class を持つ div ノードへ変換する

mdast から hast への変換処理を拡張します。

remark-rehype は第二引数に handlers というプロパティを持つオブジェクトを受け付けます。これが mdast の特定のノードを処理するハンドラーを指定するものです。

import { H } from "mdast-util-to-hast";

export default function handler(h: H, node: Node) {
  // ここを実装する
}

const processor = unified()
  .use(parser)
  .use(plugin)
  .use(toHast, {
    handlers: {
      message: handler,
    },
  })
  .use(compiler);

これであとは handler 関数の中身を実装するだけです。 hast のノードの構造に沿って、次のように記述しましょう。

import all from "./all";

export default function handler(h: H, node: Node) {
  return {
    type: "element",
    tagName: "div",
    properties: {
      className: ["msg"],
    },
    children: all(h, node),
  };
}

all 関数は mdast-util-to-hast で定義されているユーティリティー関数です。

これで実装は完了です。

まとめ

今回紹介した方法は unist で AST を操作するにあたっての基礎となる内容ですが、非常に応用が効く方法でもあります。 Markdown をベースとした DSL など、ぜひお好みの言語を生み出してみてください。

実装にあたっては AMDX のソースコードが非常に参考になりました。こちらも読んでみると、より unified についての理解が進むでしょう。