実録・remarkプラグインの作り方

remarkプラグインを自作した

markdownでも折りたたみがほしい!という動機でremarkプラグインを自作してみました。

こういうやつ

Lorem ipsum dolor sit amet consectetur adipisicing elit. Quod architecto vel voluptatibus, veniam sequi et nesciunt ex voluptatum dolor asperiores deleniti vero hic numquam, temporibus quo cum eligendi? Nemo, doloribus?

細かい話は例によってなにもわからないのですが、自分が理解した範囲のことを備忘録がてらまとめてみます。

この記事は、たいへんわかりやすいこちらの記事を

前提知識ゼロの状態から自分なりに理解できるようにさらに嚙み砕いて、どうにか自分の作りたいプラグインを作った記録です。
エンジニアの方が書かれるようないわゆる技術記事ではありません。書いているのは独学の素人である点ご留意ください。

この記事の環境

  • Astro: 5.15.1
  • remark-directive: 4.0.0
  • tailwindcss: 4.1.13
  • daisyui: 5.3.3

概要をつかむ

Astroでのマークダウンプラグインについては、公式ドキュメントに導入方法含めしっかりまとまっています。

マークダウンに独自記法を増やそうと思ったらremarkプラグインというのを作ればいいらしい。

ということはわかったものの、そもそもremarkってなに?というレベルだったので、まずは大まかな概要をつかみます。

変換の流れ

マークダウンがhtmlになって出てくるまでにはいくつかのステップがあり、だいたいこんなかんじのようです。

マークダウンの変換の流れ

mdasthastはAST(抽象構文木)とかいうやつのひとつで、markdownのASTだからmdast、htmlのASTだからhast、ということのようです。

ではAST(抽象構文木)とはなにか、といくらか説明を読んでも全然わかりませんでした。深く考えるのはよして、とりあえずプラグインを作りましょう。

見たほうが早い

説明を読んでもわからないので、mdastとhastとやらを実際に見てみましょう。

このplaygroundで、マークダウンがそれぞれの段階でどんなふうになるのかを見ることができます。 サンプルで入ってるマークダウンのまま、Showの下のプルダウンをmdasthastcompiled codeの順に切り替えてみましょう。なんとなく流れが掴めると思います。

ここではさらに、一般的なプラグインの使用有無もチェックで切り替えることができます。非常に便利です。
AstroではデフォルトでGFMが使えるようになっているのでuse remark-gfmにチェックを入れましょう。タスクリストとテーブルが機能するようになるのがわかると思います。

このmdastをいじるのがremarkプラグイン、hastをいじるのがrehypeプラグイン。なるほど。

独自記法とかのマークダウンに近いところをいじりたいならremarkプラグイン、最後に出てくるhtmlに近いものをいじりたいならrehypeプラグイン、くらいの感覚で理解しておきます。

(実際のところ処理順の都合とかのほうが大きくてどっちで書こうが構わない場合も多いんじゃないかと思いますが、基本的には近いほうで書いたほうが楽…なんじゃないかな…??)

