当ブログでは GatsbyJS の Starter を用いてデザイン等を作っていましたが、 React と GraphQL を勉強したので、ブログシステムを自分好みにスクラッチから書き直しました。その時の作業をログとして残しておきます。

GatsbyJS とは

GatsbyJS は静的サイトジェネレータ (SSG: Static Site Generator) の一種です。

React を使用してサイトを組み立てます。そのため、既存の React ライブラリをそのまま活用できます。

GatsbyJS の最大の特徴は、CMS や Markdown ファイルなどさまざまなデータソースを GraphQL でクエリすることです。 GraphQL で標準化されているので、データソース毎にデータ取得方法がバラバラにならず、簡単にデータを抽出することができます。

また、プラグインシステムを搭載しています。Sass への対応やシンタックスハイライトなども npm パッケージを追加して設定すれば、簡単に機能を追加できます。

作るブログの構成

普通のブログを作ります。「ゼロから作る」なので Starter 等は利用せず全部自分で設定を行います。

言語は TypeScript を使います。意地でも JavaScript ファイル (*.js) は書きません。 GraphQL クエリドキュメントからは TypeScript の型定義を自動生成します。

React は React Hooks を用いてコンポーネントを書きます。クラスコンポーネントで書きたい人はそれでも良いです。

各記事は Markdown ファイルを書きます。ソースプラグインを使えば WordPress 等の CMS から直接取り込むこともできます。

開発環境

エディタは Visual Studio Code を使います。もちろん他のエディタでも OK です。

最新の Node.js と npm を使用します。環境を汚したくないなら Docker コンテナ上で環境構築し、 Visual Studio Code の Remote Container で開発することができます。

$ node -v
v15.0.1
$ npm -v
6.14.8
$ code -v | head -1
1.50.1

Visual Studio Code では ESLint や Prettier などの拡張機能を入れておきましょう。

ext install editorconfig.editorconfig
ext install dbaeumer.vscode-eslint
ext install esbenp.prettier-vscode
ext install stylelint.vscode-stylelint

プロジェクトの作成

新規プロジェクト作成

適当な場所にプロジェクトのディレクトリを作成します。ディレクトリ名は、ブログを公開するドメイン名と一緒にしておくと良いと思います (本記事では blog.example.com とします) 。

$ mkdir blog.example.com
$ cd blog.example.com
$ npm init -y

エディタを開いておきましょう。

$ code .

ここからはプロジェクトの足回りの設定を行います。

EditorConfig

まず何よりも先に EditorConfig を設定します。改行やタブなどがバラバラだと無理です。好みの値を設定しましょう。

.editorconfig
root = true

[*]
end_of_line = lf
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true

[*.{json,scss,ts,tsx,yml}]
indent_style = space
indent_size = 2

package.json

プロジェクトのパッケージ情報を書き加えます。 descriptionauthor などを設定しましょう。このパッケージは publish しないので、 privatetrue に設定します。

package.json
{
  // ...
  "description": "My blog",
  "author": "John Doe <doe@example.com> (https://example.com/doe/)",
  "private": true,
  // ...
}

Git

当然。

$ git init

.gitignore は、インストールした npm モジュールや GatsbyJS の成果物を指定します。

.gitignore
# npm
node_modules/

# GatsbyJS
.cache/
public/

適度な粒度で commit しましょう。

$ git add -A
$ git commit

デフォルトブランチを main にリネームしておきます。

$ git branch -m main

Visual Studio Code

Visual Studio Code でファイル保存時に自動整形が走るよう、ワークスペースを設定します。プロジェクトのディレクトリに .vscode ディレクトリを作成し、その中に settings.json を作成します。

.vscode/settings.json
{
  "editor.codeActionsOnSave": {
    "source.fixAll": true
  }
}

TypeScript

中規模以上の Web 開発ではこれが無いとマジでしんどいです。入れましょう。 Node.js 上で TypeScript を直接動かすことができる ts-node も入れておきます。

$ npm i -D typescript ts-node

tsconfig.json に TypeScript の設定を書きます。

tsconfig.json
{
  "include": [
    "./src/**/*",
    "./gatsby-*.ts"
  ],
  "compilerOptions": {
    "target": "es2019",
    "module": "commonjs",
    "lib": ["dom", "es2017"],
    "jsx": "react",
    "strict": true,
    "esModuleInterop": true,
    "baseUrl": "."
  }
}

ESLint と Prettier

静的解析の ESLint と自動整形の Prettier をインストールします。 GatsbyJS では React を使うので、 React 用の ESLint プラグインも入れます。

$ npm i -D eslint prettier eslint-config-prettier eslint-plugin-import eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks @typescript-eslint/eslint-plugin @typescript-eslint/parser

.eslintrc.json に ESLint の設定を書きます。

.eslintrc.json
{
  "root": true,
  "extends": [
    "eslint:recommended",
    "plugin:import/errors",
    "plugin:import/warnings",
    "plugin:import/typescript",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint"
  ],
  "plugins": [
    "import",
    "react",
    "react-hooks",
    "@typescript-eslint",
    "prettier"
  ],
  "env": {
    "es6": true,
    "node": true,
    "browser": true
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "sourceType": "module",
    "project": "./tsconfig.json",
    "ecmaFeatures": {
      "jsx": true
    }
  },
  "rules": {
    "react/prop-types": "off",
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn",
    "prettier/prettier": [
      "error",
      {
        "singleQuote": true,
        "trailingComma": "none",
        "arrowParens": "avoid"
      }
    ]
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  }
}

Prettier の設定はお好みで設定しましょう。 ESLint で無視するファイルは .eslintignore に書きます。

.eslintignore
package.json
package-lock.json
.cache/
public/
types/

コマンドラインから簡単に ESLint を実行できるように、 npm スクリプトを定義します。ついでに TypeScript の型検査も行うようにします。

package.json
{
  // ...
  "scripts": {
    "lint": "eslint 'src/**/*.{ts,tsx}' gatsby-*.ts && tsc --noEmit",
    "lint:fix": "eslint --fix 'src/**/*.{ts,tsx}' gatsby-*.ts && tsc --noEmit"
  },
  // ...
}

npm run lint で ESLint を実行できます。 npm run lint:fix で自動整形もやっちゃいます。

stylelint

スタイルシート版 ESLint である stylelint を設定します。 SCSS を使うので SCSS 用のルールをインストールします。 Prettier は stylelint でもよろしくやってくれます。

$ npm i -D stylelint stylelint-prettier stylelint-config-prettier stylelint-scss stylelint-config-recommended-scss

設定ファイルを .stylelintrc.json に書きます。

.stylelintrc.json
{
  "extends": [
    "stylelint-config-recommended-scss",
    "stylelint-prettier/recommended"
  ]
}

ESLint と同じように、コマンドラインから簡単に stylelint を実行できるようにします。

package.json
{
  // ...
  "scripts": {
    // ...
    "stylelint": "stylelint 'src/**/*.scss'",
    "stylelint:fix": "stylelint --fix 'src/**/*.scss'"
  },
  // ...
}

npm run stylelint で stylelint を実行できます。 npm run stylelint:fix で自動整形。

Git フック

