GitHubのIssueをNuxtとNetlifyでブログ化するというのをやってみたので解説します。
デモサイト: https://gh-blog.netlify.com/
Issues: https://github.com/miyaoka/gh-blog/issues
Headless CMS
はじめにHeadless CMSについて解説しておきます。旧来のAPI無しのWordPressのような一体型CMSではなく、コンテンツ管理部分を切り離してAPIでやりとりできるようにするのがHeadlessなCMSです。これによりフロントの実装やデプロイが疎結合になり、好きにできるようになります。
例としてCMSにContentful、デプロイにNetlifyを使う構成だとこんな感じになります。
参考)
こうして分けたところで何が便利って思われるところもあるかもしれませんが、自分的にはバックエンドを作る必要が無く、フロント一人居れば全部できるのが非常にラクだなと思います。そして各所が最適化されているので、フロントの最適化に注力しやすくなりますし、必要であればweb以外にアプリ用など複数の画面を作ることも可能になります。
GitHub Issueをブログ化する
コンテンツ管理であり、APIでやりとりできるといえばGitHubのIssueなどもそうです。
GitHub Issueを簡易CMSとしてブログを作ってみるというのが今回の主旨になります。
環境変数設定
APIを使うためtokenなど環境固有の設定ができるように変数設定をします。
direnvをインストールしておくことで.envrc
で設定した値をprocess.env
として扱えるようになります。
# Create your tokens at https://github.com/settings/tokens
## Public token to fetch repository on production
export GH_READONLY_TOKEN=
## Private token to edit repository on local. Don't use in public!!!
export GH_WRITE_TOKEN=
## Target repository
export GH_REPO_OWNER=
export GH_REPO_NAME=
OAuth Token
ここではGitHubのPersonal Access Tokensを2つと、ターゲットとなるリポジトリの設定をしています。
トークンが2つあるのはIssueの読み取り用と編集用です。読み取り用トークンは最終的にデプロイされるソースに含めるため、権限は必ず全部外した状態にしてください。
編集用トークンはoptionalでローカルサーバー上でのみ使うことを想定しています。こちらは後ほど解説します。
フロント実装
Nuxt
フロントはなんでも良いのですが、静的ビルドできるのが便利なのでVueフレームワークのNuxt.jsを使ってます。使い方はドキュメントがめっちゃ丁寧で親切なのでとにかく読みましょう。
Apollo
GitHubはAPI v4でGraphQLに対応しているので使いましょう。
クライアントはApolloを入れます。Nuxt用には@nuxtjs/apolloがあるので、インストールしてnuxt.config.js
のmodulesに追加するだけOKです。
apolloクライアント設定
import { ApolloLink } from 'apollo-link'
import { HttpLink } from 'apollo-link-http'
import { InMemoryCache } from 'apollo-cache-inmemory'
export default (ctx) => {
const httpLink = new HttpLink({ uri: 'https://api.github.com/graphql' })
// middleware
const middlewareLink = new ApolloLink((operation, forward) => {
const token = ctx.env.GH_READONLY_TOKEN
operation.setContext({
headers: { authorization: `Bearer ${token}` }
})
return forward(operation)
})
const link = middlewareLink.concat(httpLink)
return {
link,
cache: new InMemoryCache()
}
}
クライアントはこんな感じ。
先ほどの環境変数で設定したGH_READONLY_TOKEN
をヘッダに入れます。
クエリ
query getIssues(
$repoOwner: String!
$repoName: String!
$fetchIssuePerPage: Int = 5
$endCursor: String
) {
repository(owner: $repoOwner, name: $repoName) {
name
description
issues(
orderBy: { field: CREATED_AT, direction: DESC }
first: $fetchIssuePerPage
after: $endCursor
) {
totalCount
pageInfo {
startCursor
endCursor
hasPreviousPage
hasNextPage
}
nodes {
author {
avatarUrl
login
resourcePath
url
}
id
number
title
body
createdAt
updatedAt
url
}
}
}
}
Issueを取得するクエリはこんな感じに作ってます。
どんなクエリでどんな値が取れるかはGraphQL API Explorer | GitHub Developer Guideで試せるので、必要な情報が取得できるように好きに作りましょう。ここで一気にIssueのコメント一覧なども取ることができます。
store
import getIssues from '~/apollo/queries/getIssues'
export const actions = {
async nuxtServerInit({ commit, state }, { app, env }) {
const { GH_REPO_OWNER: repoOwner, GH_REPO_NAME: repoName } = env
commit('setRepoOwner', repoOwner)
commit('setRepoName', repoName)
try {
const { data } = await app.apolloProvider.defaultClient.query({
query: getIssues,
variables: {
repoOwner,
repoName,
fetchIssuePerPage: state.fetchIssuePerPage
}
})
commit('setIssues', data.repository.issues)
} catch (err) {
console.error(err)
}
}
}
これまでに設定した環境変数とGraphQLクエリを使うことでGitHubのIssueを取得します。nuxtServerInitという特別なアクションに記述することでレンダリング前に取得してstoreに格納することができます。
画面表示
<template>
<section class="container">
<header>
<h2>Issues</h2>
<small>count: {{totalCount}}</small>
</header>
<entry-item
v-for="post in nodes"
:key="post.id"
:post="post"
/>
<no-ssr>
<infinite-loading @infinite="loadMore" ref="infiniteLoading">
<span slot="no-results">
no more articles
</span>
<span slot="no-more">
no more articles
</span>
</infinite-loading>
</no-ssr>
<div class="page">
{{nodes.length}} / {{totalCount}}
</div>
</section>
</template>
<script>
import { mapState, mapMutations } from 'vuex'
import getIssues from '~/apollo/queries/getIssues'
import EntryItem from '~/components/EntryItem.vue'
export default {
components: {
EntryItem
},
computed: {
...mapState([
'repoOwner',
'repoName',
'fetchIssuePerPage',
'totalCount',
'nodes',
'pageInfo'
])
},
storeに格納したissue一覧を表示してます。このへんは好きにやりましょう。
Issue本文はmarkdownになってるのでvue-markdown
に突っ込めばHTMLになります。
ページングはページ下部にinfinite loadingをつけてスクロールで追加読み込みしてます。vue-infinite-loadingはSSRでエラーになるので、no-ssrタグで括ることで回避してます。
(optional) ローカルからIssue編集する
GitHubのIssues画面だけじゃなくて、自サイトの画面で内容更新したほうが見た目の確認など楽なので編集機能もつけてみました。
<div class="body" :class="{hasDiff}">
<section>
<vue-markdown
class="marked"
:source="previewBody"
:anchorAttributes="{
target: '_blank',
rel: 'noopener'
}"
/>
</section>
<transition name="fade">
<div
v-if="isEditing"
class="editor"
>
<textarea
v-model="editorBody"
:disabled="isCommiting"
/>
</div>
</transition>
<div class="edit-toggle" v-if="isDev">
<button @click="toggleEdit">{{isEditing ? 'プレビュー' : '編集'}}</button>
</div>
<transition name="edit-action">
<div class="edit-action" v-if="hasDiff">
<button
@click="discardEdit"
:disabled="isCommiting"
>変更を破棄</button>
/
<button
@click="saveEdit"
:disabled="isCommiting"
>保存</button>
</div>
</transition>
</div>
記事表示コンポーネントのこのへんでやってるのが編集機能です。
v-if="isDev"
で、ローカルの開発環境でのみ編集ボタンを表示し、その場で編集できるようにしてます。
ここでIssue更新用に必要なのが先ほどoptionalで設定していた編集用トークンです。こちらのトークンにはpublic_repo
の権限を付加することでIssue編集が可能になります。(public環境にはデプロイしないでください)
GraphQLではIssueのmutationはどうもできないっぽかったので、REST APIを使ってます。
Netlifyで継続的デプロイ
環境変数
ビルドに必要な変数をNetlifyにも設定します。
Webhook
デプロイを発火させるために必要なのがWebhook設定です。
Netlifyでプロジェクトを作ると、対象Gitリポジトリの更新に応じてデプロイが行われるWebhookが自動的に追加されます。今回はこれとは別にIssueに更新があったとき用のWebhookも追加します。
まずNetlify側でhookのURLを作成し、
GitHubのリポジトリ設定からWebhookを追加し、Payload URLに先ほどのURLを記入、イベント一覧からIssue、Issue commentsを選択して設定します。
これでソースの更新とIssueの更新のどちらでも、最新の内容でビルドされたものが自動的にデプロイされるようになります。
作ったもの
はい。というわけで出来ましたね。詳しくはリポジトリとか見てください。
リポジトリ
デモサイト
そもそもの経緯
- azuさんのツイート: "GItHub Isssueをそのままマイクロブログにするのを思いついたけど、あんまり嬉しいユースケースが思いつかなかった。 データからHTMLを生成すれば静的サイトになる、WebHookを使えばコメントがあるたびに更新できる、画像が投稿できる、Issueへのコメントがそのままブログのコメントとなる。"
- みやおかさんのツイート: "azuさんがGitHubのIssueをコンテンツに使う話してて面白そうだったので、とりあえず作ってみてる https://t.co/iUoixTH6tD Nuxt + Contentful + Netlifyでブログ作るのやってたから、Contentful部分をGitHubのIssueに置き換える感じ… https://t.co/izlg1Iz3pF"
もともとazuさんがGitHub issue as blogについて話しているのを目にして、たしかにユースケースとしては「Issueそのものを見ればいいじゃん」となってしまうので微妙なんですが、まあGitHubのAPI使ってみる練習にちょうどいいかなーと実装してみた感じです。
その他雑感
カスタムフィールド
Issueだとタイトルと本文とラベルくらいしか設定できませんが、Front-matterで記述すればそのへんも設定できるとは思います。ただまあそういうフィールド欲しいなら素直にContentful使うのがいいなと思います。
まあどっちかっていうと既存のIssueがあってあくまでそのドキュメント化という用途にするべきなんでしょう。
下書き投稿
ラベルとか使って制御すれば出来ると思います。まあGitHub側では見えますが。
コメント表示/投稿
実装すればできるはず