しゃべる葦原

remark-parseを拡張して独自構文を導入した話

2025-01-29

概要


私は以前,Unifiedに自作のtransformerのPluginを組み込むことで独自の構文(Tweet構文)を実現していました。

Tweet構文とはわたしのこと

ちなみにそれに関する記事はこちら

Markdownパーサーのカスタムプラグインをつくってみた話【しゃべる葦原】

目次取得処理や独自の記法を実現するためにunifiedのプラグインを追加したり実装したりしました。

で、今回やりたいのは別の方法で構文を導入することです。

別の方法というのはremark-parse自体を拡張すること。

これによって、Textノードに1回変換せずとも、TweetNodeを作成することができるようになります。

忙しい人のために流れをざっくりと述べておきましょう。

  • remark-parseの内部でトークン化を担うmicromarkの拡張を定義
  • remark-parseの内部でmdast作成を担うmdast-util-from-markdownの拡張を定義
  • Unifiedのパイプラインに上記二つの情報を入れ込むためのPlugin作成

さっそく取り組んでいきましょう。

方針の掘り下げ(語りパート)


この節では、私がどのような経路をたどって拡張方針を立てたのかについて何となく語ります。(自分語り乙)

そもそもこの実装をするきっかけはサークルの先輩のアドバイスでした。

「Textノードを無理やり変換するより、remark-parse自体を拡張したらどうか。」

興味を持った私は早速調べにかかります。

最初に見たのはremark-parseのソースコードでした。

remark/packages/remark-parse/lib/index.js at main · remarkjs/remark

markdown processor powered by plugins part of the @unifiedjs collective - remarkjs/remark

OG Image

ざっくり引用するとこんな感じ

index.js

export default function remarkParse(options) {
  const self = this

  self.parser = function (document) {
    return fromMarkdown(document, {
      ...self.data('settings'),
      ...options,
      // Note: these options are not in the readme.
      // The goal is for them to be set by plugins on `data` instead of being
      // passed by users.
      extensions: self.data('micromarkExtensions') || [],
      mdastExtensions: self.data('fromMarkdownExtensions') || []
    })
  }
}

コメントアウトにあるように、extensionsやmdastExtensionsなどの拡張はoptionsの中に含まれておらず、remark-parseに直接引数として渡すことはできません。

ここが、今まで操作してきたtransformerやstringfyなどとは違うところですね。

プラグインの中でデータが追加されることで、パーサーが間接的に拡張されるような流れを望んでいるように読めました。

次に見たのはmicromarkのREADME.mdです。

GitHub - micromark/micromark: small, safe, and great commonmark (optionally gfm, mdx) compliant markdown parser

small, safe, and great commonmark (optionally gfm, mdx) compliant markdown parser - micromark/micromark

OG Image

めちゃめちゃ丁寧に拡張の仕方が書いてありました。この記事で書いているトークナイザーはこのやり方をまねている部分が多くあります。

また、より具体的な書き方を知るために、micromark-extension-directive という拡張のソースコードも参考にしました。

トークナイザーの実装さえうまくいけば何とかなりそう?

次に見たのはmdast-util-from-markdownです。

GitHub - syntax-tree/mdast-util-from-markdown: mdast utility to parse markdown

mdast utility to parse markdown. Contribute to syntax-tree/mdast-util-from-markdown development by creating an account on GitHub.

OG Image

こちらは具体的なやり方が書いていない代わりに、型の内容や使い方が丁寧に記載されていました。

既に提供されているmdast-util-directiveのソースコードを覗いたりもしました。

以上のような流れで、私は機能実現の方針を立てました。

micromarkを拡張する


micromarkとは


micromarkはmarkdown構文をパースする(htmlにする)ライブラリです。

もしmicromarkで独自構文を作る場合は、トークン化規則の拡張と、HTML化規則の拡張をそれぞれ作って読み込ませることになります。

micromarkのREADMEにある例が分かりやすいかもしれません。

import {micromark} from 'micromark'
import {gfmHtml, gfm} from 'micromark-extension-gfm'

const value = '* [x] contact@example.com ~~strikethrough~~'

const result = micromark(value, {
  extensions: [gfm()],
  htmlExtensions: [gfmHtml()]
})

この例の中では、gmf()というトークン化規則の拡張とgfmHtml()というHTML化規則の拡張を渡すことで、構文を追加しています。

resultの中にはHTML文が入っています。

今回は、この中のトークン化規則の拡張の部分だけを作っていきます。なので、micromark自体はあまり使いません。

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