git commit した時に ESLint 等を自動的に実行し、チェックに引っかかったら commit 失敗させるように Git フックを設定します。ウンコード・クソースの commit を予防することができます。設定したくないならしなくても OK です。

まず Git フックを簡単に設定することができる Husky と、 Git ステージに対して ESLint 等のチェックをする lint-staged をインストールします。

$ npm i -D husky lint-staged

Husky をインストールした時点で、 Git クライアントサイドフックがインストールされます。

そして、 package.json に Husky と lint-staged の設定を書きます。

package.json
{
  // ...
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged && tsc --noEmit"
    }
  },
  "lint-staged": {
    "*.{ts,tsx}": "eslint --fix",
    "*.scss": "stylelint --fix"
  },
  // ...
}

これで、 commit 時に Git ステージにあるファイルに対して自動でチェックと整形が掛かるようになりました。 git commit --no-verify でフックをバイパスすることもできます。

GatsbyJS ことはじめ

やっと本編スタートです。

GatsbyJS のインストール

まずは GatsbyJS 本体や React をインストールします。 GatsbyJS 用のコマンドラインインターフェース (CLI) や TypeScript 型定義もインストールします。

$ npm i gatsby react react-dom
$ npm i -D gatsby-cli @types/react @types/react-dom

ESLint や gatsby-cli など CLI ツールはバージョンの差異によるトラブルを避けるため、グローバルインストールではなく devDependencies としてインストールします。インストールした CLI は npx コマンドで簡単に実行することができます。

GatsbyJS の設定

GatsbyJS の設定は本来 gatsby-config.js に書きます。が、 TypeScript を使っているので .js ではなく .ts です。プロジェクトのルートに gatsby-config.ts を作ります。

gatsby-config.ts
import type { GatsbyConfig } from 'gatsby';

const config: GatsbyConfig = {};
export default config;

とりあえず、空の設定を書いて export しておきます。ここには主にプラグインの設定を書きます。

最初の静的ページを作る

簡単な index ページを作って、 GatsbyJS が動作するか確かめます。 src/pages/index.tsx に簡単なページコンポーネントを書きます。HTML っぽい記法なので内容は大体分かると思います(これは JSX で、正確には HTML ではなく JavaScript の拡張です)。

src/pages/index.tsx
import React from 'react';
import type { FC } from 'react';

const Page: FC = () => (
  <div>
    <h1>Home</h1>
    <p>Hello, world!</p>
  </div>
);
export default Page;

開発サーバーを走らせる

gatsby develop コマンドで、開発サーバーを走らせることができます。

$ npx gatsby develop

しかし、 gatsby-cli は gatsby-config.js を読みに行くので、設定が存在せずエラーとなります。書いたのは .js ではなく .ts なので。

ts-node 経由で gatsby-cli を実行すれば .ts ファイルを読みに行ってくれます。プロジェクトのルートで以下を実行します。

$ npx ts-node ./node_modules/.bin/gatsby develop

ただ、こんな長いのはいちいち打っていられないですし、見てくれも悪いので、 npm スクリプトで定義してしまいます。 package.jsonscriptsdevelop スクリプトを定義します。

package.json
{
  // ...
  "scripts": {
    // ...
    "develop": "ts-node ./node_modules/.bin/gatsby develop"
  },
  // ...
}

そして、以下のコマンドで開発サーバーを実行します。次回以降はこの短いコマンドで開発サーバーを実行することができます。

$ npm run develop

開発サーバーはデフォルトでは localhost:8000 で動きます。 Web ブラウザで開くと、いかにも <p>Hello, world!</p> なページが表示されます。

hello world

ホットリロード

Web ブラウザを開いたまま、 src/pages/index.tsx に変更を加えてみましょう。 <p> 要素の中を少しいじります。

src/pages/index.tsx
  <div>
    <h1>Home</h1>
    <p>Hello, GatsbyJS!</p>  {/* world から GatsbyJS へ変えてみる */}
  </div>

変更したら保存しましょう。すると、 Web ブラウザの表示も瞬時に更新されるはずです。

hello gatsbyjs

これは HMR (Hot Module Replacement) という機能で、変更したモジュールだけを自動的に更新してくれるという代物です。開発サーバーを再起動する必要はありません。超サクサク開発できます。

もう一つ静的ページを作る

src/pages/about.tsx に about ページを作ります。

src/pages/about.tsx
import React from 'react';
import type { FC } from 'react';

const Page: FC = () => (
  <div>
    <h1>About</h1>
    <p>GatsbyJSでできたブログやよ〜</p>
  </div>
);
export default Page;

保存したら、 Web ブラウザで /about にアクセスしてみてください。開発サーバーを再起動する必要はありません。

about

このように、 src/pages/ 内にページコンポーネントを作ると、静的ページを作ることができます。

サイト内リンクを張る

ハイパーリンクは通常通り <a> 要素を使います。ただし、サイト内リンクは Link という GatsbyJS に同梱されているコンポーネントを使います。

index.tsxabout.tsx に相互にリンクを張ってみましょう。 gatsby から Link を import しています。

src/pages/index.tsx
import React from 'react';
import type { FC } from 'react';
import { Link } from 'gatsby';

const Page: FC = () => (
  <div>
    <h1>Home</h1>
    <p>Hello, GatsbyJS!</p>
    <Link to="/about">About this blog</Link>
  </div>
);
export default Page;
src/pages/about.tsx
import React from 'react';
import type { FC } from 'react';
import { Link } from 'gatsby';

const Page: FC = () => (
  <div>
    <h1>About</h1>
    <p>GatsbyJSでできたブログやよ〜</p>
    <Link to="/">Home</Link>
  </div>
);
export default Page;

Web ブラウザでリンクをクリックして動作を確認してみてください。爆速でページが切り替わります。

hello link

GatsbyJS は SPA (Single Page Application) として動作します。Link コンポーネントをクリックしたときリンク先のページ全体が読み込まれるのではなく、必要な箇所だけが読み込まれ、その部分だけが置き換わるよう宜しくやってくれる (Router コンポーネントが組み込まれている) ので、とても軽快にページが遷移します。

コンポーネントを作る

ここから、本格的にサイトを作っていきます。

