unified で プラグインを自作したい

<!--

- TODO
    - 抜き出し（not html）
    - 抜き出し (html, visit 導入)
    - mdast の操作から hast の操作にへんこう
- 命名規則
    - markdownText2markdownAst
    - markdownAst2htmlAst
    - htmlAst2HtmlText

みたいなのが良かったな...

各 AST に対応するプラグインは接頭辞に markdownAddSlug, htmlAddToc のように AST の言語名を接頭辞として付与するみたいな...

本稿ではキャメルケースに統一する。

  -->

:::note alert

書きかけです。特にサンプルコードが汚いです。

:::


- 環境は Deno です。
- 各ファイルはコピペで動きます。
- 書いている本人も理解はしていません。
- 困っていて記事に書けていないこと
    - 型を記述できなくて苦慮しています...orz
    - これかな？と思ってライブラリから型をコピペしてきてもエラーを返されます...orz

## 0. 基本形

```ts:sample.ts
import rehypeStringify from "https://esm.sh/rehype-stringify@10.0.0"
import remarkParse from "https://esm.sh/remark-parse@11.0.0"
import remarkRehype from "https://esm.sh/remark-rehype@11.0.0"
import { unified } from "https://esm.sh/unified@11.0.4"
import { VFile } from "https://esm.sh/vfile@6.0.1"

const markdown = `\

# serial experiments lain

> 人は人の記憶の中でしか実体なんてない。だからいろんなあたしがいたの。
> あたしがいっぱいいたんじゃなくて、色んな人の中のあたしがいただけ

> あたしは何もしないよ。
> あっちと、こっち側と、どっちが本物とかじゃなく、あたしはいたの。
> あたしの存在自体が、ワイヤードとリアルワールドの領域を崩すプログラムだったの

`

const processor = unified()
  .use(remarkParse) //        Markdown -> mdast
  .use(remarkRehype) //       mdast    -> hast
  .use(rehypeStringify) //    hast     -> HTML

const vFile: VFile = processor.processSync(markdown)
console.log(String(vFile))
```
```
$ deno run sample.ts
<h1>serial experiments lain</h1>
<blockquote>
<p>人は人の記憶の中でしか実体なんてない。だからいろんなあたしがいたの。
あたしがいっぱいいたんじゃなくて、色んな人の中のあたしがいただけ</p>
</blockquote>
<blockquote>
<p>あたしは何もしないよ。
あっちと、こっち側と、どっちが本物とかじゃなく、あたしはいたの。
あたしの存在自体が、ワイヤードとリアルワールドの領域を崩すプログラムだったの</p>
</blockquote>
$ 
```




<details><summary>Deno の補足</summary>

### (1) Deno を使っていいの？

自分みたいな素人が手を出すのは危険な気がするのですが、コピペで動作を示すサンプルコードならいいのかなと... 環境を揃えるの楽だし...