micromarkに関連するライブラリをインストールします。

  • micromark-util-types:TypeScriptでの実装に必要な型を提供しています。
  • micromark-util-symbol:トークン化において文字コードをわかりやすく扱うために使います。

拡張規則を作る


Extension型で、micromarkトークン化規則の拡張を作ります。

micromark-util-typesのコードをみて、Extension型がどのようなものか確認しましょう。

/**
 * A syntax extension changes how markdown is tokenized.
 *
 * See: <https://github.com/micromark/micromark#syntaxextension>
 */
export interface Extension {
  attentionMarkers?: {null?: Array<Code> | undefined} | undefined
  contentInitial?: ConstructRecord | undefined
  disable?: {null?: Array<string> | undefined} | undefined
  document?: ConstructRecord | undefined
  flowInitial?: ConstructRecord | undefined
  flow?: ConstructRecord | undefined
  insideSpan?:
    | {null?: Array<Pick<Construct, 'resolveAll'>> | undefined}
    | undefined
  string?: ConstructRecord | undefined
  text?: ConstructRecord | undefined
}

このTypeについての詳細説明が公式ドキュメントにあまりなく、実際にREADMEの中で紹介されているリポジトリを参考にした部分が大きいです。

何となくつかめたのは

  • textはインライン構文の拡張の時に使う
  • flowはブロック構文の拡張の時に使う

という事だけ…

指定されているConstructRecordの内容も覗いてみましょう。

/**
 * Several constructs, mapped from their initial codes.
 */
export type ConstructRecord = Record<
  string,
  Array<Construct> | Construct | undefined
>

コメントアウトでも書いてありますが、Constructを開始記号と結び付ける(マッピングする)ためのTypeです。

Constructの内容も考慮しながら、実装に取りかかりましょう、今回は最低限必要なもののみ指定します。その他改善を加えたい場合はConstructのソースコードのコメントアウトの内容を読んでやることになるでしょう。

ソースコードは非常に長いので、詳しく知りたい方はこれを覗いてみてください

micromark/packages/micromark-util-types/index.d.ts at main · micromark/micromark

small, safe, and great commonmark (optionally gfm, mdx) compliant markdown parser - micromark/micromark

OG Image

tweet-micromark-extension.ts

import { Tokenizer, Construct, Extension } from "micromark-util-types";

const  tokenizeTweetContainer: Tokenizer = function(effects, ok, nok) {
/* (ここに何か書く)*/
}

//拡張で呼び出されるConstruct
const tweetContainer: Construct = {
    tokenize: tokenizeTweetContainer,
    concrete: true,
}

//micromarkの拡張を定義
export const tweetExtension = (): Extension => {
    return {
        flow: {
            [codes.colon]: tweetContainer,
        },
    }
}

後はトークナイザーの中身を実装するだけです。

ここでは、Stateという型を使って実装をします。

トークナイザーの中では、複数の関数を順にめぐっていく形でトークン化がなされます。

micromark-extension-directive/dev/lib at main · micromark/micromark-extension-directive

micromark extension to support generic directives (`:cite[smith04]`) - micromark/micromark-extension-directive

OG Image

このソースコードがめちゃめちゃ参考になりました。

今回導入する規則の条件は

  1. :::tweetで始まっている
  2. 中身がただの文字で、なおかつ一行
  3. :::で終わっている

この3つです。

2番目の条件は私の至らなさゆえのもので、いつかは構文の中で改行したり、斜体や強調、箇条書き等を使えるようにしたいと思っています。

あとは、書くだけ。

本当はコードを全部載せて説明したいのですが、とても長いのでかいつまんで紹介します。

1. 開始Stateを用意

まずはしょっぱなに呼ばれる関数を用意してあげます。

flow: { [codes.colon]: tweetContainer }を拡張の発火条件としたので、先頭文字が:であることだけ確定しています。

tweet-micromark-extension.ts

import { Tokenizer, Construct, Extension } from "micromark-util-types";

const  tokenizeTweetContainer: Tokenizer = function(effects, ok, nok) {

    const start: State = (code) => {
        return /*次の関数*/
    }
    return start
}

2. Stateの中身を書く

条件分岐で構文の条件を満たしているかどうかを確認していきます。

確認してOKだったらトークン化を開始しましょう。

tweet-micromark-extension.ts

import { Extension, Construct, Tokenizer, State } from "micromark-util-types";
import { codes } from "micromark-util-symbol";