GatsbyJS では React を使っているので、ページ上で表示される部品をコンポーネントとして分離することができます。ヘッダーやフッターなどコンテンツ以外の部分は別のコンポーネントに押し込んでやることで、コードがシンプルになったり、記述の繰り返しを避けることができます。 DRY (Don't Repeat Yourself) を意識しましょう。

ヘッダーコンポーネント

サイトのタイトルのみが含まれるシンプルなヘッダーを作ります。 src/components/header.tsx にコンポーネントを書きます。

src/components/header.tsx
import React from 'react';
import type { FC } from 'react';
import { Link } from 'gatsby';

export const Header: FC = () => (
  <header>
    <h1>
      <Link to="/">My blog</Link>
    </h1>
  </header>
);

フッターコンポーネント

コピーライト表記といくつかのリンクが含まれるフッターを作ります。 src/components/footer.tsx に書きます。

src/components/footer.tsx
import React from 'react';
import type { FC } from 'react';
import { Link } from 'gatsby';

export const Footer: FC = () => (
  <footer>
    <p>&copy; John Doe</p>
    <ul>
      <li>
        <Link to="/about">About</Link>
      </li>
      <li>
        <a href="mailto:doe@example.com">Contact</a>
      </li>
    </ul>
  </footer>
);

これらはほんの一例です。好きなように変更してみてください。

レイアウトコンポーネント

先程作成した HeaderFooter コンポーネントをページコンポーネントで import して使用すれば、ヘッダーとフッターが表示されます。が、全部のページに <Header /><Footer /> と記述しているのはまだまだ DRY に反しています。今はヘッダーとフッターしかありませんが、後々サイドバーを追加するなどとなった場合、すべてのページコンポーネントを書き換えなくてはなりません。

そこで、サイトのすべてのページで共通なレイアウトを Layout コンポーネントとして作ってしまうことにします。 src/components/layout.tsx に書きます。

src/components/layout.tsx
import React from 'react';
import type { FC } from 'react';
import { Header } from '../components/header';
import { Footer } from '../components/footer';

export const Layout: FC = ({ children }) => (
  <div className="wrapper">
    <Header />
    <div className="content">{children}</div>
    <Footer />
  </div>
);

JSX では、 HTML の class 属性を className として指定します。 JSX は JavaScript の拡張であり、そのまま class キーワードを使うと ECMAScript のクラス構文と干渉してしまうので、 className として指定する必要があります。レンダリング後の DOM ではちゃんと class 属性になっているので安心してください。

children は、子コンポーネントを受け取るための引数です。以下のように使用することで、 Layout コンポーネントの使用側からコンポーネントを渡すことができます。

<Layout>
  <p>Hello</p>  {/* <-- これが children として渡される */}
</Layout>

作ったコンポーネントを使う

実際にページコンポーネントで Layout コンポーネントを使ってみましょう。

src/pages/index.tsx
import React from 'react';
import type { FC } from 'react';
import { Link } from 'gatsby';
import { Layout } from '../components/layout';

const Page: FC = () => (
  <Layout>
    <h1>Home</h1>
    <p>Hello, GatsbyJS!</p>
    <Link to="/about">About this blog</Link>
  </Layout>
);
export default Page;
src/pages/about.tsx
import React from 'react';
import type { FC } from 'react';
import { Link } from 'gatsby';
import { Layout } from '../components/layout';

const Page: FC = () => (
  <Layout>
    <h1>About</h1>
    <p>GatsbyJSでできたブログやよ〜</p>
    <Link to="/">Home</Link>
  </Layout>
);
export default Page;

保存すると、ヘッダーとフッターが現れると思います。ページの共通部分を Layout コンポーネントに押し込めることができました。

layout

スタイルシートを適用する

ここまでスタイルシートが全く当てられていないため、ブラウザデフォルトのユーザーエージェントスタイルシートが適用された ダサい 見た目になっています。

というわけでスタイルシートを作ります。今回はグローバルスタイルを当てることにします。コンポーネントスコープのスタイルを当てることもできますが今回は割愛します。

Sass を使って SCSS を書きたいので、まず node-sass と GatsbyJS 用 Sass プラグインをインストールします。

$ npm i node-sass gatsby-plugin-sass

次に、プラグインを使用する設定を書きます。 gatsby-config.ts に書き加えます。

gatsby-config.ts
const config: GatsbyConfig = {
  plugins: ['gatsby-plugin-sass']
};

これで、 Sass を使えるようになりました。 SCSS ファイル src/styles/index.scss を作成します。ここではスタイルを簡単に設定します。超適当です。

src/styles/index.scss
* {
  margin: 0;
  padding: 0;
}

html {
  font-family: sans-serif;
}

@media screen and (min-width: 560px) {
  .wrapper {
    margin: 0 3rem;
  }
}

header {
  margin-bottom: 1rem;
  padding: 1rem;
  background: lavender;
}

footer {
  margin-top: 1rem;
  padding: 1rem;
  background: lavender;
  display: flex;
  justify-content: space-between;

  ul {
    display: flex;
    list-style-type: none;
  }

  li:not(:first-child) {
    margin-left: 1rem;
  }
}

.content {
  margin: 1rem;
}

最後に、 src/components/layout.tsx で SCSS ファイルを import すればスタイルシートが適用されます。

src/components/layout.tsx
import React from 'react';
import type { FC } from 'react';
import { Header } from '../components/header';
import { Footer } from '../components/footer';
import '../styles/index.scss';

export const Layout: FC = ({ childrent }) => (/* ... */);

プラグインを追加したので、開発サーバーは再起動する必要があります。

ページを開いてみるとスタイルシートが適用されているのが確認できると思います。自分好みのスタイルに色々変えてみてください。スタイルシートに対してもホットリロードが効くのでスタイルの調整が捗ります。 Sass なので、スタイルシートが大きくなってもファイル分割して @import で import できます。

style

データを外から流し込む

GraphQL を用いて、様々なデータソースからデータを取得できるのが GatsbyJS の特徴です。データは基本的にソースコードの外側に置いておくのが望ましいです。ブログの記事はもちろん、ブログタイトルや著者などの情報も外側に置きます。

まず、ハードコーディングされたブログタイトルなどのサイト情報を外に置き、 GraphQL でデータを取得する雰囲気を掴みましょう。

サイトメタデータ

GatsbyJS 内蔵のデータソースとして、サイトメタデータがあります。 gatsby-config.ts で自由に値を設定できます。書いてみましょう。

gatsby-config.ts
const config: GatsbyConfig = {
  siteMetadata: {
    title: 'My blog',
    author: 'John Doe',
    description: 'Example blog using GatsbyJS',
    email: 'doe@example.com'
  },
  plugins: ['gatsby-plugin-sass']
};

型は siteMetadata?: Record<string, unknown>; なので、キーが string なら値は何でも OK です。ここでは、ブログのタイトル、著者、説明、メールアドレスを設定しています。

GraphQL とは

GraphQL でデータを取得してみる前に、少し GraphQL について説明します。

GraphQL は Facebook が開発した問い合わせ (クエリ) 言語です。新しい Web API として REST の代わりに使われたります。 GraphQL では操作として

  • データの読み込み (query)
  • データの書き込み (mutation)
  • データの購読 (subscription)

が行えます。 GatsbyJS では query を使ってデータを取得することになります。

REST ではリソースを URL のパスとして表していますが、 GraphQL ではクエリドキュメント内でリソースを指定して、単一のエンドポイントに POST することでクエリを行います。

また、 GraphQL では必要なデータのみを過不足無く単一のリクエストのみでクエリできるため、 REST と比べてネットワーク効率が良くなります。

例として、 GitHub API の v3 (REST) と v4 (GraphQL) を比較してみましょう。 Organization Octokit のすべてのリポジトリのスター数を取得しようと思います。

まず v3 (REST) ではこうなります。 API のエンドポイントは https://api.github.com で始まります。

GET /orgs/octokit/repos
[
  {
    "id": 417862,
    "node_id": "MDEwOlJlcG9zaXRvcnk0MTc4NjI=",
    "name": "octokit.rb",
    "full_name": "octokit/octokit.rb",
    "private": false,
    "owner": {
      // ...
    },
    "html_url": "https://github.com/octokit/octokit.rb",
    "description": "Ruby toolkit for the GitHub API",
    // ...
    "deployments_url": "https://api.github.com/repos/octokit/octokit.rb/deployments",
    "created_at": "2009-12-10T21:41:49Z",
    "updated_at": "2020-11-02T16:05:51Z",
    "pushed_at": "2020-10-28T20:09:58Z",
    "git_url": "git://github.com/octokit/octokit.rb.git",
    "ssh_url": "git@github.com:octokit/octokit.rb.git",
    "clone_url": "https://github.com/octokit/octokit.rb.git",
    "svn_url": "https://github.com/octokit/octokit.rb",
    "homepage": "http://octokit.github.io/octokit.rb/",
    "size": 16574,
    "stargazers_count": 3351,
    "watchers_count": 3351,
    "language": "Ruby",
    "has_issues": true,
    "has_projects": true,
    "has_downloads": true,
    "has_wiki": false,
    "has_pages": true,
    "forks_count": 1107,
    "mirror_url": null,
    "archived": false,
    "disabled": false,
    "open_issues_count": 55,
    "license": {
      // ...
    },
    "forks": 1107,
    "open_issues": 55,
    "watchers": 3351,
    "default_branch": "4-stable",
    "permissions": {
      // ...
    }
  },
  {
    "id": 711976,
    // ...
  },
  // ...
]

リポジトリデータの配列の最初 1 件以外は省略しています。リポジトリ名とスター数だけが欲しいのに他のデータもジャラジャラついてきました。上記のレスポンスはいくつかのフィールドを省略していますがこの有様です。データ全部貰えてお得と思う人もいるかと思いますが、データ量が多くなってネットワーク負荷も掛かるしデータ処理にも時間が掛かるし良いこと無いです。

逆に REST API が返すデータが少なすぎても問題です。必要なデータが取得できるまで複数リクエストを投げなくてはなりません。時間が掛かります。

続いて v4 (GraphQL) です。 GraphQL API Explorer でクエリを投げることができます。投げるクエリとレスポンスは以下の通りです。

query MyQuery {
  organization(login: "octokit") {
    repositories(first: 100) {
      edges {
        node {
          name
          stargazerCount
        }
      }
      totalCount
    }
  }
}
{
  "data": {
    "organization": {
      "repositories": {
        "edges": [
          {
            "node": {
              "name": "octokit.rb",
              "stargazerCount": 3351
            }
          },
          {
            "node": {
              "name": "rest.js",
              "stargazerCount": 4312
            }
          },
          // ...
        ],
        "totalCount": 48
      }
    }
  }
}

GraphQL ではクエリドキュメントにクエリを書き、それを POST することでリクエストを投げられます。

query のブロックでデータ取得操作を表します。クエリには MyQuery のように名前をつけられます。その中でブロックをネストしていき必要なフィールドを書きます。 organization(login: "octokit") のように括弧を使ってパラメータを指定することもできます。

ページネーションがあるため repositories(first: 100) と取得件数を指定する必要があります。そのため、 totalCount で全件数も同時に取得しています。このように、複数のデータも同時に指定して一度に取得できます。リクエスト・レスポンス共に一度のみです。複数の Organizations に対しても同じようにデータを取得できますし、検索のような全く無関係なものも同時にできます。

レスポンスの JSON を見てみましょう。最初の 2 件以外は省略しています。指定したフィールドのみが取得できています。他の余計なデータは一切くっついていません。 REST API のレスポンスより圧倒的にスリムです。

GraphQL はデータ取得の query 以外にも mutationsubscription があります。また、クエリの一部を使い回す fragment やクエリ変数を使うこともできます。 GraphQL について詳しくは、オライリーの 初めてのGraphQL (ISBN978-4-87311-893-2) が分かりやすくておすすめです。

GraphQL の柔軟性が分かったところで、先程設定したサイトメタデータを実際に取得してみます。

GraphiQL でクエリしてみる

サイトメタデータを書いて gatsby-config.ts を保存したら、開発サーバーを再起動します。そして、 開発サーバーに内蔵されている GraphiQL というツールを開きます。開発サーバーが localhost:8000 で動いている場合、 http://localhost:8000/___graphql を Web ブラウザで開くと GraphiQL が開きます。

graphiql

GraphiQL は GraphQL の IDE です。 GraphQL クエリドキュメントの組み立て・実行や、スキーマリファレンスを調べることができます。 IDE として、 GraphiQL の他に GraphQL Playground などもあります。

GraphiQL を用いて、先程設定したサイトメタデータからデータを取得してみましょう。 GraphiQL の左ペイン (Explorer) の sitesiteMetadata とツリーを展開して authortitle にチェックを入れてみましょう。すると、画面中央のクエリドキュメントが自動で組み上げられていきます。

query

クエリドキュメントが組み上がったら、画面上部の再生ボタンでクエリを実行できます。右ペインにクエリの実行結果が JSON 形式で表示されます。

query result

siteMetadata のうち、 authortitle を指定して取得できました。 GraphiQL の Explorer を使えば、自分でクエリドキュメントを書かなくても簡単に組み上げられます。

ヘッダーにタイトルを差し込む

GraphQL を通してサイトメタデータを取得する方法が分かったので、実際にデータを差し込んでみます。まずはヘッダーのブログタイトルから。 src/components/header.tsx を編集します。

src/components/header.tsx
import React from 'react';
import type { FC } from 'react';
import { Link, graphql, useStaticQuery } from 'gatsby';

export const Header: FC = () => {
  const data = useStaticQuery(graphql`
    query HeaderComponent {
      site {
        siteMetadata {
          title
        }
      }
    }
  `);

  return (
    <header>
      <h1>
        <Link to="/">{data.site.siteMetadata.title}</Link>
      </h1>
    </header>
  );
};

gatsby から新たに graphqluseStaticQuery を import しています。

コンポーネント内では useStaticQuery フックを使うことで、 GatsbyJS のデータを引っ張ることができます。 useStaticQuery の引数に graphql タグがついたテンプレート文字列を渡します。文字列の中身は GraphQL クエリドキュメントです。クエリ名として HeaderComponent と名前をつけています。 useStaticQuery の戻り値はクエリのレスポンスとなります。

あとはレスポンスのデータを使ってコンポーネントに埋め込むだけです。 {} の間の式は展開されます。結構簡単。開発サーバーを再起動して結果を確認してみてください。

ここで React Hooks のルールを簡単に説明します。 useStaticQuery など use で始まるフック関数は、関数コンポーネントのトップレベルで呼び出す必要があります。コンポーネントの外で呼び出したり、 if の中など呼び出されるかどうか不定な場所でフック関数は利用できません。 ESLint で設定しているのでこのルールに違反していた場合エラーが出ると思います。

クエリのレスポンスに型をつける

熱心な TypeScript 使いの人なら思うはずです。 useStaticQuery の戻り値の型が any なのが気に食わないと。 useStaticQuery の型定義を見てみましょう。

node_modules/gatsby/index.d.ts
export const useStaticQuery: <TData = any>(query: any) => TData

実は useStaticQuery の型パラメータが戻り値の型で、デフォルトで any になっています。 TData にレスポンスの JSON ドキュメント通りの型を指定してやれば型付けができます。

その肝心の型はどうやって用意するんだって話ですよね。 GatsbyJS のプラグインに自動生成してもらいます。インストールしましょう。

$ npm i gatsby-plugin-graphql-codegen

そして gatsby-config.ts にプラグインを使用する設定を書きます。

gatsby-config.ts
const config: GatsbyConfig = {
  siteMetadata: {
    // ...
  },
  plugins: [
    'gatsby-plugin-sass',
    {
      resolve: 'gatsby-plugin-graphql-codegen',
      options: {
        fileName: 'types/graphql-types.d.ts',
        documentPaths: ['src/**/*.{ts,tsx}', 'gatsby-*.ts']
      }
    }
  ]
};

プラグイン使用の指定方法は 2 種類あります。 1 つは 'gatsby-plugin-sass' のように文字列で指定する方法。もう 1 つは今回追加したオブジェクトで指定する方法です。 'gatsby-plugin-hoge'{ resolve: 'gatsby-plugin-hoge' } は等価です。オブジェクトで指定した場合、プラグインに対して追加のオプションも指定できるようになります。

gatsby-plugin-graphql-codegen は、ソースコード中の GraphQL クエリドキュメントを読み取って、データソースの型と照合しながらレスポンスの型定義を生成するプラグインです。 options で、型定義の生成先ファイルの指定と、クエリドキュメントを検索する対象ソースコードを指定しています。

types/graphql-types.d.ts に型定義を生成させるよう設定したので、 .gitignore に追加しておきましょう。自動生成されたソースコードは commit するべきではないです。

.gitignore
# npm
node_modules/

# GatsbyJS
.cache/
public/

# gatsby-plugin-graphql-codegen
types/graphql-types.d.ts

開発サーバーを再起動してみましょう。自動生成プラグインが動いて types/graphql-types.d.ts が自動生成されます。

あとは src/components/header.tsxuseStaticQuery の型パラメータを指定しましょう。

src/components/header.tsx
export const Header: FC = () => {
  const data = useStaticQuery<HeaderComponentQuery>(graphql`
    query HeaderComponent {
      # ...
    }
  `);

  return (
    <header>
      <h1>
        <Link to="/">{data.site.siteMetadata.title}</Link>
      </h1>
    </header>
  );
};

自動生成される型の名前は、クエリ名 + Query となります。今回のクエリ名は HeaderComponent なので型名は HeaderComponentQuery となります。これを useStaticQuery の型パラメータに指定すれば型チェックができます。

書けたら保存して npm run lint でチェックしてみましょう。 data.site.siteMetadata.title のうち、 sitesiteMetadatanull | undefined の可能性があるぞとエラーが出ました。 TypeScript のおかげで気づくことができました。これに基づいて修正してみましょう。

src/components/header.tsx
export const Header: FC = () => {
  const data = useStaticQuery<HeaderComponentQuery>(/* ... */);

  return (
    <header>
      <h1>
        <Link to="/">{data.site?.siteMetadata?.title ?? '(無題)'}</Link>
      </h1>
    </header>
  );
};

これで型をつけつつ GraphQL でデータを取得して差し込むことができました。

フッターに著者とメールアドレスを差し込む

続いてフッターにも GraphQL でデータを差し込みましょう。

src/components/footer.tsx
import React from 'react';
import type { FC } from 'react';
import { Link, graphql, useStaticQuery } from 'gatsby';
import type { FooterComponentQuery } from '../../types/graphql-types';

export const Footer: FC = () => {
  const data = useStaticQuery<FooterComponentQuery>(graphql`
    query FooterComponent {
      site {
        siteMetadata {
          author
          email
        }
      }
    }
  `);

  return (
    <footer>
      <p>&copy; {data.site?.siteMetadata?.author ?? '(著者未設定)'}</p>
      <ul>
        <li>
          <Link to="/about">About</Link>
        </li>
        <li>
          <a href={`mailto:${data.site?.siteMetadata?.email ?? ''}`}>Contact</a>
        </li>
      </ul>
    </footer>
  );
};

フッターでも同じようにデータをクエリしています。クエリ名は FooterComponent としたので、自動生成される型定義は FooterComponentQuery となります。他のコンポーネントとクエリ名がかぶると型定義の生成に不具合が出るので、クエリ名は一意な名前に設定しましょう。

JSX では、属性に文字列リテラル以外を指定するときは {} で囲みます。今回はその中でテンプレートリテラルを使用しメールアドレスを埋め込んでいます。

これでフッターのデータも GraphQL で差し込むことができました。

About ページにもタイトルを差し込む

About ページにもブログのタイトルを表示してみましょう。ページ内では、 useStaticQuery ではなくページクエリを使用します。

src/pages/about.tsx
import React from 'react';
import type { FC } from 'react';
import { Link, graphql } from 'gatsby';
import { Layout } from '../components/layout';
import type { AboutPageQuery } from '../../types/graphql-types';

interface PageProps {
  data: AboutPageQuery;
}

const Page: FC<PageProps> = ({ data }) => (
  <Layout>
    <h1>About {data.site?.siteMetadata?.title ?? '(無題)'}</h1>
    <p>GatsbyJSでできたブログやよ〜</p>
    <Link to="/">Home</Link>
  </Layout>
);
export default Page;

export const query = graphql`
  query AboutPage {
    site {
      siteMetadata {
        title
      }
    }
  }
`;

ページコンポーネントでは、クエリを query として export します。クエリの結果はページコンポーネントの引数に data として渡されます。ページコンポーネントの引数の型を PageProps として定義し、 FC<PageProps> とすることで型をつけています。

ブログのタイトルを一箇所に定義しているので、後々ブログタイトルの修正があっても gatsby-config.ts のデータを修正するだけ。複数のファイルを修正する必要がありません。

about with title

記事のページを生成する

今までは src/pages ディレクトリにページコンポーネントを置くことで静的ページを作ってきました。本節ではいよいよ Markdown ファイルから動的にページを生成してみようと思います。

動的にページを生成する

まずはシンプルな例からいきましょう。

動的にページを生成するには、プロジェクトのルートに gatsby-node.ts を作成し、そこで createPages 関数を定義します。

gatsby-node.ts
import { resolve } from 'path';
import type { GatsbyNode } from 'gatsby';

export interface SamplePageContext {
  name: string;
}

export const createPages: GatsbyNode['createPages'] = async ({ actions }) => {
  const names = ['hoge', 'fuga', 'piyo'];

  names.forEach(name =>
    actions.createPage({
      path: `/${name}`,
      component: resolve(__dirname, 'src', 'templates', 'sample.tsx'),
      context: { name }
    })
  );
};

createPages 関数の引数として、 actions があります。 actions.createPage 関数を使用するとページを生成することができます。

ここでは、文字列の配列 ['hoge', 'fuga', 'piyo'] からページを生成しています。まずは forEach でイテレートします。そして、配列の要素 (すなわち文字列) を actions.createPage の引数に渡しています。

actions.createPage の引数にはオブジェクトを渡します。

path には、生成するページに割り当てられる URL のパスを指定します (/hoge のように)。

component は、テンプレートとなるページコンポーネントへのファイルパスを指定します。 Node.js の path モジュールの resolve 関数で、ファイルパスを結合しています。 __dirname はソースファイル自身があるディレクトリパスを絶対パスで取得します。つまり、今回はプロジェクトルートから見て src/templates/sample.tsxcomponent に指定しています。

最後のパラメータ context は、テンプレートページコンポーネントに渡る引数を指定します。今回は { name } つまり { name: name } を渡しています。後にテンプレートページコンポーネント内で型の整合性を取るため、 SamplePageContext インターフェースを export しています。

これで、ページを生成する指定ができました。次にテンプレートページコンポーネントを src/templates/sample.tsx に作ります。

src/templates/sample.tsx
import React from 'react';
import type { FC } from 'react';
import { Layout } from '../components/layout';
import type { SamplePageContext } from '../../gatsby-node';

interface PageProps {
  pageContext: SamplePageContext;
}

const Page: FC<PageProps> = ({ pageContext }) => (
  <Layout>
    <h1>Page {pageContext.name}</h1>
  </Layout>
);
export default Page;

そんなに難しくないと思います。先程 createPage 関数に渡された context は、ページコンポーネントの引数 pageContext に渡ってきます。これに基づいてページを組み立てれば終わりです。

開発サーバーを再起動して、 /hoge にアクセスしてみます。

hoge

/fuga/piyo にアクセスしても同様に生成されたページを見ることができます。プログラムからページを生成することができました。

ページ生成方法が分かったので、 /hoge /fuga /piyo ページは削除してしまって構いません。 gatsby-node.tssrc/templates/sample.tsx は一旦消してしまいましょう。

Markdown ファイルを作る

もうこれに関して言うことは無いと思いますが、サンプルとしていくつか Markdown ファイルを作ります。プロジェクトのルートに articles ディレクトリを作ってその中に Markdown ファイルを配置します。

articles/first-post.md
---
title: "最初の投稿"
slug: /my-first-post
date: 2020-12-08T00:00:00
---

# こんにちは

最初の投稿です。よろしくお願いします。
articles/im-hungry.md
---
title: "おなかすいた"
slug: /i-want-to-eat-jiro
date: 2020-12-09T12:00:00
---

# 空腹

ラーメン二郎に行きたい

それぞれ Markdown ファイルの先頭にハイフン 3 つ --- で囲まれたエリアがあります。これは YAML Front Matter というもので、記事のタイトルなどのメタデータを YAML 形式で定義することができます。ここでは記事のタイトルを title 、 URL のパスを slug 、投稿日時を date として定義しています。 Markdown のファイル名と URL のパスは必ずしも一致している必要はありません。日付等を用いないシンプルなパスにすることで SEO (Search Engine Optimization) 的に有利な URL にします。多分。

ファイルリストを取得するプラグイン

特定のディレクトリ内のファイルリストを取得するデータソースプラグインを追加して設定します。

$ npm i gatsby-source-filesystem
gatsby-config.ts
import { resolve } from 'path';
import type { GatsbyConfig } from 'gatsby';

const config: GatsbyConfig = {
  siteMetadata: { /* ... */ },
  plugins: [
    'gatsby-plugin-sass',
    {
      resolve: 'gatsby-plugin-graphql-codegen',
      // ...
    },
    {
      resolve: 'gatsby-source-filesystem',
      options: {
        name: 'articles',
        path: resolve(__dirname, 'articles')
      }
    }
  ]
};
export default config;

オプションとして、 name にはファイルリストのインスタンス名 (Windows のドライブ文字みたいなものです) 、 path には検索するディレクトリのパスを指定します。

GraphiQL で動作を確認することができます。以下のようなクエリを投げるとファイルリストが JSON で返ってきます。

query MyQuery {
  allFile(filter: {sourceInstanceName: {eq: "articles"}}) {
    edges {
      node {
        size
        relativePath
      }
    }
  }
}
{
  "data": {
    "allFile": {
      "edges": [
        {
          "node": {
            "size": 158,
            "relativePath": "first-post.md"
          }
        },
        {
          "node": {
            "size": 133,
            "relativePath": "im-hungry.md"
          }
        }
      ]
    }
  },
  "extensions": {}
}

Markdown を HTML へ変換するプラグイン

先程の gatsby-source-filesystem はあくまでもファイルのリストを取得するだけで、ファイルの中身を取得するわけではありません。 Markdown ファイルの中身を読んで HTML に変換しないといけません。

GatsbyJS なら Markdown ファイルを HTML に変換するのも簡単です。プラグインでできます。インストールして設定します。

$ npm i gatsby-transformer-remark
gatsby-config.ts
import { resolve } from 'path';
import type { GatsbyConfig } from 'gatsby';

const config: GatsbyConfig = {
  siteMetadata: { /* ... */ },
  plugins: [
    'gatsby-plugin-sass',
    {
      resolve: 'gatsby-plugin-graphql-codegen',
      // ...
    },
    {
      resolve: 'gatsby-source-filesystem',
      // ...
    },
    'gatsby-transformer-remark'
  ]
};
export default config;

このプラグインの追加によって、 GraphQL に allMarkdownRemark フィールドなどが追加されます。このフィールドで Markdown の HTML 変換結果をクエリできます。 GraphiQL で確認してみましょう。

query MyQuery {
  allMarkdownRemark {
    nodes {
      html
      frontmatter {
        date
        slug
        title
      }
    }
  }
}
{
  "data": {
    "allMarkdownRemark": {
      "nodes": [
        {
          "html": "<h1>こんにちは</h1>\n<p>最初の投稿です。よろしくお願いします。</p>",
          "frontmatter": {
            "date": "2020-12-08T00:00:00.000Z",
            "slug": "/my-first-post",
            "title": "最初の投稿"
          }
        },
        {
          "html": "<h1>空腹</h1>\n<p>ラーメン二郎に行きたい</p>",
          "frontmatter": {
            "date": "2020-12-09T12:00:00.000Z",
            "slug": "/i-want-to-eat-jiro",
            "title": "おなかすいた"
          }
        }
      ]
    }
  },
  "extensions": {}
}

簡単だねぇ。ここまで来ればもう勝ちです。

記事ページを動的に生成する

改めて gatsby-node.ts を書きます。

gatsby-node.ts
import { resolve } from 'path';
import type { GatsbyNode } from 'gatsby';
import type { CreatePagesQuery } from './types/graphql-types';

export interface ArticlePageContext {
  slug: string;
}

export const createPages: GatsbyNode['createPages'] = async ({
  graphql,
  actions
}) => {
  const data = await graphql<CreatePagesQuery>(`
    query CreatePages {
      allMarkdownRemark {
        nodes {
          frontmatter {
            slug
          }
        }
      }
    }
  `);

  data.data?.allMarkdownRemark.nodes.forEach(
    (node: CreatePagesQuery['allMarkdownRemark']['nodes'][0]) => {
      const slug = node.frontmatter?.slug;
      if (slug) {
        actions.createPage({
          path: slug,
          component: resolve(__dirname, 'src', 'templates', 'article.tsx'),
          context: { slug }
        });
      }
    }
  );
};

createPages 関数では、引数の graphql 関数でクエリを叩くことができます。この graphql 関数は Promise を返すので、 await キーワードで Promise の resolve を待ちます。 createPages 関数は async にします。各 Markdown ファイルにある YAML Front Matter の slug フィールドのみ取得します。

forEach でイテレートします。型定義について詳しくは後述しますが、 types/graphql-types.d.ts が存在しないときに引数 node の型が暗黙的な any 型になると言われエラーになるので、型を明示的に書いています。配列の要素の型を得るときは [0] で取り出します。

あとは actions.createPage 関数を呼んで動的にページを作成します。テンプレートページコンポーネントは後ほど src/templates/article.tsx に書きます。コンポーネントに渡すデータは slug 、つまり URL のパス部分を渡します。

型未定義問題への対処

ここで一つ問題があります。開発サーバーを再起動すると、型 CreatePagesQuery が見つからないので TypeScript をコンパイルできませんと言われます。どうやら GraphQL 型定義が自動生成される前に gatsby-node.ts のコンパイルが行われるようで、自動生成される前に型を解決しようとしたのでエラーになったみたいです。

なので、 CreatePagesQuery に関しては仮の型定義を用意しておいて、自動生成された型のチェックは後で行うことにします。

types/graphql-types-stub.d.ts に仮の型定義を書きます。

types/graphql-types-stub.d.ts
export type CreatePagesQuery = any;

そして tsconfig.json に少し追記します。

tsconfig.json
{
  "include": [/* ... */],
  "compilerOptions": {
    // ...
    "paths": {
      "graphql-types": [
        "types/graphql-types",
        "types/graphql-types-stub"
      ]
    }
  }
}

gatsby-node.ts の import 宣言を少し変更します。

gatsby-node.ts
// eslint-disable-next-line import/no-unresolved
import type { CreatePagesQuery } from 'graphql-types';

こうすることで、モジュール graphql-types を解決する際、最初に types/graphql-types.d.ts を試み、ファイルが存在しなかったら types/graphql-types-stub.d.ts にフォールバックするようになります。 ESLint の import 未解決エラーに関してはコメントで抑制しています。

自動生成された types/graphql-types.d.ts を削除してから開発サーバーを起動すると、型エラーは解消されます。代わりにページコンポーネントが存在しないというエラーになるはずです。

記事のページコンポーネントを作る

というわけで、 src/templates/article.tsx にテンプレートページコンポーネントを作ります。

src/templates/article.tsx
import React from 'react';
import type { FC } from 'react';
import { graphql } from 'gatsby';
import { Layout } from '../components/layout';
import type { ArticlePageContext } from '../../gatsby-node';
import type { ArticleTemplateQuery } from '../../types/graphql-types';

interface PageProps {
  data: ArticleTemplateQuery;
  pageContext: ArticlePageContext;
}

const Page: FC<PageProps> = ({ data }) => (
  <Layout>
    <h1 className="article-title">
      {data.markdownRemark?.frontmatter?.title ?? '(無題)'}
    </h1>
    {data.markdownRemark?.frontmatter?.date && (
      <p className="article-date">
        {data.markdownRemark.frontmatter.date} 投稿
      </p>
    )}
    <hr />
    <div
      className="article-body"
      dangerouslySetInnerHTML={{ __html: data.markdownRemark?.html ?? '' }}
    />
  </Layout>
);
export default Page;

export const query = graphql`
  query ArticleTemplate($slug: String!) {
    markdownRemark(frontmatter: { slug: { eq: $slug } }) {
      html
      frontmatter {
        title
        date(formatString: "YYYY/MM/DD", locale: "ja-JP")
      }
    }
  }
`;

まず GraphQL クエリについて見てみましょう。クエリの名前の後に ($slug: String!) とありますが、これはクエリ変数の定義です。関数の引数のように、クエリに引数を持たせることができます。ここでは $slug という名前で String! 型の変数を定義しています。型名の ! は null 非許容を表します。クエリ中で、 $slug を使用してクエリを組み立てることができます。

markdownRemark フィールドは、引数の条件に当てはまる文書 1 件を返します。ここでは frontmatter: { slug: { eq: $slug } } と指定し、 YAML Front Matter の slug$slug に一致する文書を取得しています。その中で htmltitledate を指定しています。 date にはフォーマット機能があり、好みの文字列形式で日時を取得することができます。

さてこの $slug 変数はどこで実際の値を指定しているのかと言うと、ページコンポーネントに渡ってくる pageContext がそのままクエリ変数として設定されます。つまり gatsby-node.tscontext として渡している値がクエリまで渡ります。

必要なデータはページクエリですべて揃ったので、あとはコンポーネントを組み立てるだけです。コンポーネント内で pageContext は使わないので data のみ引数を書いています。

JSX 内で { condition && <p></p> } とすると、左辺の condition が truthy な値の場合のみ右辺の <p></p> がレンダリングされます。 JSX では boolean | null | undefined 型の値は無視され、何もレンダリングされません。この仕様と論理演算子を利用することで、条件付きレンダーをすることができます。

React で HTML 文字列を埋め込むときは、 dangerouslySetInnerHTML 属性を使用して埋め込む必要があります。また、属性の値も { __html: "..." } として HTML 文字列を渡さなければなりません。 XSS (Cross Site Scripting) 脆弱性が危惧される操作なのでわざと目立つ & 面倒くさい API にしています。こういう API 設計は大好きです。

これで記事ページが完成です。開発サーバーを起動して /my-first-post/i-want-to-eat-jiro にアクセスしてみましょう。

first post

バッチリですね。

Markdown ファイルに対してもホットリロードが効きます。実際の記事の見え方を確認しながら記事を執筆できます。スタイルも自由に設定してみてください。

記事一覧を作る

ラストスパートです。直リンクでしか記事にたどり着けないのはブログとしてはダメダメなので、トップページに記事一覧を表示します。

まずはクエリを書きます。

query MyQuery {
  allMarkdownRemark(sort: {fields: frontmatter___date, order: DESC}) {
    edges {
      node {
        excerpt
        frontmatter {
          date(formatString: "YYYY/MM/DD", locale: "ja-JP")
          slug
          title
        }
      }
    }
  }
}
{
  "data": {
    "allMarkdownRemark": {
      "edges": [
        {
          "node": {
            "excerpt": "空腹 ラーメン二郎に行きたい",
            "frontmatter": {
              "date": "2020/12/09",
              "slug": "/i-want-to-eat-jiro",
              "title": "おなかすいた"
            }
          }
        },
        {
          "node": {
            "excerpt": "こんにちは 最初の投稿です。よろしくお願いします。",
            "frontmatter": {
              "date": "2020/12/08",
              "slug": "/my-first-post",
              "title": "最初の投稿"
            }
          }
        }
      ]
    }
  },
  "extensions": {}
}

GraphiQL で実行してみると、日付が降順にソートされた記事の一覧が JSON で出てきます。 TypeScript のコードでソートしても良いのですが、ソート機能が GraphQL データソースに含まれているのでそっちを使っちゃいましょう。

Front Matter に加えて excerpt をクエリしています。自動的に本文の先頭部分を抜粋してくれています。

このクエリを使って src/pages/index.tsx を書き換えます。

src/pages/index.tsx
import React from 'react';
import type { FC } from 'react';
import { graphql, Link } from 'gatsby';
import { Layout } from '../components/layout';
import type { IndexPageQuery } from '../../types/graphql-types';

interface PageProps {
  data: IndexPageQuery;
}

const Page: FC<PageProps> = ({ data }) => (
  <Layout>
    <div className="article-list">
      {data.allMarkdownRemark.edges.map(edge => {
        if (!edge.node.frontmatter?.slug) return null;

        const slug = edge.node.frontmatter.slug;

        return (
          <>
            <Link key={slug} className="article-list-item" to={slug}>
              {edge.node.frontmatter.date && (
                <p className="article-list-item-date">
                  {edge.node.frontmatter.date}
                </p>
              )}
              <h1>{edge.node.frontmatter.title ?? '(無題)'}</h1>
              {edge.node.excerpt && (
                <p className="article-list-item-excerpt">{edge.node.excerpt}</p>
              )}
            </Link>
            <hr />
          </>
        );
      })}
    </div>
  </Layout>
);
export default Page;

export const query = graphql`
  query IndexPage {
    allMarkdownRemark(sort: { fields: frontmatter___date, order: DESC }) {
      edges {
        node {
          excerpt
          frontmatter {
            date(formatString: "YYYY/MM/DD", locale: "ja-JP")
            slug
            title
          }
        }
      }
    }
  }
`;

記事の配列を map で JSX 要素に変換しています。 null | undefined の可能性があるのでチェックし、有効な値がなければ null を返します。 JSX では null は何もレンダリングしません。

JSX で <></> という記法があります。これは fragment というもので、複数の要素を <div> 要素などで包むことなく返せる機能です。 DOM ツリーが深くならないのでパフォーマンスが良くなります。

また、 <Link> コンポーネントに key 属性を渡しています。 map などでリストから要素を生成するときは、一意の値を key 属性に指定する必要があります。これはリストが変更されたときに不要な再レンダリングを避けるための React の機能です。 今回の場合リストが動的に変化することはありませんが、 key 属性は React の仕様ですので設定する必要があります。ここでは slugkey として設定しています。

完成したら、 / にアクセスしてみましょう。

article list

これで、最低限の機能のブログが完成しました。

Markdown 文書にリンクされた画像などはまだ正しく動作しませんが、プラグインで簡単に対応させられます。

クエリなどを駆使すれば、記事ページに前後の記事へのリンクをつけたり、記事にタグ機能をつけたり、月別アーカイブなんかもつけられます。このブログにどのような機能をつけるのかは自分しだいです。カスタマイズを楽しみましょう。

言い忘れていましたが、 gatsby-plugin-react-helmetreact-helmet をインストールして設定すれば、 <title> 要素など <head> 中の要素を指定できます。各自で設定してください。

ブログをビルドする

開発サーバーで見る Web ページは開発ビルドで、本番用の Web ページとは異なります。 GatsbyJS では、 production ビルドを生成するのも簡単です。本プロジェクトでは TypeScript で書いているので、以下のコマンドを実行します。

$ npx ts-node ./node_modules/.bin/gatsby build

例によってコマンドが長いので、 npm スクリプトを定義します。

package.json
{
  // ...
  "scripts": {
    // ...
    "build": "ts-node ./node_modules/.bin/gatsby build"
  },
  // ...
}

これで以下のコマンドでビルドできます。

$ npm run build

ビルド結果は、プロジェクトルートにある public ディレクトリに生成されます。この中身を Amazon S3 バケットなどのストレージに突っ込んで Amazon CloudFront などの CDN で公開するなどすれば、ブログをインターネットに公開できます。 Netlify などの選択肢もあります。

GitHub Actions を設定する

このブログは Git でバージョン管理しているので (していますよね?) 、ほとんどの方は GitHub にリポジトリを立てて push すると思います。

ならプロジェクトのビルドやチェック、デプロイはすべて GitHub にやらせてしまいましょう。 GitHub Actions の出番です。なお、本記事では Amazon S3 と Amazon CloudFront をデプロイ対象としています。その他のクラウドを使用している方はそれぞれ書き換えてください。

以前にも GitHub Actions の記事 は書きましたが、今回も改めて書きます。

.github/workflows/build.yml にアクションを書きます。

.github/workflows/build.yml
name: Build and Deploy

on:
  push:
    branches:
      - main

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
    - name: Checkout
      uses: actions/checkout@v2
    - name: Install Node.js
      uses: actions/setup-node@v1
      with:
        node-version: 15
    - name: npm ci
      run: npm ci
    - name: npm run build
      run: npm run build
    - name: npm run lint
      run: npm run lint
    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ap-northeast-1
    - name: Deploy
      run: aws s3 sync --exact-timestamp --delete public/ ${{ secrets.AWS_S3_BUCKET_NAME }}
    - name: Invalidate CloudFront cache
      run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION_ID }} --paths '/*'

