フロントエンド初心者がGatsbyでブログを作り直した話

フロントエンド初心者が無事に Hugo のブログを Gatsby で一から作り直すことができた。その振り返り。

歴史

インターネットを小中学生(もはや 25 年以上前)に触り初めた頃に、HTML で文章の構造を作り CSS でデザインする、ということができるのを知って感動したけど、結局自分には何かが合わなくてそれを突き詰めることができなかった。というか、それを知ったが故にテーブルレイアウトとかがどうしても気に入らず、かといって CSS は float が難しすぎて、結局ウェブサイトを作る、という根本的な営みをずっと避けてきてしまった。

時は過ぎ、Wordpress の様なブログエンジンや Hugo の様な仕組みがあったおかげでブログを初めて続けることはできた。13 年前にレンタルサーバに Wordpress を置いて始めたこのブログも 9 年前には VPS での Wordpress 運用になり、5 年前に Hugo になり、と変遷してきた。ただ、どの時代にもデザインは何かのテンプレートをそのまま使っただけで、せいぜい画像を差し替えたりするくらいだった。仕事でもフロントエンドを触ることはなく、ブラウザの仕組みも SEO も何もわからないまま 10 年以上が過ぎてしまった。

ある時、React がとても良いという話をチラッと聞いた。またある時、TypeScript がとても良いという話をチラッと聞いた。そしてある時 GraphQL がとても良いという話もチラッと聞いた。完全にミーハーだけど、それらの組み合わせはなんか勉強しといて損はないかなーという気がした(この時点では JSX の存在も GraphQL の resolver も知らないレベル)。それで色々と調べはじめたら、Gatsby に出会った。React と GraphQL を使って、静的なサイトが構築できる!なんかとても良さそう!そう思ったのが 2019 年。

Gatsby の良さ

まず何よりも情報の豊富さと React + JSX の体感の良さが圧倒的だった。パッとスターターを起動してみて、component をいじると即座に反映される。JSX は HTML を文字列のテンプレートで扱わなくていいのが本当に素晴らしい。Plugin の仕組みも数も素晴らしくて、やりたいこと(例えば RSS を作るとかrobot.txtを作るとか)は何も考えずにすぐにできる。React を見たことない人向けにすごくシンプルな例だと

import React from "react";
export default function Home() {
  return (
    <div style={{ color: `purple` }}>
      <h1>Hello Gatsby!</h1>
      <p>What a world.</p>
    </div>
  );
}

こんな感じの JavaScript のファイルを作るだけできれいにレンダリングしてくれる。gatsby buildすればそれを静的な HTML に吐き出してくれる。これだけシンプルだと使うメリットないけど、見ての通り普通のコードなので、これをガンガン拡張していけることは容易に想像がつく。

しかも、Hugo の Markdown の記事を移行するのはとても簡単そうに見えた。なのでやり始めてみたんだけど、色々と課題が。250 くらい記事がすでにあるんだけど、全体に一律に加えたい微修正とかがある。例えば、要約(excerpt) を生成するのに、<!--more--> の様な separator より上の部分を使うというのができるんだけど、昔の記事だとタグを入れてなかった。なのでもし separator がなければ 1 段落目の後に入れる、というのをやりたかったけど、手作業はちょっとしんどい。Markdown の構造を的確に扱える何かがないかなぁと思った。

Unified JS の哲学

そこで出会ったのがUnified JS。Gatsby の内部でもgatsby-transformer-remarkという Markdown の記事を変換する plugin がremarkを使っていることから知って調べていったんだけど、これがとてもよくできてる。例えばその plugin が内部的に行なっていることを簡略化するとこんな感じ

var unified = require("unified");
var stream = require("unified-stream");
var markdown = require("remark-parse");
var remark2rehype = require("remark-rehype");
var html = require("rehype-stringify");

var processor = unified().use(markdown).use(remark2rehype).use(html);
process.stdin.pipe(stream(processor)).pipe(process.stdout);