//TokenTypeを拡張
declare module 'micromark-util-types' {
    interface TokenTypeMap {
        tweetContainer: 'tweetContainer'//Tweet構文そのもの
        tweetContainerFence: 'tweetContainerFence'//開始記号":::tweet"
        tweetContainerFenceClose: 'tweetContainerFenceClose'//終了記号":::""
        tweetContainerContent: 'tweetContainerContent'//構文内の内容
    }
}

const  tokenizeTweetContainer: Tokenizer = function(effects, ok, nok) {

    const start: State = (code) => {
        //万が一開始が:ではなかったら
        if (code !== codes.colon) {
            //構文ではないので終了
            return nok(code);
        }
        //Token: tweetContainerの開始
        effects.enter("tweetContainer");
        //Token: tweetContainerFeneの開始
        effects.enter("tweetContainerFence");

        return /*次の関数*/
    }
    return start
}

codeには文字コードが数字で格納されていて、codesは直感的な文字列をその文字コードにマッピングしてくれます。

例えば:は58番、tは116番なのですが、毎度これを調べるのも面倒です。

ですが、micromark-util-symbolのcodesを使うと、codes.colonやcodes.lowercaseT等、数字よりもわかりやすい扱いが可能になります。

また、本来effects.enter()はmicromark-util-typesで決められたTokenしか始めることができませんが、宣言マージによってモジュールを拡張して解決しています。

ちなみに、この宣言マージを別ファイルで行うと解釈順序が乱れてmicromark-util-typesそのものがimportできなくなったり、拡張が反映されなかったりしてしまいました。

3. 次の関数に遷移させる

Startが終わったら次の関数に遷移させます。 他の関数だけでなく自身を再度呼び出して、同じ処理を繰り返したりもできます。その時には引数を与えなくていいことにも注意しましょう

tweet-micromark-extension.ts

const  tokenizeTweetContainer: Tokenizer = function(effects, ok, nok) {

    const start: State = (code) => {
        //万が一開始が:ではなかったら
        if (code !== codes.colon) {
            //構文ではないので終了
            return nok(code);
        }
        //Token: tweetContainerの開始
        effects.enter("tweetContainer");
        //Token: tweetContainerFeneの開始
        effects.enter("tweetContainerFence");

        return openSequence(code);
        //例えばstart自身をもう一回やりたいときはreturn start;でいい
    }

    let startColonCount = 0;

    //開始記号を解析:(:::tweet)
    const openSequence: State = (code) => {
        //":::"を消費
        if (code === codes.colon && startColonCount !== 3) {
            effects.consume(code);
            startColonCount++;
            return openSequence;
        }

        //'t'を読み取ったら"tweet"解析に進む
        if(startColonCount === 3 && code === codes.lowercaseT) {
            return openText(code);
        }

        //条件にそぐわない場合失敗
        effects.exit("tweetContainerFence");
        effects.exit("tweetContainer");
        return nok(code);
    }

    return start
}

先ほど挙げた3つの条件とにらめっこしながら、上記の流れでトークナイザーを作成しました。

mdast-util-from-markdownを拡張する


mdast-util-from-markdownのfromMarkdownはremark-parseの内部で使われている、mdastを構成する機能です。

GitHub - syntax-tree/mdast-util-from-markdown: mdast utility to parse markdown

mdast utility to parse markdown. Contribute to syntax-tree/mdast-util-from-markdown development by creating an account on GitHub.

OG Image

この機能を拡張することで、独自のトークンをどのようなノードにして構文木に組み込んだらよいのかを決めてあげます。

import { Extension, Handle } from "mdast-util-from-markdown";
import { Token } from "micromark-util-types";
import { Node as MdastNode } from "mdast";
import { Text } from "mdast";

//Tweet用のNode
interface TweetNode extends MdastNode {
    type: 'tweet';
    children: PhrasingContent[];
}

declare module 'mdast' {
    interface RootContentMap {
        tweet: TweetNode;//TweetNodeを許容するよう設定
    }
}

//トークンからTweetノード作成
export const tweetFromMarkdownExtension = (): Extension => {
    return {
        enter: {
            tweetContainer: enterTweetContainer,
        },
        exit: {
            tweetContainerContent: exitTweetContainerContent,
            tweetContainer: exitTweetContainer,
        },
    };
}

const enterTweetContainer: Handle = function (token: Token) {
    const node: TweetNode = {
        type: 'tweet',
        children: [],
    }

    this.enter(node, token);
}

const exitTweetContainerContent: Handle = function (token: Token) {
    const content = (this.sliceSerialize(token)).replace(/::$/, "");
    const textNode: Text = {
        type: "text",
        value: content,
      };
    const currentNode = this.stack[this.stack.length-1] as TweetNode;
    currentNode.children.push(textNode);
}

