remark-parseを拡張して独自構文を導入した話
2025-01-29
概要
私は以前,Unifiedに自作のtransformerのPluginを組み込むことで独自の構文(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
ざっくり引用するとこんな感じ
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
めちゃめちゃ丁寧に拡張の仕方が書いてありました。この記事で書いているトークナイザーはこのやり方をまねている部分が多くあります。
また、より具体的な書き方を知るために、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.
こちらは具体的なやり方が書いていない代わりに、型の内容や使い方が丁寧に記載されていました。
既に提供されている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
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
このソースコードがめちゃめちゃ参考になりました。
今回導入する規則の条件は
- :::tweetで始まっている
- 中身がただの文字で、なおかつ一行
- :::で終わっている
この3つです。
2番目の条件は私の至らなさゆえのもので、いつかは構文の中で改行したり、斜体や強調、箇条書き等を使えるようにしたいと思っています。
あとは、書くだけ。
本当はコードを全部載せて説明したいのですが、とても長いのでかいつまんで紹介します。
1. 開始Stateを用意
まずはしょっぱなに呼ばれる関数を用意してあげます。
flow: { [codes.colon]: tweetContainer }を拡張の発火条件としたので、先頭文字が:であることだけ確定しています。
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だったらトークン化を開始しましょう。
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が終わったら次の関数に遷移させます。 他の関数だけでなく自身を再度呼び出して、同じ処理を繰り返したりもできます。その時には引数を与えなくていいことにも注意しましょう
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.
この機能を拡張することで、独自のトークンをどのようなノードにして構文木に組み込んだらよいのかを決めてあげます。
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のソースコードをもう一度確認してみます。
/**
* 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をいじれば、値を格納できそう…?
という事でさっそくプラグインを作成し、格納していきます。
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段階で、独自構文をパーサーで解釈できるようにしてみました。
今後の展望としては、より複雑な構文規則のトークン化ができたらよいなと思っています。