Markdown や HTML といったコンテンツの種類ごとに AST (Abstract Syntax Tree) が定義されていて、それをインターフェースとして、remark-parseは Markdown を解析して Markdown AST (MDAST)にする仕事、remark-rehypeは MDAST を HTML AST (HAST)にする仕事、rehype-stringifyは HAST を実際の HTML に変換する仕事、と綺麗に分担されている(実際にはmdast-util-to-hastみたいなもっと premitive なパッケージがあってそれがエンジンになっている)。

まるで「UNIX という考え方」そのままって感じだ。

というわけで、しばらく Unified JS を勉強して、自分の持っている Markdown を変換する作業をしていた(昔の記事は HTML そのままだったりもした)。Unified JS は本当に強力で美しいので出会えて本当によかった。それで無事に Markdown の記事を Gatsby で表示させるところまでは辿り着けた。

ちなみに、その過程でgatsby-transformer-remarkの挙動で少し変な部分があったので Pull request して merge されたりしてた。おかげで Gatsby の collaborator になっている。

https://github.com/gatsbyjs/gatsby/pull/14723

さぁ、いざブログを作ろうと思ったけれども、またテンプレートみたいなのでデザインを省略するのは避けたかった。しかしいくら JSX が気持ちいいとはいえ、CSS を手でちまちま指定していくのは前にやって合わないのはわかってるし、Bootstrap みたいなフレームワークを使うとそれに詳しくなる必要が出てきてあまり価値を感じないし、細かいところが謎のままで終わってしまいそうだった。

Tailwind CSS が決め手

そんな中でまたふと出会ったのがTailwind CSSだった。サイトのデモとかを見て貰えばわかるけど、class を変更するだけでなんでも変更できる。そんなの CSS で直接変更するのと変わらないでしょ?というのもよく見かけるけど、Tailwind CSS は CSS との property と完全に 1 to 1 対応しているわけではないし、フォントサイズや色は標準でパレット的に提供されているので、何 px にしようか、みたいなことに悩まなくてよい。そして、別に class を直接指定する必要もなくて、CSS の@applyを使ってまとめあげることもできる。

例えばこのブログの一番下の author component はたったこれだけで記述できている

const Author: React.FC = () => {
  const { author, bio } = useSiteMetadata();
  const fluid = useRiywoImage();
  return (
    <div className={styles.container}>
      <Img fluid={fluid} className={styles.image} />
      <p className={styles.author}>{author}</p>
      <p className={styles.bio}>{bio}</p>
    </div>
  );
};
.container {
  @apply grid grid-cols-7 grid-rows-2 gap-y-2 gap-x-4;
  @apply max-w-md mx-auto;
}

.image {
  @apply rounded-full ring-2 ring-gray-300;
  @apply self-center justify-self-end;
  @apply w-16 h-16 col-span-2 row-span-2;
}

.author {
  @apply self-end;
  @apply col-span-5;
}

.bio {
  @apply self-start;
  @apply col-span-5 text-xs;
}

本当に素敵すぎる。なお、この時まで Flex box も CSS grid も一切いじったことがなかったけれども、Tailwind CSS のドキュメントが視覚的でとてもわかりやすいのと、それらを使うのにただクラスを追加していくだけで済む簡便さ、あとはCSS Tricks というサイトがとてもわかりやすかったおかげで、なんとかデザインをやりきることができた。

あとCSS modules も本当に最高で、class の名前を何も考えずにそのスコープ上だけでつければ、あとは build 時に勝手に衝突しない名前をつけてくれる。

素の CSS は僕には naive 過ぎたけど、ごついフレームワークはそれ自体のエコシステムについていく気がしなかった。Tailwind CSS はちょうどその中間で気持ち良いインタフェースを提供してくれたので、こんな自分でもついにまともに CSS でデザインすることができた。

Typography

以前は Typography.js を使おうかと思ってたけど、Tailwind CSS に出会ってからはそれはやめて、Tailwind Typography pluginを利用した。これは文章を読みやすくするためのプリセットが入ったprose class を提供してくれるので、ほぼそれをそのまま活用しているけど、いくつか気に入らない部分は上書きしてる。

あと、Web フォントを利用しているけど、Fontsourceを使って build 時に取り込んで自前で配信している。

GraphQL