on で、 main ブランチが push されたときにジョブが走るように設定しています。 main ブランチ以外では走らないので、ブログの機能開発ブランチや記事の下書きブランチなど他のブランチで作業したものはデプロイされません。

セットアップ→ビルド→チェック→デプロイという順番で、単一のジョブ build を定義しています。ジョブが動く環境として最新の Ubuntu を指定しています。

actions/checkout@v2 で自身のリポジトリを取得しています。 with でアクションに対するオプションを指定できます。このアクションではオプションを指定しない場合、デフォルトで自身のリポジトリを取得する設定となります。

actions/setup-node@v1 で Node.js v15 をインストールしています。

run でコマンドを実行しています。 npm ci で npm パッケージをインストールしています。 npm install (npm i) ではパッケージをインストールして package-lock.json の更新を行いますが、 npm ci では依存関係の解決はせず package-lock.json を見てパッケージをインストールします。 CI を回す際は必要な処理だけする npm ci でパッケージをインストールしましょう。

npm ci でインストールする量が多いので、このステップは時間が掛かります。 actions/cache でキャッシュすることもできます。試してみてください。

aws-actions/configure-aws-credentials@v1 では、 AWS のアクセスキーなどの設定を行います。リージョンは東京 (ap-northeast-1) に設定しています。 ${{ secrets.AWS_ACCESS_KEY_ID }} は GitHub リポジトリに設定されている AWS_ACCESS_KEY_ID シークレットに置き換えられます。

