LoginSignup
0
0

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

Last updated at Posted at 2024-05-20

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

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

0. 基本形

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>
$ 
Deno の補足

(1) Deno を使っていいの?

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

一番の問題点、Node は新しいプロジェクトを一式整えるための手間が非常に重い。とくに ts で書いたものを他の環境に渡すための方法が未だにしんどい。ある環境で動いたコードをそのままコピーしても、プロジェクト設定の非互換を踏む可能性が非常に高い。deno にそういう側面がないとは言わないが、非常に少ない。とくに TS が直接動く + HTTP Import で、一度書いたコードのポータビリティが非常に高い。


(2) esm.sh ってなに?

import 文に登場する https://esm.sh/ は CDN だそうです。esm.shesmbuild を使って JavaScript のコードを CommonJS 形式(require 関数, module.exports プロパティ)から ECMAScript 形式(import 文, export 文)に書き換えたコードを配信してくれているそうです。esmES Modules の略だと思われます。


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

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

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

// 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"


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

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

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

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

この記述方法は、JSR を Node/npm から使う場合と同じ記述方法になっていて、クロスプラットフォームな記述方法にもなっています。


1. インライン

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

(1) { allowDangerousHtml: false } の場合

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 } の場合

もっと簡単に書けます。

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>
$ 

◯ 参考

  • こちらのほうがちゃんとやられているがなにをやっているか理解できず断念...
  • インライン, 1行の内容を置換するだけなら unified 使わずとも正規表現で置換するだけでよいのでは?みたいなことを思いながらコードを書いてました。

今回は、プラグインを増やすと学習コストが増えること、複雑なセレクタ指定を使ったマークアップはしないことを理由に、正規表現でclassを付与していく方針をとりました。

2. ブロックライン

変換前 - Markdown
:::

うほうほ

:::
変換後 - HTML
<div>

うほうほ

</div>

(1) { allowDangerousHtml: false } の場合

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 で拡張記法を安易に定義するのもためらわれる😇

details

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

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
 *     - 1つのプラグインとして定義できないのか?
 *     - remark, rehype で分割して定義しないといけないのか?
 *     - rehypheMessage で1つにまとめたほうが綺麗だったのでは?
 * - 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)
  }
}

3. 要素の抜き出し

◯ プロンプト

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

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

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

◯ 参考

4. そのほか

◯ プロンプト

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

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0