Gatsby は外部のデータを build 時に動的に取得するインタフェースとして GraphQL を採用している。Markdown の記事を読み込むのも GraphQL で一旦表現しなおされていて、それに対してクエリをすることで中身を取り出している。オーバーテクノロジーの様な気もするけど、例えば Markdown から Headless CMS に切り替えたい (実際いつかそうしたい)時には、その Headless CMS が GraphQL を提供していれば割とあっさりと結合できてしまう。また、GraphQL という境界でデバッグを分けることもできるので、ちょっと楽をできたこともある。(実際はそんなにきれいにいくことばかりではないが)

例えば、ブログのトップページはこんな感じで全件取得して年で group by している。もし Headless CMS に置き換えるなら、このクエリを CMS 側に合わせてあげればよい。

  query BlogIndex {
    allMdx(sort: { fields: [frontmatter___date], order: DESC }) {
      group(field: fields___year) {
        nodes {
          frontmatter {
            date
            title
            path
          }
        }
      }
    }
  }

TypeScript

さて、もう一つの課題として、完全に TypeScript だけで書きたいというのがあった。これは、JavaScript に弱い自分にとっては必須で、下手なバグを避けるためにも型によってきっちり縛って書きたかったからだ。TypeScript で記述するというだけであれば、最近の Gatsby は何もせずに対応しているのだが、それは単に JavaScript に変換してくれるというだけの話で、型の検査はしてくれない。また、Gatsby plugin の開発をしている時に知った commit 時に自動で format したり linter をかけたりするのも、JS/TS の良い書き方を知らない自分には必須だった。というわけで、それらをセットアップするのに相当の時間を費やした。以下のブログは大変に参考にさせて頂いた。

Prettier と ESlint/Stylelint

そもそも formatter と linter の違いもよくわかって無かったけど、この説明 (Prettier for formatting and linters for catching bugs)を読んで納得。どちらも Unified JS の様に AST を内部で利用しているが、Prettier は一貫性のある形に format するけど AST を解析してはくれない。一方 ESLint や StyleLint は AST の解析も行ってバグの発見してくれる。じゃあ ESLint/StyleLint だけ使えばいいのでは?という感じがするけど、formatter としての Prettier はかなり広く普及していて IDE もサポートしてるし、何より format だけなので実行が速くてストレスがない。

というわけで、format は Prettier のみにして、ESLint/StyleLint 側では Prettier と競合するルールを除外するというのが Prettier が最近推奨しているやり方なのでそれに乗っかった。ポイントは大体以下の感じ。IDE は WebStorm を使ってる。

  • prettierは Gatsby の starter で既に設定されていたので、IDE でファイル保存時に自動実行するように
  • npx mrm lint-stagedで commit 時のprettier有効化 (のちにeslintstylelintも追加)
    • npx mrmはかなり便利で、他の TypeScript プロジェクト開始時にも利用している。
  • tsconfig.jsonを作って IDE で常に型検査 (npx tsc --init --target esnext --jsx preserve --noEmit)
    • noEmitなので transpile はさせていない。Gatsby では内部的に Babel を利用している。
  • eslintを追加、eslint-config-prettierprettierと競合するルールを無効化、IDE で自動検査
  • stylelintを追加、stylelint-config-prettierprettierと競合するルールを無効化、IDE で自動検査

これをレポジトリを作った初期に行なっておくことで、このあと追加したファイルが最初から型検査および format/lint された状態で始められるのでとても気持ちが良い。なお、tscは 1 ファイルに対して実行するものではないので、lint-stagedで自動実行はさせておらず、IDE のチェックを頼りにしている。(そもそもtscだと plugin が読まれず CSS modules が引っかかってしまうという問題もあり、IDE のみでのチェックがちょうど良い)

Gatsby の TypeScript 化

gatsby-plugin-typegenを入れると、GraphQL のクエリから自動で返り値の型を生成してくれるので、利用側で安心して使える。ただ、基本的に全て Optional になってしまうので、そうじゃない signature に渡す時に工夫が必要だった(!は ESLint で弾いている)。3.7 で入ったassertを使うとそれ以降のコードでは型を推論しなおしてくれて Optional じゃなくなるという便利機能があるので、それを使って初期化部分を切り出したらまぁまぁよくなった。こんな感じ