リポジトリにシークレットを追加するには、 GitHub リポジトリの設定を開いて、 Secrets から New secret ボタンを押します。

github settings

入力画面が出ますので、シークレットの名前と値を入力します。

github new secret

Add secret ボタンを押せばシークレットが追加されます。必要な他の値も追加しましょう。

github secret list

シークレットは一度追加すると値を確認できなくなります。たとえ追加した本人でも見ることができません。値の更新と削除は可能です。アクセスキーなど秘密にするべき情報はシークレットで管理しましょう。間違ってもリポジトリに直に載せてはいけません。悪意のある bot がアクセスキーなどを検索して不正利用を試みています。 awslabs/git-secrets の使用をおすすめします。プライベートリポジトリであっても直に載せるのはやめましょう。

build.yml の説明に戻ります。まあ後は aws コマンドで S3 バケットへの同期と CloudFront のキャッシュを無効化しているだけです。 S3 バケットの指定 (s3:// で始まる奴) と CloudFront ディストリビューション ID もシークレットにしています。

これを commit して main ブランチに merge して push すれば、すべて自動で進みます。快適なマイブログの完成です。

GitHub Actions の実行終了時にメールや Slack 等で通知するアクションもあります。便利。

おわり

お疲れ様でした。

滅茶苦茶長い記事になってしまいました。反省はしていない。