GROWIでRSSフィードを表示するプラグインを作りました

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

今回は、GROWIプラグインとしてRSSフィードを一覧表示するプラグインを作りました。ニュースサイトの表示を行ったり、[QiitaのGROWIタグ](https://qiita.com/tags/growi/feed)を表示したりするのに便利です。最近ではRSSリーダーを利用する方が減っているので、リーダー代わりに最新情報を表示する場として利用できます。

## プラグインの動作

Markdownは以下のように記述します。リンクに対して `RSS` と記述するのがポイントです。

```markdown
[RSS](https://qiita.com/tags/growi/feed)
```

すると、以下のように表示されます。

![FireShot Capture 281 - RSS Reader - GROWI - localhost.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/6647818d-da8b-5673-daf6-2af27ac97714.jpeg)


### アドバンス版

RSSフィードをJSONに変換する（CORS対策）ため、[RSS to JSON Converter online](https://rss2json.com/#rss_url=http%3A%2F%2Ffeeds.twit.tv%2Fbrickhouse.xml)を利用しています。認証なしでも利用できますが、認証することでオプションが幾つか利用できます（並び替え、件数制限など）。

この場合はリンクではなく、[Remark Directive](https://github.com/remarkjs/remark-directive)を利用します。`{}` 内はapiKeyを除いてオプションです。各オプションはスペースで繋ぎます。

```markdown
::rss[https://qiita.com/tags/growi/feed]{apiKey=API_KEY count=2 order=pubDate}
```

## コードについて

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

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

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

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

## 注意点

前述の通り、RSSフィードの変換に外部サービスを利用しています。そのため、LAN内のデータは利用できません（ポータルサイトのRSSフィードなど）。また、RSS to JSON Converter onlineの利用制限もあるので、ご注意ください（課金すると制限は解除できます）。

## コードについて

今回は `a` タグに対する処理（シンプルな使い方）と、Remark Directive の両方をサポートしています。

```tsx
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 { a } = options.components.a;
    options.components.a = rssReader(a);
    // Remark Directive版
    options.remarkPlugins.push(rssReaderPlugin as any);
    return options;
  };

  // For preview
  const originalGeneratePreviewOptions = optionsGenerators.customGeneratePreviewOptions;
  optionsGenerators.customGeneratePreviewOptions = (...args) => {
    const preview = originalGeneratePreviewOptions ? originalGeneratePreviewOptions(...args) : optionsGenerators.generatePreviewOptions(...args);
    const { a } = preview.components;
    preview.components.a = rssReader(a);
    preview.remarkPlugins.push(rssReaderPlugin as any);
    return preview;
  };
};
```

### Remark Directive版の処理

Remark Directive版は、渡されたオプションを解析して、 `a` タグに変換します。 `data-` 要素が使えなかったので、 `title` 要素にデータを渡しています。

つまり、 Remark Directiveでは、シンプル版と同じように `a` タグを生成する役割になります。

```tsx
export const rssReaderPlugin: Plugin = () => {
  return (tree: Node) => {
    // leafDirective(::)のみを指定
    visit(tree, 'leafDirective', (node: Node) => {
      const n = node as unknown as GrowiNode;
      if (n.name !== 'rss') return;
      const data = n.data || (n.data = {});
      data.hName = 'a'; // 描画をaタグに変換
      data.hChildren = [{ type: 'text', value: 'RSS' }];
      const href = n.children[0].url;
      data.hProperties = {
        href,
        title: JSON.stringify(n.attributes),
      };
    });
  };
};
```

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

```markdown:元
::rss[https://qiita.com/tags/growi/feed]{apiKey=API_KEY count=2 order=pubDate}
```

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

```html:変換後
<a
  href="https://qiita.com/tags/growi/feed"
  title='{"apiKey":"API_KEY","count":2,"order":"pubDate"}'
>
  RSS
</a>
```

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

そして、シンプル版では、title要素がJSONパースできるかどうかを判定して、処理を行います。

```jsx
// オプションを解析。エラーなら空で返す
const parseOptions = (str: string) => {
  try {
    return JSON.parse(str);
  }
  catch (err) {
    return {
      apiKey: null,
    };
  }
};

const API_ENDPOINT = 'https://api.rss2json.com/v1/api.json?';

export const rssReader = (Tag: React.FunctionComponent<any>): React.FunctionComponent<any> => {
  return ({
    children, title, href, ...props
  }) => {
    try {
      if (children === null || children !== 'RSS') {
        return <Tag {...props}>{children}</Tag>;
      }
      // オプションを解析
      const { apiKey, count, order } = parseOptions(title);
      // リクエストするURLを生成
      const params = new URLSearchParams();
      params.append('rss_url', href);
      if (apiKey) {
        params.append('api_key', apiKey);
        params.append('count', count || '10');
        params.append('order_by', order || 'pubDate');
      }
      const url = `${API_ENDPOINT}${params.toString()}`;
      // 後述
    } 
    catch (err) {
      // console.error(err);
    }
    // Return the original component if an error occurs
    return (
      <a {...props}>{children}</a>
    );
  };
};
```

### データの取得と描画

データの取得は、 `react-async` を使って非同期で行います。

```tsx
// 指定されたURLを呼んで、結果をJSONで返すのみ
const getRss = async({ url }: any) => {
  const res = await fetch(url);
  const json = await res.json();
  return json;
};
```

データが取得できたら、それを `table` タグで描画します。

```tsx
return (
  <Async promiseFn={getRss} url={url}>
    {({ data, error, isPending }) => {
      if (isPending) return 'Loading...';
      if (error) return `Something went wrong: ${error.message}`;
      if (data) {
        return (
          <>
            <table className='table table-striped'>
              <thead>
                <tr>
                  <th>Title</th>
                  <th>Author</th>
                  <th>PubDate</th>
                </tr>
              </thead>
              <tbody>
                {(data.items || []).map((item: any) => (
                  <tr key={item.guid}>
                    <td><a href={item.link} target='_blank'>{item.title}</a></td>
                    <td>{item.author}</td>
                    <td>{item.pubDate}</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/)