const exitTweetContainer: Handle = function (token: Token) {
    this.exit(token);
};

この部分は先ほどのトークナイザー程頭を回さなくてよくて、やりたいことを忠実に行うだけです。

先ほどの公式ドキュメント内に、どのようなメソッドが提供されているか詳しく書いてあるので、メソッドとにらめっこしながら実装しました。

Unifiedの中に拡張を組み込む。


remark-parseのソースコードをもう一度確認してみます。

index.js

/**
 * Aadd support for parsing from markdown.
 *
 * @this {Processor<Root>}
 *   Processor instance.
 * @param {Readonly<Options> | null | undefined} [options]
 *   Configuration (optional).
 * @returns {undefined}
 *   Nothing.
 */
export default function remarkParse(options) {
  const self = this

  self.parser = function (document) {
    return fromMarkdown(document, {
      ...self.data('settings'),
      ...options,
      // Note: these options are not in the readme.
      // The goal is for them to be set by plugins on `data` instead of being
      // passed by users.
      extensions: self.data('micromarkExtensions') || [],
      mdastExtensions: self.data('fromMarkdownExtensions') || []
    })
  }
}

拡張はどうやらself.data('micromarkExtension)等から読み取られていそうですね。

selfはthis(Processor)なので、何とかしてここに先ほどの拡張を追加したいです。

type Plugin<
  PluginParameters extends unknown[] = [],
  Input extends Node | string | undefined = Node,
  Output = Input
> = (
  this: Processor,
  ...parameters: PluginParameters
) => Input extends string // Parser.
  ? Output extends Node | undefined
    ? undefined | void
    : never
  : Output extends CompileResults // Compiler.
  ? Input extends Node | undefined
    ? undefined | void
    : never
  : // Inspect/transform.
      | Transformer<
          Input extends Node ? Input : Node,
          Output extends Node ? Output : Node
        >
      | undefined
      | void

プラグインの中でthisをいじれば、値を格納できそう…?

という事でさっそくプラグインを作成し、格納していきます。

markdown.ts

const setRemarkParseExtensions: Plugin = function() {
    const micromarkExtensions = (this.data('micromarkExtensions') || []) as MicromarkExtension[];
    const fromMarkdownExtensions = (this.data('fromMarkdownExtensions') || []) as fromMarkdownExtension[];
    this.data('micromarkExtensions', [...micromarkExtensions, tweetExtension()]);
    this.data('fromMarkdownExtensions', [...fromMarkdownExtensions, tweetFromMarkdownExtension()]);
};

これをunifiedのチェーンに組み込むと…

const result = await unified()
.use(setRemarkParseExtensions)
.use(remarkParse)
root[114] (1:1-484:1, 0-12881)
├─0   heading[1] (2:1-2:6, 1-6)
│     │ depth: 2
│     └─0 text "概要" (2:4-2:6, 4-6)
├─1   thematicBreak (3:1-3:4, 7-10)
├─2   paragraph[1] (5:1-5:67, 12-78)
│     └─0 text "私は以前,Unifiedに自作のtransformerのPluginを組み込むことで独自の構文(Tweet構文)を実現していました。" (5:1-5:67, 12-78)
├─3   tweet[1] (6:1-8:4, 79-107)
│     └─0 text "\nTweet構文とはわたしのこと"
├─4   paragraph[1] (10:1-10:17, 109-125)
│     └─0 text "ちなみにそれに関する記事はこちら" (10:1-10:17, 109-125)
├─5   paragraph[3] (12:1-14:4, 127-178)
│     ├─0 text ":::linkCard\n" (12:1-13:1, 127-139)
│     ├─1 link[1] (13:1-13:36, 139-174)
│     │   │ title: null
│     │   │ url: "https://ashihara.vercel.app/blog/04"
│     │   └─0 text "https://ashihara.vercel.app/blog/04" (13:1-13:36, 139-174)

これはこの記事のmdastの冒頭部分なのですが、3つ目のノードがTweetノードになっているのが分かるでしょうか。

変換成功です!!!

まとめ


  • remark-parseの内部でトークン化を担うmicromarkの拡張を定義
  • remark-parseの内部でmdast作成を担うmdast-util-from-markdownの拡張を定義
  • Unifiedのパイプラインに上記二つの情報を入れ込むためのPlugin作成

の3段階で、独自構文をパーサーで解釈できるようにしてみました。

今後の展望としては、より複雑な構文規則のトークン化ができたらよいなと思っています。