class Post {
  constructor(readonly title: string) {}
}

const initPost = (post: GatsbyTypes.BlogPostQuery["post"]) => {
  assert(post && post.frontmatter && post.frontmatter.title);
  return new Post(post.frontmatter.title);
};

この辺は、JavaScript でやってれば気にする必要は無かったことだけど、反面で実行時のエラーのデバッグが少し大変。このコードであればどうしてコケたのかが即座に分かるので良い。なお、Gatsby は型検査せずに実行可能(Babel だから)なので、開発中はとりあえずの全体挙動を確認するために型検査を無視して実行させて、それからゆっくり型をつけていくこともできた。

また、gatsby-config.js等の設定ファイルは JavaScript であることが期待されているけど、gatsby-plugin-ts-config を使うことでこれらも TS 化。ただ、こっちはガチガチにやる必要はないので、今はgatsby-configanyなままにしている。

これらを頑張ったおかげで、以降の開発は IDE に頼りながら実に快適に進めることができた。もう JavaScript には戻れない。

MDX

最初は remark で Markdown を読ませてたんだけど、よく考えると Hugo で Shortcode を使っている部分があった。これを置き換えるには MDX という Markdown の中に JSX を書けるものを使うと良い。Chart.js をtableから生成する component はこんな感じに書けた(これは切り出してリリースしてもいいかもしれない)

import React, { useLayoutEffect, useRef, useState } from "react";
import assert from "assert";
import ChartComponent from "react-chartjs-2";
import RandomColor from "randomcolor";
import styles from "./chart.module.css";

interface Props {
  type: string;
}

const toCellsArray = (row: HTMLTableRowElement) =>
  [...row.cells].map((cell) => cell.innerHTML);

const Chart: React.FC<Props> = ({ type, children }) => {
  const tableRef = useRef<HTMLDivElement>(null);
  const [chartData, setChartData] = useState({});

  useLayoutEffect(() => {
    const rows = tableRef.current?.querySelector("table")?.rows;
    assert(rows, `There is no data: ${tableRef.current}`);
    const [head, ...dataRows] = [...rows];
    const labels = toCellsArray(head).slice(1);
    const datasets = dataRows.map((row) => {
      const [label, ...data] = toCellsArray(row);
      const color = RandomColor({ seed: label });
      return {
        label: label,
        data: data.map(parseFloat),
        backgroundColor: color,
        borderColor: color,
        fill: false,
      };
    });
    setChartData({
      labels: labels,
      datasets: datasets,
    });
  }, [tableRef]);

  return (
    <div className={styles.container}>
      <ChartComponent type={type} data={chartData} />
      <div ref={tableRef} className={styles.table}>
        {children}
      </div>
    </div>
  );
};

export default Chart;
<Chart type="line">

| Month             | Dec 17 | Jan 18 | Feb  | Mar  | Apr  | May  | Jun  | Jul  | Aug  | Sep  | Oct  | Nov  | Dec  |
| ----------------- | ------ | ------ | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| Vancouver Max (℃) | 8.9    | 11.8   | 12.4 | 13.3 | 18.1 | 25.1 | 26.8 | 28.6 | 29.0 | 27.1 | 17.6 | 15.0 | 12.9 |
| Tokyo Max (℃)     | 16.0   | 16.0   | 15.1 | 24.2 | 28.3 | 29.0 | 32.9 | 39.0 | 37.3 | 33.0 | 32.3 | 22.8 | 23.4 |
| Vancouver Min (℃) | -5.8   | -4.5   | -7.5 | -1.7 | -0.2 | 6.1  | 8.3  | 8.8  | 10.8 | 6.1  | 1.6  | -2.2 | -5.7 |
| Tokyo Min (℃)     | -0.2   | -4.0   | -1.8 | 1.7  | 5.5  | 9.0  | 14.2 | 19.1 | 18.3 | 14.1 | 11.6 | 5.8  | 0.4  |

</Chart>

上の Markdown が下の様に変換される

