GROWIでGitHubのIssueを表示するプラグインを作りました

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

今回は、GROWIプラグインとしてGitHubのIssueを一覧表示するプラグインを作りました。プロジェクト関連の情報共有にGROWIを利用しているケースは多いので、そのIssueも一覧で表示できれば情報の一元管理が進みます。

## プラグインの動作

Markdownは以下のように記述します。Remark Directiveを利用しています。

```markdown
::github[issue]{repo=weseek/growi per_page=10}
```

将来的にIssue以外にも対応できそうなので、 `github` というキーワードにしました。`{}` 内はオプションです。各オプションはスペースで繋ぎます。オプションは [REST API endpoints for issues \- GitHub Docs](https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues) に準拠しています。

![FireShot Capture 292 - GitHub - GROWI - localhost.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/d7541d9b-f9be-4c33-ad2c-b2ef631a20a4.jpeg)

## コードについて

コードは[goofmint/growi\-plugin\-github](https://github.com/goofmint/growi-plugin-github)にて公開しています。ライセンスはMIT Licenseになります。

## プラグインを追加する

利用する際には、GROWIの管理画面の `プラグイン` にて追加してください。URLは `https://github.com/goofmint/growi-plugin-github` です。

![Admin](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/f5b3e2d3-ccf3-d536-d59e-7301ea1b2f7e.jpeg)

## 注意点

GitHub APIを利用する際に、APIキーを通していません。そのため、GitHubのAPI制限に引っかかる可能性があります。

## コードについて

このプラグインでは、以下のような手順を取っています。

1. Remark Directiveをcodeタグに変換
2. codeタグを取得してGitHub APIを実行、結果を表示

```tsx:client-entry.tsx
import { gitHub, githubPlugin } from './src/GitHub';

const activate = (): void => {
  if (growiFacade == null || growiFacade.markdownRenderer == null) {
    return;
  }
  const { optionsGenerators } = growiFacade.markdownRenderer;
  const originalCustomViewOptions = optionsGenerators.customGenerateViewOptions;
  optionsGenerators.customGenerateViewOptions = (...args) => {
    const options = originalCustomViewOptions ? originalCustomViewOptions(...args) : optionsGenerators.generateViewOptions(...args);
    const { code } = options.components;
    // replace
    options.components.code = gitHub(code);
    options.remarkPlugins.push(githubPlugin as any);
    return options;
  };

  // For preview
  const originalGeneratePreviewOptions = optionsGenerators.customGeneratePreviewOptions;
  optionsGenerators.customGeneratePreviewOptions = (...args) => {
    const preview = originalGeneratePreviewOptions ? originalGeneratePreviewOptions(...args) : optionsGenerators.generatePreviewOptions(...args);
    const { code } = preview.components;
    preview.components.code = gitHub(code); // Wrap the default component
    preview.remarkPlugins.push(githubPlugin as any);
    return preview;
  };
};
```

### Remark Directiveの処理

Remark Directiveでは、渡されたオプションを解析して、 `code` タグに変換します。

```tsx:GitHub.tsx
export const githubPlugin: Plugin = () => {
  return (tree: Node) => {
    visit(tree, 'leafDirective', (node: Node) => {
      const n = node as unknown as GrowiNode;
      if (n.name !== 'github') return;
      const data = n.data || (n.data = {});
      const type = n.children[0].value;
      data.hName = 'code';
      data.hProperties = { className: `language-github-${type}` };
      data.hChildren = [{ type: 'text', value: JSON.stringify(n.attributes) }];
    });
  };
};
```

イメージとしては、以下のようになります。

```markdown
::github[issue]{repo=weseek/growi per_page=10}
```

というMarkdownが、以下のようなHTMLになっています。

```html
<code class="language-github-issue">
  {"repo":"weseek/growi","per_page":"10"}
</code>
```

### コンポーネントとしての処理

そして、 `code` タグに対する処理を行います。この場合は、GitHubのAPIを叩いて、Issueを取得しています。

```tsx:GitHub.tsx
export const gitHub = (Tag: React.FunctionComponent<any>): React.FunctionComponent<any> => {
  return ({
    children, ...props
  }) => {
    try {
      // クラスの判定
      if (!props.className.match(/language-github-/)) {
        return <Tag {...props}>{children}</Tag>; // 対象以外は普通のコンポーネントとして扱う
      }
      // JSONをパース
      const params = JSON.parse(children);
      switch (props.className) {
        case 'language-github-issue': // Issue表示
          return GitHubIssue(params);
        default:
          return <Tag {...props}>{children}</Tag>; // 対象以外は普通のコンポーネントとして扱う
      }
    }
    catch (err) {
      // console.error(err);
    }
    // Return the original component if an error occurs
    return (
      <Tag {...props}>{children}</Tag>
    );
  };
};
```

#### GitHubIssueの処理

`GitHubIssue` では、GitHubのAPIを叩いてIssueを取得しています。データの取得は `react-async` を使い、非同期処理にて行います。そして、受け取った結果は `table` タグで描画しています。

```tsx:GitHub.tsx
const getUrl = async({ url }: any) => {
  const res = await fetch(url);
  const json = await res.json();
  return json;
};

const GitHubIssue = (params: GitHubIssueProps) => {
  // URLの作成
  const baseUrl = `https://api.github.com/repos/${params.repo}/issues`;
  // クエリーの作成
  const query = Object.entries(params).map(([key, value]) => {
    if (key !== 'repo' && value !== null) {
      return `${key}=${value}`;
    }
    return '';
  }).join('&');
  const url = `${baseUrl}?${query}`;
  return (
    <Async promiseFn={getUrl} url={url}>
      {({ data, error, isPending }) => {
        if (isPending) return 'Loading...';
        if (error) return `Something went wrong: ${error.message}`;
        if (data) {
          return (
            <>
              <table className='table striped'>
                <tbody>
                  {data.map((item: any) => (
                    <tr key={item.id}>
                      <td><a href={item.html_url} target='_blank'>
                        {item.title}</a><br />
                        🕒 {format(item.created_at)} by {item.user.login}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </>
          );
        }
        return null;
      }}
    </Async>
  );
};
```

## GROWIコミュニティについて

プラグインの使い方や要望などがあれば、ぜひGROWIコミュニティにお寄せください。実現できそうなものがあれば、なるべく対応します。他にもヘルプチャンネルなどもありますので、ぜひ参加してください！

[GROWI Slackへの参加はこちらから](https://communityinviter.com/apps/wsgrowi/invite)

## まとめ

GROWIプラグインを使うと、表示を自由に拡張できます。足りない機能があれば、どんどん追加できます。ぜひ、自分のWikiをカスタマイズしましょう。

[OSS開発wikiツールのGROWI | 快適な情報共有を、全ての人へ](https://growi.org/ja/)