GROWIプラグインを作るのにremark-directiveが便利だという話

[オープンソースのWikiであるGROWI](https://growi.org/ja/)にはプラグイン機能が用意されています。自社のデータを表示したり、表示をカスタマイズするのに利用できます。

[プラグインは `$` を使う方法や、HTMLタグを置き換える方法などがある](https://qiita.com/goofmint/items/c63cd35282f2cf8436bb)のですが、GROWIで利用しているMarkdownパーサーであるremarkの提供する専用記法（ディレクティブ）を使うことで、より高度なプラグインが開発できます。

この記事では、[remark-directive](https://github.com/remarkjs/remark-directive)を使ってGROWIプラグインを作る方法を紹介します。

## プラグインのテンプレートについて

今回は[goofmint/growi\-plugin\-script\-template](https://github.com/goofmint/growi-plugin-script-template)をベースにしますが、既存のタグを置き換える方式ではなく、Remarkプラグインを追加する方法になります。

`/client-entry.tsx` は以下のようになります。

```tsx
import { plugin } from './src/remarkDirectives';

const activate = (): void => {
  if (growiFacade == null || growiFacade.markdownRenderer == null) {
    return;
  }

  const { optionsGenerators } = growiFacade.markdownRenderer;

  // For page view
  optionsGenerators.customGenerateViewOptions = (...args) => {
    const options = optionsGenerators.generateViewOptions(...args);
    options.remarkPlugins.push(plugin as any);  // プラグイン追加（表示用）
    return options;
  };

  // For preview
  optionsGenerators.customGeneratePreviewOptions = (...args) => {
    const options = optionsGenerators.generatePreviewOptions(...args);
    options.remarkPlugins.push(plugin as any);  // プラグイン追加（プレビュー用）
    return options;
  };
};
```

`/src/remarkDirectives.ts` は以下のようになります。

```tsx
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';

interface GrowiNode extends Node {
  name: string;
  type: string;
  attributes: {[key: string]: string}
  children: GrowiNode[];
  value: string;
  position: {
    start: { line: number; column: number; offset: number; };
    end: { line: number; column: number; offset: number; };
  }
}

export const plugin: Plugin = function() {
  return (tree) => {
    visit(tree, (node) => {
      const n = node as GrowiNode;
      // ここにプラグインの処理を書きます
    });
  };
};
```

## ディレクティブの処理

Remark Directiveには3つの書き方があります。

### 文中に書く方法

以下のように `:` ではじめるパターンです。 `:TAG[ATTRS]` または `:TAG[ATTRS]{DATA}` という形式で書きます。

```markdown
A :i[lovely] language know as :abbr[HTML]{title="HyperText Markup Language"}.
```

#### i[lovely]の場合

nodeの内容は以下のようになります。 `name` にタグ名が入っています。また、その後の `children` には `lovely` が入っています。

```json
{
	"type": "textDirective",
	"name": "i",
	"attributes": {},
	"children": [
		{
			"type": "text",
			"value": "lovely",
			"position": {
				"start": {"line": 3, "column": 6,	"offset": 30 },
				"end": {"line": 3, "column": 12, "offset": 36 }
			}
		}
	],
	"position": {
		"start": {"line": 3, "column": 3,	"offset": 27 },
		"end": {"line": 3, "column": 13, "offset": 37 }
	}
}
```

#### :abbr[HTML]{title="HyperText Markup Language"} の場合

nodeの内容は以下のようになります。 `attributes` の中に `{}` 内で定義した内容が入っています。

```json
{
	"type": "textDirective",
	"name": "abbr",
	"attributes": {
		"title": "HyperText Markup Language"
	},
	"children": [
		{
			"type": "text",
			"value": "HTML",
			"position": {
				"start": {"line": 3, "column": 37, "offset": 61 },
				"end": {"line": 3, "column": 41, "offset": 65 }
			}
		}
	],
	"position": {
		"start": {"line": 3, "column": 31, "offset": 55 },
		"end": {"line": 3, "column": 77, "offset": 101 }
	}
}
```

### 行頭に書く方法

行頭に1行で書く記法です。GROWIの `$` と同じような使い方です。

```markdown
::hr{.red}
```

この時の `node` は以下のような内容です。 `{.red}` と書くと、 `attributes` に `class` として入ります。

```json
{
	"type": "leafDirective",
	"name": "hr",
	"attributes": {
		"class": "red"
	},
	"children": [],
	"position": {
		"start": {"line": 3, "column": 1,	"offset": 25 },
		"end": {"line": 3, "column": 11, "offset": 35 }
	}
}
```

### ブロックで書く方法

そして、Remark Directiveではぷらぐいんの中身をブロックで書くこともできます。たとえば、以下のように `:::` で書き始めます。

```markdown
:::main{#mainId.hello}
Hello, world!

こんにちは、世界
:::
```

この時のnodeの内容は以下の通りです。ブロック内の記述は `children` に入ります。また、 `#mainId.hello` と書くと、 `attributes` に `id` と `class` として入ります。

注意点としては、ブロック内のテキストがpタグに入っていることです。そのため、実際のテキストを取得する場合には `node.children[0].children[0].value` とする必要があります。

```json
{
	"type": "containerDirective",
	"name": "main",
	"attributes": {
		"id": "mainId",
		"class": "hello"
	},
	"children": [
		{
			"type": "paragraph",
			"children": [
				{
					"type": "text",
					"value": "Hello, world!",
					"position": {
						"start": { "line": 4, "column": 1, "offset": 48 },
						"end": { "line": 4, "column": 14, "offset": 61 }
					}
				},
				{
					"type": "paragraph",
					"children": [
						{
							"type": "text",
							"value": "こんにちは、世界",
							"position": {
								"start": { "line": 6, "column": 1, "offset": 63 },
								"end": { "line": 6, "column": 9, "offset": 71 }
							}
						}
					],
					"position": {
						"start": { "line": 6, "column": 1, "offset": 63 },
						"end": { "line": 6, "column": 9, "offset": 71 }
					}
				}
			],
			"position": {
				"start": { "line": 3, "column": 1, "offset": 25 },
				"end": { "line": 7, "column": 4, "offset": 75 }
			}
		}
	]
}
```

## Tips: 特定のnodeだけ取得する

`visit` の第2引数に取得したいノード種別を指定することで、特定のノードだけ取得することができます。

```tsx
visit(tree, 'containerDirective', (node) => {
	// ブロックのみ
});
visit(tree, 'textDirective', (node) => {
	// 文中のみ
});
visit(tree, 'leafDirective', (node) => {
	// 行頭のみ
});
```

## 例

7.1.1でリリースされたGitHub Alertはこの仕組みを使っています。詳しくは[こちらのコードを参照](https://github.com/weseek/growi/blob/master/apps/app/src/features/callout/services/callout.ts#L10)してください。

```tsx
export const remarkPlugin: Plugin = () => {
  return (tree) => {
    visit(tree, 'containerDirective', (node: ContainerDirective) => {
      if (AllCallout.some(name => name === node.name.toLowerCase())) {
        const type = node.name.toLowerCase();
        const data = node.data ?? (node.data = {});

        // extract directive label
        const paragraphs = (node.children ?? []).filter((child): child is Paragraph => child.type === 'paragraph');
        const paragraphForDirectiveLabel = paragraphs.find(p => p.data?.directiveLabel);
        const label = paragraphForDirectiveLabel != null && paragraphForDirectiveLabel.children.length > 0
          ? (paragraphForDirectiveLabel.children[0] as Text).value
          : undefined;
        // remove directive label from children
        if (paragraphForDirectiveLabel != null) {
          node.children.splice(node.children.indexOf(paragraphForDirectiveLabel), 1);
        }

        data.hName = 'callout';
        data.hProperties = {
          type,
          label,
        };

      }
    });
  };
};
```

## まとめ

GROWIはプラグインを使って拡張することで、より便利なWikiになります。現在、プラグインを随時追加していますが、足りなければ自分で作成できます。ぜひ本記事を参照して、自分だけのプラグインを作成してみてください。

[GROWI プラグイン \| GROWI\.org](https://growi.org/ja/plugins)