Githubを入稿システムとして使ってる

この記事は[Recruit Engineers Advent Calendar 2019](https://adventar.org/calendars/4502)の7日目の記事です。

リクルートライフスタイルで[HOT PEPPER Beauty cosme](https://beauty.hotpepper.jp/doc/cosme/info/)(以下HPBC)の開発を担当している[@roronya](https://twitter.com/roronya)です。GatsbyJSを使ってGithubを「運営からのお知らせ」の入稿システムにするための設定やアプリの実装方針について紹介します。

HPBCには「運営からのお知らせ」という新機能やキャンペーンをユーザーに知らせる画面があります。

<img width="720" alt="お知らせ画面.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27893/bef08324-3660-1fa1-3ed8-b117d9c2a45d.png">


「運営からのお知らせ」に表示する文面は、その特性上ディレクターやマーケターが用意する場合が多いのですが、HPBCでは、「運営からのお知らせ」用の入稿システムを作らずに、Githubを入稿システムとして使ってもらっています。

最近のGithubは、Web上でファイルの追加や編集ができたり、更にその変更をブランチを切ってPRを作るところまで簡単にできたりするので、Gitの知識が無くても使えるツールになってきています。Githubに原稿が来てしまえば、あとはCIでアプリで表示するためのAPIを叩いたりデプロイをしたりといった作業を任せることができます。エンジニアが何か作業をする必要がないので効率的です。

他にも以下のようなメリットがあります。

- Markdown Previewのついたエディタがデフォルトで備わっている
- 意識させずにバージョン管理を強制できる
- PRで文面のレビューがやりやすい

この記事ではGithubの機能を活かして入稿システムとして使うためのGatsbyJSの設定やAPIやアプリの実装方針について紹介します。また、サンプルコードは以下のリポジトリに置いておきます。
[roronya/gatsby-notification](https://github.com/roronya/gatsby-notification)

## 入稿からアプリでの表示の流れ
以下のような工程でMarkdownをもとにお知らせページを生成しアプリで見れるようにしています。

1. GithubにMarkdownをpushする
2. pushにフックしてGatsbyJSで静的ページをbuild
3. FirebaseHostingにデプロイ
4. デプロイしたURLをAPIに登録する
5. APIでURL一覧を返しアプリでWebViewで表示する

## アプリでのお知らせの表示方法
アプリからお知らせを表示する方法は2通り考えられます。

1. APIから文面をもらってアプリでレイアウトする
2. Webページで作ってWebViewで表示する

1番のAPIから文面をもらう方法だと作れるお知らせ画面の自由度が減りそうだったので、今回はWebページで作ることにしました。

## Markdownからページを生成する
静的ページジェネレータにはGatsbyJSを選びました。

GithubはMarkdownでファイルを編集するとプレビュー機能が使えるので、お知らせの文面もMarkdownで入稿できると、出来上がりが想像できて良さそうでした。GatsbyJSならMarkdownから静的ページを生成できます。

GatsbyJSの公式のドキュメント[^1]を参考にMarkdownからページを生成できるようにしていきます。
大まかな流れは以下のようになります。

1. GatsbyJSの導入
2. Markdownを読み込むためのプラグインの設定
3. ページ生成時の処理

[^1]: [Adding Markdown Pages \| GatsbyJS](https://www.gatsbyjs.org/docs/adding-markdown-pages/)

### 初期化
適当なstarterを使って初期化します。今回はチュートリアル[^2]と同様にgatsby-starter-hello-worldを使
いました。

```console:console
$ gatsby new gatsby-tutorial https://github.com/gatsbyjs/gatsby-starter-hello-world
``````

[^2]: [Get to Know Gatsby Building Blocks \| GatsbyJS](https://www.gatsbyjs.org/tutorial/part-one/)

### Markdownを読み込むためのプラグインの設定
GatsbyJSでMarkdownを読み込めるようにするためにプラグインの設定をします。必要なパッケージをインストールしてgatsby-config.jsに以下のように設定します。

```console:console
$ npm install --save gatsby-source-filesystem gatsby-transformer-remark
```

```javascript:gatsby-config.js
module.exports = {
  plugins: [
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        name: `src`,
        path: `${__dirname}/src/`,
      },
    },
    `gatsby-transformer-remark`,
  ],
}
```

`gatsby-source-filesystem` がファイルを読み込むためのプラグインです。pathに指定したディレクトリからファイルを読み込みます。

`gatsby-transformer-remark` は読み込んだファイルがMarkdownであるかを認識しHTMLに変換するプラグインです。加えて、このプラグインは、Markdownにfrontmatter形式で書いたメタデータを認識することも出来ます。メタデータは、URLとして表示するときのpathの設定をするために使います。

### Markdownをデータソースにしてページを書き出す
Markdownは `gatsby-transformer-remark` プラグインでHTMLに変換されますが、それはGraphQL上に存在しているのみで、具体的なページとしてはまだ書き出されていません。`gatsby-node.js` はビルド時の処理を書けます。ここでMarkdownから作られたHTMLを取得し、後述するHTMLを埋め込むテンプレートを使ってページとして書き出します。

```javascript:gatsby-node.js
const path = require(`path`)

exports.createPages = async ({ actions, graphql, reporter }) => {
  const { createPage } = actions

  const template = path.resolve(`src/templates/notificationTemplate.js`)

  const result = await graphql(`
    {
      allMarkdownRemark {
        edges {
          node {
            frontmatter {
              path
            }
          }
        }
      }
    }
  `)

  // Handle errors
  if (result.errors) {
    reporter.panicOnBuild(`Error while running GraphQL query.`)
    return
  }

  result.data.allMarkdownRemark.edges.forEach(({ node }) => {
    createPage({
      path: node.frontmatter.path,
      component: template,
      context: {}, // additional data can be passed via context
    })
  })
}
```  

以下はHTMLを埋め込むテンプレートです。ここでHTMLにスタイルも当ててしまいます。HTMLを埋め込むdivにstyled-componentで適当に装飾してます。

```javascript:/src/templates/notificationTemplate.js
import React from "react"
import { graphql } from "gatsby"
import styled from 'styled-components'

const Container = styled.div`
  body {
    margin: 0;
  }
  main {
    font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", YuGothic, "ヒラギノ角ゴ ProN W3", Hiragino Kaku Gothic ProN, Arial, "メイリオ", Meiryo, sans-serif;
    box-sizing: border-box;
    margin: 16px;
  }
  p {
    color: rgba(51,51,51,1);
    font-size: 13px;
  }
  h1 {
      padding-left: 4px;
      color: rgba(85,85,85,1);
      border-left: 4px solid rgba(85,85,85,1);
      font-size: 13px;
  }
`

export default function Template({
  data, // this prop will be injected by the GraphQL query below.
}) {
  const { markdownRemark } = data // data.markdownRemark holds your post data
  const { html } = markdownRemark
  return <Container dangerouslySetInnerHTML={{ __html: html }} />
}

export const pageQuery = graphql`
  query($path: String!) {
    markdownRemark(frontmatter: { path: { eq: $path } }) {
      html
    }
  }
`
```

## APIへの登録
前述の通りアプリではWebViewでお知らせページを表示するので、APIにはURLを登録します。

### URLの作り方
各MarkdownにFrontmatterでURLになるときのパスをメタデータとして記しておき、デプロイ時にデプロイ先のドメインと結合してURLにします。

### APIに登録する
以下のようなデプロイスクリプトを書きます。全てのMarkdownを走査してメタデータを読んでAPIに必要なパラメータを作っています。HPBCではその他にも以下のようなメタデータをMarkdownに書いてAPIへ登録しています。

- title
- published_at
  - 何時にお知らせを配信するか
- enabled
  - APIからこのお知らせを返すか否か
  - もし何か問題があったときにすぐに表示を変更できるようにしています

```javascript:bin/deploy.js
const fs = require("fs").promises
const fm = require("front-matter")
const axios = require("axios")

const API_URL = process.env.API_URL
const DEPLOY_BASE_URL = process.env.DEPLOY_BASE_URL
const DIR = "./src/pages"

const main = async () => {
  const files = await fs.readdir(DIR)
  const paths = files
    .filter(file => /.*\.md$/.test(file))
    .map(file => `${DIR}/${file}`)
  const contents = await Promise.all(
    paths.map(async path => {
      const data = await fs.readFile(path, "utf8")
      return fm(data)
    })
  )
  const params = contents.map(content => ({
    title: content.attributes.title,
    published_at: content.attributes.date,
    url: `${DEPLOY_BASE_URL}/${content.attributes.path}`,  // デプロイされたときのURLを作る
    enabled: content.attributes.enabled,
  }))
  const response = await axios.post(API_URL, { contents: params })
}

main()
  .then(console.log("done!"))
  .catch(err => {
    console.log(err)
    process.exit(1)
  })
```

## デプロイ
デプロイ先はFirebaseにしました。gatsby buildの結果をそのままdeployし、完了したら上記のスクリプトを叩いてAPIへ登録します。この一連の処理をpackage.jsonのscriptsに登録し、pushやmerge,tagのタイミングでdev,stg,prdの環境にそれぞれ適用しています。

```json:package.json
{
  ...
  "scripts": {
    ...
    "deploy": "gatsby build && firebase deploy && node ./bin/deploy.js"
  }
}
```

## たまにはリッチなコンテンツも作りたい
文章だけのお知らせ以外に、クリエイティブで訴えるお知らせを打ちたいということもあります。GatsbyJSはReactで自由度の高いコンテンツを作れるのでこういった要件にも答えることができます。

以下はユーザーに投稿するときの画像をどうやって撮影するかというコンテンツです。こういったコンテンツはMarkdownでは表現しづらいのでReactで作っています。

<img width="310" alt="投稿ガイド.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27893/08266ad9-2d79-6770-07ab-c0635ff3f0d6.png">

## 入稿方法は丁寧な説明書を作った
Githubは英語だし、変なボタンを触るとコードが壊れちゃうのでは！という不安があるらしかった。図つきの詳しいドキュメントを社内wikiに置いて参照してもらっています。

<img width="720" alt="入稿方法ドキュメント.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27893/1127d58c-2153-809b-2c1f-026b0f1b553c.png">

## 終わりに
GithubをGatsbyJSで入稿システムとして使うための設定の紹介をしました。入稿システムをいちから作るよりも早く作ることができました。CIが使えたり、GatsbyJSで作れるコンテンツの自由度が高いこともあり、クリエイティブで訴えるような施策にも対応できるので便利に使っています。