> 一番の問題点、Node は新しいプロジェクトを一式整えるための手間が非常に重い。とくに ts で書いたものを他の環境に渡すための方法が未だにしんどい。ある環境で動いたコードをそのままコピーしても、プロジェクト設定の非互換を踏む可能性が非常に高い。deno にそういう側面がないとは言わないが、非常に少ない。とくに TS が直接動く + HTTP Import で、一度書いたコードのポータビリティが非常に高い。
> * [最初に: なぜ Deno を使いたいか - Zenn](https://zenn.dev/mizchi/articles/deno-first-choice)



<br>

### (2) esm.sh ってなに？

import 文に登場する `https://esm.sh/` は CDN だそうです。`esm.sh` は [esmbuild](https://www.google.com/search?q=esbuild) を使って JavaScript のコードを [CommonJS](https://www.youtube.com/watch?v=3B2VFh1RVGM) 形式（`require` 関数, `module.exports` プロパティ）から [ECMAScript](https://www.youtube.com/watch?v=BaCgOnHyDa4) 形式(`import` 文, `export` 文)に書き換えたコードを配信してくれているそうです。`esm` は [ES Modules](https://www.youtube.com/watch?v=UkTLGN02YLs) の略だと思われます。

* ["esm.sh" - Google 検索結果](https://www.google.com/search?q=%22esm.sh%22)
* [esm.sh による npm パッケージ管理に便利な CLI スクリプトについて - Zenn](https://zenn.dev/okayurisotto/articles/ba1d657b7913fd)
* [esm.sh について - GitHub Gist](https://gist.github.com/ikasoba/2aec36e5d5ea697665401c2866e635f3)
* [esm.shを理解したい - Zenn](https://zenn.dev/hashrock/scraps/9e9eb8620553f7)
    *  esbuild による変換前と変換後のコードを例示してくれています

<br>

### (3) パッケージはどうやって探すの？

npm, JSR, GitHub にあるパッケージを変換してくれている様子なので、それぞれのサイトで検索することになるかと思います。

* npm ... https://www.npmjs.com/ 
* JSR ... https://jsr.io/ 
* GitHub ... https://github.com/

パッケージ名と import 文に記載する URL の対応規則は以下の通りです。詳細はリンク先を参照してください。

```js
// npm
// `https://esm.sh/${パッケージ名}`
import React from "https://esm.sh/react"

// JSR
// `https://esm.sh/jsr/${パッケージ名}`
import * as mod from "https://esm.sh/jsr/@std/encoding"

// GitHub
// `https://esm.sh/gh/${ユーザー名}/${リポジトリ名}`
import tslib from "https://esm.sh/gh/microsoft/tslib"

```

* [Supported Registries, esm-dev/esm.sh - GitHub](https://github.com/esm-dev/esm.sh?tab=readme-ov-file#supported-registries)




<br>

### (4) import に URL を書きたくない...

import 文に URL を書きたくない方はリンク先を参照してください。

```js
// これはいや
import remarkRehype from "https://esm.sh/remark-rehype@11.0.0"

// こっちがいい
import remarkRehype from "remark-rehype"
```

> この記述方法は、JSR を Node/npm から使う場合と同じ記述方法になっていて、クロスプラットフォームな記述方法にもなっています。
> * [Q. Deno から JSR パッケージを使う場合、jsr: インポート指定子 (specifier) はソースコード上に直接書くべきか? - Zenn](https://zenn.dev/kt3k/articles/4aa235ff817a6c#q.-deno-%E3%81%8B%E3%82%89-jsr-%E3%83%91%E3%83%83%E3%82%B1%E3%83%BC%E3%82%B8%E3%82%92%E4%BD%BF%E3%81%86%E5%A0%B4%E5%90%88%E3%80%81jsr%3A-%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88%E6%8C%87%E5%AE%9A%E5%AD%90-(specifier)-%E3%81%AF%E3%82%BD%E3%83%BC%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%89%E4%B8%8A%E3%81%AB%E7%9B%B4%E6%8E%A5%E6%9B%B8%E3%81%8F%E3%81%B9%E3%81%8D%E3%81%8B%3F)




<br>




</details>


https://www.youtube.com/watch?v=oSexyChDFmo



## 1. インライン

```:変換前 - Markdown
[うほうほ]<uhouho>
```
```html:変換後 - HTML
<ruby>うほうほ<rt>uhouho</rt></ruby>
```

### (1) `{ allowDangerousHtml: false }` の場合

```ts:sample.ts
import rehypeStringify from "https://esm.sh/rehype-stringify@10.0.0"
import remarkParse from "https://esm.sh/remark-parse@11.0.0"
import remarkRehype from "https://esm.sh/remark-rehype@11.0.0"
import { unified } from "https://esm.sh/unified@11.0.4"
import { visit } from "https://esm.sh/unist-util-visit@3.1.0"
import { VFile } from "https://esm.sh/v135/vfile@6.0.1/index.js"

const markdownText = `\
とある魔術の[禁書目録]<インデックス> [うほうほ]<ほげほげ>


* とある魔術の[禁書目録]<インデックス> [うほうほ]<ほげほげ> ドナドナ

> とある魔術の[禁書目録]<インデックス> [うほうほ]<ほげほげ> ドナドナ

<!-- これは動作しない... -->

* とある魔術の[禁書目録]<インデックス> [うほ[うほ]<uho>]<ほげほげ>

<div>sample text</div>
`

const processor = unified()
  .use(remarkParse)
  .use(remarkRuby)
  .use(remarkRehype)
  .use(rehypeStringify)

const vFile: VFile = processor.processSync(markdownText)
console.log(String(vFile))

//
//
//
function remarkRuby() {
  return (tree) => {
    visit(tree, "text", (node, index, parent) => {
      if (!parent || !node.value) return
      const children = createaChildren(node.value)
      if (children.length > parent.children.length) {
        parent.children = children
      } else {
        // console.log("----")
        // console.log(text)
        // console.log(parent.children)
        // console.log(children)
      }
    })
  }
}

function createaChildren(text: string): any[] {
  const children: any[] = []
  const regex = /\[(.*?)\]<(.+?)>/g
  let lastIndex = 0
  let match: RegExpExecArray | null
  while ((match = regex.exec(text)) !== null) {
    if (match.index > lastIndex) {
      children.push({
        type: "text",
        value: text.slice(lastIndex, match.index)
      })
    }

    children.push({
      type: "div",
      data: { hName: "ruby" },
      children: [
        {
          type: "text",
          value: match[1]
        },
        {
          type: "rt",
          data: { hName: "rt" },
          children: [
            {
              type: "text",
              value: match[2]
            }
          ]
        }
      ]
    })

    // 次の検索開始位置を更新
    lastIndex = regex.lastIndex
  }

  // 最後のマッチ後に残ったテキストを追加
  if (lastIndex < text.length) {
    children.push({
      type: "text",
      value: text.slice(lastIndex)
    })
  }

  return children
}
```
```
$ deno run sample.ts
<p>とある魔術の<ruby>禁書目録<rt>インデックス</rt></ruby> <ruby>うほうほ<rt>ほげほげ</rt></ruby></p>
<ul>
<li>とある魔術の[禁書目録]&#x3C;インデックス> [うほ[うほ]]&#x3C;ほげほげ></li>
<li>とある魔術の<ruby>禁書目録<rt>インデックス</rt></ruby> <ruby>うほうほ<rt>ほげほげ</rt></ruby> ドナドナ</li>
</ul>
<blockquote>
<p>とある魔術の<ruby>禁書目録<rt>インデックス</rt></ruby> <ruby>うほうほ<rt>ほげほげ</rt></ruby> ドナドナ</p>
</blockquote>
$ 
```



### (2) `{ allowDangerousHtml: true }` の場合

もっと簡単に書けます。

```ts:sample.ts
import rehypeStringify from "https://esm.sh/rehype-stringify@10.0.0"
import remarkParse from "https://esm.sh/remark-parse@11.0.0"
import remarkRehype from "https://esm.sh/remark-rehype@11.0.0"
import { unified } from "https://esm.sh/unified@11.0.4"
import { visit } from "https://esm.sh/unist-util-visit@3.1.0"
import { VFile } from "https://esm.sh/v135/vfile@6.0.1/index.js"

const markdownText = `\
とある魔術の[禁書目録]<インデックス>

> うほうほ

<div>sample text</div>
`

const processor = unified()
  .use(remarkParse)
  .use(remarkRuby)
  .use(remarkRehype, { allowDangerousHtml: true })
  .use(rehypeStringify, { allowDangerousHtml: true })

const vFile: VFile = processor.processSync(markdownText)
console.log(String(vFile))

//
//
//
function remarkRuby() {
  return (tree) => {
    visit(tree, "text", (node, index, parent) => {
      if (!parent || !node.value) return
      const regex = /\[(.*?)\]<(.+?)>/g
      let match
      while ((match = regex.exec(node.value)) !== null) {
        const [fullMatch, mainText, rubyText] = match
        const rubyHtml = `<ruby>${mainText}<rt>${rubyText}</rt></ruby>`
        parent.children[index] = {
          type: "html",  // <--- { allowDangerousHtml: false } の場合、無視される...
          value: node.value.replace(fullMatch, rubyHtml)
        }
      }
    })
  }
}
```
```
$ deno run sample.ts
<p>とある魔術の<ruby>禁書目録<rt>インデックス</rt></ruby></p>
<blockquote>
<p>うほうほ</p>
</blockquote>
<div>sample text</div>
$ 
```


### ◯ 参考

- こちらのほうがちゃんとやられているがなにをやっているか理解できず断念...
    - [Remark で広げる Markdown の世界](https://bit.ly/4dMTRRR)
- インライン, １行の内容を置換するだけなら unified 使わずとも正規表現で置換するだけでよいのでは？みたいなことを思いながらコードを書いてました。

> 今回は、プラグインを増やすと学習コストが増えること、複雑なセレクタ指定を使ったマークアップはしないことを理由に、正規表現でclassを付与していく方針をとりました。
> * [unified, remark, rehypeでマークダウンをHTMLに変換する](https://blog.howtelevision.co.jp/entry/2024/02/09/120000)


## 2. ブロックライン

```md:変換前 - Markdown
:::

うほうほ

:::
```
```html:変換後 - HTML
<div>

うほうほ

</div>
```


### (1) `{ allowDangerousHtml: false }` の場合

```ts
import rehypeStringify from "https://esm.sh/rehype-stringify@10.0.0"
import remarkParse from "https://esm.sh/remark-parse@11.0.0"
import remarkRehype from "https://esm.sh/remark-rehype@11.0.0"
import { unified } from "https://esm.sh/unified@11.0.4"
import { visit } from "https://esm.sh/unist-util-visit@5.0.0"
import { Node, Parent } from "https://esm.sh/v135/@types/unist@3.0.2/index.d.ts"

function remarkMessage() {
  return (tree) => {
    visit(tree, "paragraph", (node: Node, index: number, parent: Parent) => {
      // ":::"で始まり":::"で終わるブロックを探す
      if (node.children[0].value.startsWith(":::")) {
        node.children[0].value = node.children[0].value.slice(3)
        const end = parent.children.findIndex(
          (n, i) =>
            i > index && n.type === "paragraph" && n.children[0].value === ":::"
        )
        if (end !== -1) {
          // カスタムブロック内のノードをdivで囲う
          const block = {
            type: "div",
            data: { hName: "div", hProperties: { className: ["message"] } },
            children: parent.children.slice(index + 1, end)
          }
          parent.children.splice(index, end - index + 1, block)
        }
      }
    })
  }
}

// unifiedプロセッサの設定
const processor = unified()
  .use(remarkParse)
  .use(remarkMessage)
  .use(remarkRehype, { allowDangerousHtml: false })
  .use(rehypeStringify, { allowDangerousHtml: false })

// サンプルのMarkdown
const markdownText = `
こんにちは、世界！

:::

<div>Hola!</div>

> Hello, world!

<p>
  > **Nihao, shijie!**
</p>

:::

さようなら、世界！
`

// Markdown を HTML に変換
const vFile = processor.processSync(markdownText)
console.log(String(vFile))

```


### (2) `{ allowDangerousHtml: true }` の場合

なし


### ◯ プロンプト

こんな感じのプロンプトを書いたら ChatGPT 先生が教えてくれました...

```
# Markdown を扱う unified というライブラリにおいて、つぎのようなプラグインを作成してください。

---md
<!--  sample.md -->

こんにちは、世界！

:::

Hello, world!

Nihao, shijie!

:::

さようなら、世界！
---
---html

<p>こんにちは、世界！</p>
<div class="message">
  <p>Hello, world!</p>
  <p>Nihao, shijie!</p>
</div>
<p>さようなら、世界！</p>
---

* `:::` で囲われた部分には任意の Markdown 形式のテキストが入るものとします。
* 実行環境は Node.js ではなく Deno であるとします。
* 提示するコードには `sample.md` のテキストを使用してください。
```



### ◯ 参考

`<details>` タグを `:::` で拡張されたことを指しているのかな？と思ったのですが、こういうのを見ると unified で拡張記法を安易に定義するのもためらわれる😇

https://x.com/catnose99/status/1419434566897397760

https://x.com/catnose99/status/1419460691753246721


* [unified におけるプラグインまとめ - Zenn](https://zenn.dev/januswel/articles/745787422d425b01e0c1)

<details><summary>details</summary>

上記の記事の内容でコードを実装しました。理解はできていません。

```ts
import rehypeStringify from "https://esm.sh/rehype-stringify@10.0.0"
import remarkParse from "https://esm.sh/remark-parse@11.0.0"
import remarkRehype from "https://esm.sh/remark-rehype@11.0.0"
import { Plugin, unified } from "https://esm.sh/unified@11.0.4"
import { visit } from "https://esm.sh/unist-util-visit@5.0.0"
import { Paragraph, Text } from "https://esm.sh/v135/@types/mdast@3.0.10/index.d.ts"; // prettier-ignore
import { Literal, Node, Parent } from "https://esm.sh/v135/@types/unist@3.0.2/index.d.ts"; // prettier-ignore
import { VFileCompatible } from "https://esm.sh/vfile@6.0.1"

/**
 *
 * - 1
 *     - １つのプラグインとして定義できないのか？
 *     - remark, rehype で分割して定義しないといけないのか？
 *     - rehypheMessage で１つにまとめたほうが綺麗だったのでは？
 * - 2
 *     - unified を介さず正規表現で扱ってしまったほうが楽なのでは？
 * - 3
 *     - ほかのプラグインはどう実装しているんだろう？
 *     - https://zenn.dev/januswel/articles/745787422d425b01e0c1
 *
 */
const MESSAGE_BEGINNING = ":::message\n"
const MESSAGE_ENDING = "\n:::"

const markdownText = `\
# title

uhouho

:::message
This is a custom note.
:::

uhouho

## Hello, world!
`

const processor = unified()
  .use(remarkParse)
  .use(remarkMessage)
  .use(remarkRehype, { handlers: { message: handler } }) // @ts-ignore: よくわからない...
  .use(rehypeStringify)

const vFile = processor.processSync(markdownText)
console.log(String(vFile))

//
// 1. remarkMessage
//
function remarkMessage(): Plugin {
  return (tree: Node, _file: VFileCompatible) => {
    visit(tree, isMessage, visitor)
  }
}

// 1.1. isMessage
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_BEGINNING))) {
    return false
  }

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

  return true
}

function isText(node: unknown): node is Text {
  return isLiteral(node) && node.type === "text" && typeof node.value === "string" // prettier-ignore
}
function isParagraph(node: unknown): node is Paragraph {
  return isNode(node) && node.type === "paragraph"
}

function isNode(node: unknown): node is Node {
  return isObject(node) && "type" in node
}

function isParent(node: unknown): node is Parent {
  return isObject(node) && Array.isArray(node.children)
}

function isLiteral(node: unknown): node is Literal {
  return isObject(node) && "value" in node
}

function isObject(target: unknown): target is { [key: string]: unknown } {
  return typeof target === "object" && target !== null
}

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

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

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

function processFirstChild(children: Array<Node>, identifier: string) {
  const firstChild = children[0]
  const firstValue = (firstChild as Text).value
  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 as Text).value
  if (lastValue === identifier) {
    children.pop()
  } else {
    children[lastIndex] = {
      ...lastChild,
      value: lastValue.slice(0, lastValue.length - identifier.length)
    }
  }
}

//
// 2. handler
//
function handler(state: any, node: Node) {
  return {
    type: "element",
    tagName: "div",
    properties: {
      className: ["msg"]
    },
    children: state.all(node)
  }
}

```

</details>


## 3. 要素の抜き出し



### ◯ プロンプト

ChatGPT 先生に聞いてみてください。自分の場合は答えてくれました。ChatGPT 先生は意外と雑に聞いてもなんでも答えてくれてビビります...

> Markdown を取り扱う unified というライブラリのプラグインにおいて、サイドバーに表示された H2, H3, H4 の一覧をページの現在位置に合わせて色を変えて現在位置を教えてくれるプラグインはありますか？

自分以外にもやりたいと思っている方がいらっしゃった... 

https://x.com/sorinaji/status/1697031105675976849

### ◯ 参考

- [syntax-tree/mdast-util-toc - GitHub](https://github.com/syntax-tree/mdast-util-toc)
- [unifiedでMarkdownをHTMLに変換 & ReactでQiitaっぽい目次を作る](https://takumon.com/2018/10/28/)
- [目次hookを自作したので共有, Next.js, React - YouTube](https://www.youtube.com/watch?v=9_qaewqixkw)
    - unified 使わなくても目次作れるな...😇
    - remark, rehype 関係ないですが勉強になりました...



## 4. そのほか

### ◯ プロンプト

ちょっと気になったので ChatGPT 先生に聞いてみました...

> unified のプラグインはなぜ関数を返す関数として定義されるのですか？