mdastとhastはどちらも、unist (unified's trees)というASTの共通仕様的なものに則っており、そのおかげでunified側で用意されている仕組みを使ってツリーの走査などのもろもろが簡単にできるようになっています。unist-xx というのが出てきたらそれです。便利。

プラグインを作る

remark-directiveを導入する

登場人物紹介はこれくらいにして、とりあえずremarkプラグインを作っていきます。
今回作りたいのはdetailsとsummaryを使ったよくある折りたたみです。

つまり既存のマークダウンにない独自記法を追加することになるわけですが、独自記法ったってぼくがかんがえたさいきょうの記法を無から生み出す必要はありません。いややりたければそれでもいいと思うけど…。

マークダウンの拡張として提案されている有名な汎用記法がすでにあって、それを実現するremarkプラグインもすでにあります。

ありがとう先人の知恵!
つまり、このプラグインを使って、この汎用記法に則った自分の記法を実装すれば良いわけです。

というわけで、マークダウンを以下のように書いたら

:::collapse[見出し]
ここに本文
:::

<details>
<summary>見出し</summary>
<p>ここに本文</p>
</details>

こうなるようにします。
もちろん本文部分はpだけでなくリストでも画像でもなんでも入ります。

ただ、daisyUIを使っていい感じに装飾したい都合上、実際にはこうなるようにします。

<details class="remark-collapse-details collapse collapse-arrow">
<summary class="remark-collapse-summary collapse-title">見出し</summary>
<div class="remark-collapse-content collapse-content">
<p>ここに本文</p>
</div>
</details>

急にごちゃごちゃした…。
クラス名を足し、折りたたまれる中身を別途divで囲っています。
(なんでクラス名がこんなことになっているのかは後で説明します)

mdastを確認する

作業中は先述のplaygroundと首っ引きで、なにをどう書き換えればいいのかを考えていきます。

まず、書いたマークダウンがmdastになるとどうなるかを確認しましょう。
playgroundでShowのプルダウンをmdastにしておきます。

サンプルのマークダウンを消して、先ほどの独自記法を貼り付けます。

:::collapse[見出し]
ここに本文
:::

paragraphが1つできて、貼り付けたマークダウンの内容が全部そこに入った状態になっていると思います。

そこで use remark-directiveにチェックを入れます。

// rootは省略
{
"type": "containerDirective",
"name": "collapse",
"attributes": {},
"children": [
{
"type": "paragraph",
"data": {
"directiveLabel": true
},
"children": [
{
"type": "text",
"value": "見出し"
}
]
},
{
"type": "paragraph",
"children": [
{
"type": "text",
"value": "ここに本文"
}
]
}
]
}

ちゃんとtypeがcontainerDirectiveになっていますね。nameにcollapseも入っています。
見出し部分もなんかlabelっぽいのがtrueになってる。よし!

これをhastまたはcompiled codeに切り替えてみると、div>p*2になっていることがわかります。

こんなかんじで変換自体はremark-directiveがよしなにやってくれるので、あとは出力をdivじゃなくてdetailsにするためのコード等々を書いていけばよいだけです。

ツリーを走査する

先述のunistより、unist-util-visitを使ってmdastツリー全体から処理したいノードだけを抽出します。

./src/utils/remarkCollapse/index.ts
import type { Root } from "mdast";
import type { ContainerDirective } from "mdast-util-directive";
import { visit } from "unist-util-visit";
export default function remarkCollapse() {
return function (tree: Root) {
visit(tree, "containerDirective", (node) => {
if (node.name !== "collapse") return;
// ...
});
};
}
  • visit(tree, "typeName", (node) => {...})
    • tree: Root :markdownから変換されたmdast全体
    • "typeName" :ツリーから抽出したいmdastのtype。今回は"containerDirective"
    • コールバック:抽出した対象ノードをどうしたいか(返り値はなし)

visitcontainerDirectiveだけを抽出し、そこからさらにnamecollapseのものだけに絞り込んでいます。

対象ノードをいじる

このvisitのコールバック関数の中で対象ノードの出力をいじります。
returnする必要はなく、node自体を書き換えていきます。

実はremark-directive で追加される3種類のノード1dataというプロパティを持っており、このdataではなんとhastに変換されるときにどうしてほしいかを指定することができるのです。すごいぜ!

visit(tree, "containerDirective", (node) => {
if (node.name !== "collapse") return;
const data = node.data || (node.data = {});
data.hName = "details";
data.hProperties = {
className: ["remark-collapse-details", "collapse", "collapse-arrow"],
};
// ...
}
  • node.data :そのノードがhastではどう変換されるか
    • hName:hastのtagName
    • hProperties?:hastのproperties
    • hChildren:hastのchildren (今回は不使用)

playgroundでhastに切り替えてみるとわかりますが、mdastでtype: containerDirectiveだったノードはhastではtype:element, tagName: divになっています。compiled codeに切り替えるとそのまんま<div>タグになっていますね。

このhastのtagNameをmdastの側からいじれるのがnode.data.hNameです。
同様に、hPropertiesでは属性をいじることができますので、ここでクラス名もつけておきます。

remark-directiveさまさますぎる…。

[見出し]を拾う

中身についても基本的には同じことなのですが、見出しはsummaryにして、折りたたまれる中身はdivで囲わなくてはいけません。
まずは見出しをなんとかします。

この通りContainerDirectiveのラベルの中身はdirectiveLabel: true付きで子ノードの先頭に入ります。

見出しがあってもなくても折りたたみ自体は機能するようにしたいので、あるなし両方でなんとかなるようにします。

// [label]がないなら足す
const defaultLabel = "続きを読む"
if (
!(node.children[0].type === "paragraph" &&
node.children[0].data?.directiveLabel)
) {
node.children.unshift({
type: "paragraph",
children: [
{
type: "text",
value: defaultLabel,
},
],
});
}
node.children[0].data = {
hName: "summary",
hProperties: {
className: ["remark-collapse-summary", "collapse-title"],
},
};

ノードを扱うときは最初にtypeで絞り込まないとだいたい何を書いても怒られます。この場合だとtypeparagraphだと確定していないとdirectiveLabelまでたどり着けません。
てことで条件式、他にどう書いたらいいかわかんなかった…。

divで囲む

次は折りたたみの中身です。
折りたたみの中身をdivで囲うには、現在のmdast上にないdiv(になるContainerDirective)を新しく作る必要があります。

先ほどない見出しをねじこんだ要領で、ContainerDirectiveを新しく作って形を整えます。

const contentNodes = node.children.slice(1);
const contentWrapper: ContainerDirective = {
type: "containerDirective",
name: "content", // 必須なので便宜的に
data: {
hName: "div",
hProperties: { className: ["remark-collapse-content", "collapse-content"] },
},
children: contentNodes,
};

typenamechildrenは必須ですが、この使い方だとnameはいらないので適当に入れておきます。
見出し以外の中身をまとめてchildrenに突っ込んでラップします。

できあがり

最後に、details>summary+divになるようにnode.childrenの中身を詰め替えてできあがりです!

node.children = [node.children[0], contentWrapper];

これでおわかりの通り、最終的なノードは別にツリーからの加工品ではなくゼロから生成しても全然問題ありません。
ファイル名だけ書いてローカルからその中身を持ってくるとか、なんならランダムに運勢を返すとか(ビルドするけど…)どうにでもなります。

全コード

最終的な全体のコードは以下です。

全コード
./src/utils/remarkCollapse/index.ts
import type { Root } from "mdast";
import type { ContainerDirective } from "mdast-util-directive";
import { visit } from "unist-util-visit";
export default function remarkCollapse() {
return function (tree: Root) {
visit(tree, "containerDirective", (node) => {
if (node.name !== "collapse") return;
const data = node.data || (node.data = {});
data.hName = "details";
data.hProperties = {
className: ["remark-collapse-details", "collapse", "collapse-arrow"],
};
// {open}があれば最初から開ける
if (node.attributes?.open === "") {
data.hProperties = {
...data.hProperties,
open: true,
}
};
// [label]がないなら足す
const defaultLabel = "続きを読む"
if (
!(node.children[0].type === "paragraph" &&
node.children[0].data?.directiveLabel)
) {
node.children.unshift({
type: "paragraph",
children: [
{
type: "text",
value: defaultLabel,
},
],
});
}
node.children[0].data = {
hName: "summary",
hProperties: {
className: ["remark-collapse-summary", "collapse-title"],
},
};
const contentNodes = node.children.slice(1);
const contentWrapper: ContainerDirective = {
type: "containerDirective",
name: "content", // 必須なので便宜的に
data: {
hName: "div",
hProperties: { className: ["remark-collapse-content", "collapse-content"] },
},
children: contentNodes,
};
node.children = [node.children[0], contentWrapper];
});
};
}

ついでに、ラベルの後ろに{open}を足したら最初からopen状態になるようにしています。そんな機能使うか?という疑問は置いておいてせっかくなので実装しておきました。

{key=value}ではなく{key}だけで書く場合、attributesにkeyは増えるもののvalueは空("")になるので分岐条件に気を付ける必要があります。
if (node.attributes?.open)だと常にfalseになっちゃうので…(引っかかった)

あとはこれをastro.config.mjsにimportし、remarkPluginsの列に加えれば使えます。お疲れさまでした!

しょうもない余談

あとは特に読まなくてもいい、無駄にひっかかった部分の話です。

daisyUI剥がせない問題

先にも申しましたが、出力のクラスの付け方がいかにもわけがわからないかんじですね。

<details class="remark-collapse-details collapse collapse-arrow">
<summary class="remark-collapse-summary collapse-title">見出し</summary>
<div class="remark-collapse-content collapse-content">
<p>ここに本文</p>
</div>
</details>

remark-collapse-で始まっているのが自分でつけた独自クラス、それ以外はdaisyUIのcollapseコンポーネントのクラスです。

本当はそれぞれのhtmlタグに独自クラスを付け、別途cssファイルで@applyしてスタイリングしようとしていました。
他のプラグインを見てみてもだいたいそういうつくりでしたし、プラグインをいじってもホットリロードが効かない問題(後述)もあって調整のいる見た目部分は分離しておきたいと思ったので。

…なんですけど、どうもcollapseコンポーネントのクラスは直接つけておかないと正常に動かないようで。

まあ、当たり前と言えば当たり前なんですが…。daisyUIのクラス名にホバーすると実際に追加されるcssが見られるのですが、コンポーネントとしてセットで使うクラスはだいたい連動しています。

たとえば折りたたみの右端に開閉に連動するアイコンを付与するクラスcollapse-arrow だと、

.collapse-arrow {
@layer daisyui.modifier {
> .collapse-title:after {...}

みたいになっています。
playgroundにサンプルを用意してみましたので、実際にホバーしてみるとわかりやすいと思います。

Tailwindの@applyは、当たり前ですがセレクタで指定された要素に対してCSSを付与するものであって、クラス名は付与されません。よって、外部CSSで書くとクラス名ベースで動くdaisyUIのコンポーネントが壊れてしまう、という単純な話でした。

ということに気が付くまでに結構右往左往したので、ここに記しておきます。そもそもmdxでastroコンポーネントにでも置き換えりゃいいところをこっちのが汎用性あるからっつってremarkプラグインにしてるのにそこで別のものに依存してたら意味ないにもほどがあるだろ、というのは…本当にそう…。
スタイリングをサボりつつ簡単に綺麗な開閉アニメーションつけたかったの~!いつの間にかdetailsでも普通にアニメーションつけられるようになっててすごいや!

独自クラスが残っているのは、コンポーネントに直接関わらない素のTailwindのスタイル(bg-*my-*等)は結局外部CSSで@applyしているからです。やっぱり基本的には分離したほうがいいと思うし…。

ホットリロード効かない問題

そのまんまですが、remarkプラグインのtsファイルをいじってもホットリロードが効かない…。
なんならいったん止めてnpm run devし直しても変更が反映されなかったりして、開発中だいぶ困りました。VScode再起動すればだいたいは反映されるんですがそれはいくらなんでも面倒くさすぎるし…。

プラグインはastro.config.mjsで読み込んでいる都合上、Viteのなんやかやでいろいろあってホットリロードされないんだとおもいます(なにもわからない)
一応AIに聞いてvite.server.fs.allowにtsファイルのあるフォルダを指定してみたりはしたんですが、変更は検知するけど書き変わらない…。
……今のわたしの手に負える問題じゃないやつだ!

とりあえずastro.config.mjsを変更すればRe-optimizing dependencies because vite config has changedつってConfiguration file updated. Restarting...してくれるので、これで無理矢理なんとかしていました。ちょっと時間かかるし面倒くさいけど確実に変更が反映されるので…。

具体的にはremarkPluginsの当該プラグインの読み込みを消したり戻したりをCtrl+Zとかでしてました。わはは…。

テストコードはじめました

という問題もあって、今回プラグインをつくるにあたって人生初めてのテストコードを書くに至りました。
今までテストしてなかったのかって?そうだよ!見た感じ動いてるからまあええやろという精神で生きてきたよ!

なにもわからないままvitest入れて、わ~結果がすぐわかってすごーい!くらいのノリでテストをしました。とってもべんりだなあとおもいました。これからもっとべんきょうしていきたいです。

独学だとマジで自分が必要になった部分しか学ばないからこうなるよなあ…。まあ必要な部分が必要な時にわかればいいかあ…という精神。

remarkのplaygroundの下のほうに手元で同じ結果を得たいならこうすればいいぞ!っていうのがちゃんとついてて助かりました。なんて至れり尽くせりなんだ…。

次回予告

調子に乗ってrehypeプラグインも作った2ので、rehypeプラグインを作った話も書きます。
remarkプラグインの話をまとめるだけですごい時間かかっててすでに泣きそうですけど…。
でもrehypeプラグインのほうはそんなに書くことないような気もする。

さておき、改めて手順を振り返るとめっちゃ勉強になりますね。期せずしてちょっと変だったコードも直せたし。
他にも書きたい記事いろいろあるので気長にちょっとずつがんばります…。


  1. ContainerDirective, LeafDirective, TextDirectiveの3種類。

  2. こんなふうに脚注をtitleに入れてホバー表示できるrehypeプラグインです。