この調子で、Tweet とか YouTube とかも component しようかと思ったけど、gatsby-remark-embedderという便利なのを見つけてしまった。これだと実は MDX にする必要がなかったりする(Chart.js はやりたかったので僕は MDX でよかったけど)。現状は、Gist の埋め込みが大変で諦めた(scriptを実行できない/iframeにするとリサイズが大変)のと、Slideshare とか Amazon の埋め込みはiframe直書きのまま残している。いずれは頑張りたい。

OG image

これも以前からやりたかったんだけど、エントリ毎にタイトルを埋め込んだ画像を生成して、SNS でのシェアの時にかっこいい感じにしたかった。バーっと調べた結果、gatsby-plugin-open-graph-imagesが自分が手でやろうと思ったことを大体実装してくれてたので採用した。ただ、そのままでは動かない感じだったので、PR 送りつつ fork した自分のレポジトリを参照している。やったのは以下の感じ

  • OG image 用の Gatsby のテンプレートを作成。他のページと全く同じノリでデザインできて最高
  • createPagesAPI の中で上のテンプレートを利用したページも作成し、画像パスを context で渡しておく
    • その画像パスを必要なmetaタグに埋め込んでおく
  • postBuildAPI の中でexpressを起動してpuppeteerでスクリーンショットを画像パスに保存

実に快適で、数日で完成してしまった。現状は毎回全ページ再生成なので build 時間が若干遅いけど、cache の仕様を読み込んで実装する時間は取れなかったのでまぁよしとする。一応puppeteer-clusterを使うことで 30 秒くらいは短くできた。ちなみに、手元の開発がしょぼしょぼ 2016 Macbook なので、手元での全 build は遅過ぎて使ってない。代わりに環境変数でブログコンテンツを入れ替えられる様にして、手元では数エントリしか入ってない開発用のコンテンツを参照している。

なお、headが巨大になると Facebook や Twitter が後ろの方にあるmetaを読んでくれないという問題があって、こちらの workaround をしておく必要があった。なお、titleは以下の設定すると無駄な空のタグも生成してはてブがタイトルを認識できなくなってたので僕は消している。(はてブがog:titleを読んでくれてもいい気はする)

https://marcomelilli.com/posts/gatsby-react-helmet-og-meta-tags-are-not-recognized

Plausible

Google Analytics は使いたくなくなったので数年前に切ってるんだけど、それ以降アクセス解析を全くしてなかった。リニューアルするので再開しようと思って色々と調べた結果、今は Plausible に落ち着いてる。非常にシンプルでかつ全て OSS でいくという判断をしているところ。どこまで続くかはわからないけど、UX は最高なのでひとまず使い続けてみる。

Gatsby の plugin を公開してる人がいるのでとりあえずgatsby-plugin-plausibleを使っているけど、Outbound link 計測もしたかったので PR を自分の fork に merge して使っている。

なお、この時気づいたけど最近のnpm(といっても v5 出たの 2017 年だけど)では、GitHub からのインストールでnpm prepareを実行してくれるので、build が必要な package も簡単に使える様になっていた。

AWS Amplify Console

さて、実際にデプロイする先をどうしようかと思ったけど、AWS が CI/CD とホスティング環境として AWS Amplify Console を出していたのでそれを利用した。Backend は使っていないので純粋に Frontend の CI/CD+ホスティングのみで利用している。Backend を使わなければ UX は非常に快適だ。1

AWS Cloud Development Kit (AWS CDK)も対応してるので、手作業で色々試したあとに全てコードに落とし込んだ。ここでは上で培った TypeScript の環境作りが役に立ってて、即座にprettiereslintも導入できた。レポジトリ連携は一番簡単な AWS CodeCommit を利用して、それも CDK で作成した。別にどこかに公開するコードでもないので GitHub である必然性はなかった。

また今回から AWS アカウントもブログ用に切り出して管理をしやすくした。AWS Organizations でサクッとアカウント作成して、CDK からのアクセスも CodeComimt へのアクセスも AssumeRole を利用したので、新規の IAM user は必要なかった。最近はいろんなツールが~/.aws/configに対応してくれてて便利。

[profile blog]
region=us-west-2
role_arn=arn:aws:iam::0000000000:role/OrganizationAccountAccessRole
source_profile=default

こんな感じで~/.aws/configを書いておいて、CDK はcdk.json"profile": "blog"を入れるだけ。CodeCommit はgit-remote-codecommitを利用すれば楽ちんで、brew install git-remote-codecommitした後で、

git remote add origin codecommit://blog@<repo-name>

これだけで完了。いい時代になったものだ。

ようやくリリース

というわけで、2019 年 4 月に Gatsby 化を思い立ってから、1 年半以上かかってしまった。実際に作業していたのはそのうちの 2 ヶ月くらいなんだけどね。。。でもおかげでそのうちにいくつかバグが治ったり仕様が安定したりしたので、まぁ時間がかかったのは悪いことだけではなかった。

リリース作業は、アカウント分離にともない Amazon Route53 の Hosted zone も切り出したので、少し複雑になった。

  1. Amplify Console を CDK で作成し、あらかじめデプロイしておく
    1. ドメイン管理は有効化せず、amplifyapp.comのドメインで事前に動作確認
  2. 新アカウント上にblog.riywo.comの Hosted zone を作成
    1. こいつのライフサイクルは Amplify とは異なるので CDK には含めなかった
  3. 新 Hosted zone の NS をriywo.com側でblog.riywo.comの NS に設定
    1. この時点でブログは一時アクセス不能
  4. Amazon CloudFront を利用していたので、Amplify Console と競合しないように CNAME の設定からblog.riywo.comを削除
    1. Amplify Console も配信には CloudFront を利用しているため
  5. CDK を更新して Amplify Console のドメイン管理を有効化
    1. あとは待つだけ

もろもろの反映にもっと時間かかるかなと思ったけど、30 分もかからずに終わってしまった。期待通りにブログが見えて感動…

…したのも束の間、sitemap と robots.txt を置いてなかったのを思い出したのと、はてブがタイトルを取得できてないのに気づいたのでささっと修正。この辺が、しっかり腰を据えて Gatsby を理解したお陰で、ものの数十分で対応できた。

なぜ Hugo のままじゃなかったの?

上で見てきてわかるように、今回学んだ技術の多くは Gatsby だけに囚われず応用できるものが多い(そう意図して挑戦してきた)。Hugo は別に悪くないんだけど、その独特の世界を深く理解しても Hugo だけでしか活かせないものが多い。また、HTML を文字列テンプレートで扱うのはもうやりたくなかった。

React + JSX および Gatsby の体験の良さが、Hugo を使い続ける理由をかなり初期に取り払ってしまった感じだ。

まとめ

10 年以上インターネット界隈で仕事をしているのに、フロントエンドを触る機会はまるでなかったので、ずーっと完全に初心者だったけど、知の高速道路のお陰でとりあえずスタートラインに立てるくらいにはキャッチアップできたと思う。20 年以上前からやりたかったウェブサイト構築を初めて自分でできた気がする。

今後は記事をもっと書きつつ、ちまちまとサイトをアップデートしていきたい。以下がとりあえずやりたいことリスト

  • Twitter/Facebook/はてブの共有ボタン設置
  • Author のところに Twitter や GitHub のリンク設置
  • Prism の syntax highlight
  • JSON-LD 対応
  • トップページのスクロール時に年の section を float させたい (iOS の UITableView の感じ)
    • すごい適当な実装を一回やったら event handler 作りまくりになって劇遅になった。React 勉強しないと。。。
  • パフォーマンス計測&最適化
  • Gist の埋め込み対応
  • Slideshare と Amazon の埋め込み対応
  • OGP 使った汎用的な埋め込み
  • build 時間短縮

おまけ

見て分かる通り、色のイメージは 49ers のチームカラーだけど、細かい調整をしてる時間はなかったのでだいぶ大雑把。カバー画像は普通の写真をduotoneを使って色付けしている

Footnotes

  1. Backend も Analytics を試したんだけど、Amazon Pinpoint が欲しい機能と少し違ったのと、Amplify のいろんなバグを踏み抜いた結果諦めた)