Hori Blog

フリーランスでバックエンドエンジニアとして活動している Ryota Hori のブログです。ドメイン駆動設計や Go の話など。

Gatsby.jsとBulmaでブログをリニューアルした

全然ブログを更新していなかったので久々に書こうと思い、勢いでリニューアルしました。これまではHugoを使っていたのですが、今回はGatsby.jsとBulmaを組み合わせてみました。

乗り換えにあたって検討したことと、Gatsby.jsとBulmaで苦労したこと、調整したことなどを残しておきます。

HugoからGatsby.jsに乗り換えた理由

HugoはGo製の静的サイトジェネレータで、登場時には既存ツールに比べてとにかく爆速で話題でした。

The world’s fastest framework for building websites | Hugo

とりあえずMarkdownで記事を書いておけば移植性も高いと思い軽いノリで導入したのですが、たしかに爆速で移植性も高くいい感じでした。

一方でどうしても使いこなせなかった点がいくつかありました。

Hugoを導入したのは2015年なので今では改善されている問題も多いですが、Hugoを調整するよりもGatsby.jsに乗り換える方がベターと判断して乗り換えました。

Gatsby

以下、乗り換えの動機です。

Goのtemplateがつらかった

HugoはGo製なので、themeのテンプレートはGoのtemplateです。詳細は省きますがあまりHTMLの出力に適しているとはいい難く、自作テーマを作成する気にはならず既存のテーマを拝借していました。

Gatsby.jsはReactベースなのでHTML出力に適しているだけでなく、各種の最適化もいい感じに行ってくれるので自作テーマの作りやすさが段違いでした。

Hugoでは画像の扱いが面倒

Markdownの画像記法だけでは widthheight のパラメータが入るわけでもなく、また画像の最適化も自動ではやってくれません。Exif情報などのメタデータも削除してくれないので単体では使い勝手が満足できませんでした。

HugoにはShortcodeと呼ばれる独自記法があり、これを用いることで対処はできるのですが求めていたのはシンプルなMarkdownでの記事作成だったので欲求に合わず。

最終的には自作ツールで画像の変換をかけつつ、パラメータ指定は気にせずに大きな画像をそのまま提供している状態でした。

Hugo のサムネ作成、画像追加、画像最適化を自動化するシェル芸 | Hori Blog

なお、今ではHugoも Image Processing という名で画像周りの前処理は挟めるようですが、表示側の調整は今でもshortcodeを用いるようです。

Gatsby.jsでは(pluginを入れれば)shortcodeを用いずに画像の事前処理も表示分けもできる上、テーマ側でも難しい条件分岐をせずにシンプルな記載で適用できるのでとても楽でした。

Hugoのテーマの移植性の低さ

これはGatsby.jsで解決されているわけではないですが、Gatsby.jsでは自作テーマを用いるので気にならなかった点です。

HugoにはMarkdownで記載することによるテーマ間の移植性の高さを期待していたのですが、試したテーマの範囲では互換性の低さが目立ちました。全体設定ファイルのパラメータ名だけでなく、パラメータの書式(camelCaseとか)も異なり、また記事ファイルに記載するfrontmatter(メタデータ)の記法もバラバラだったため、テーマ変更の手間は大きかったです。

最速である必要はない

Hugoは確かに爆速ではあるのですが、最終的にはテキスト処理の爆速については自分のユースケースでは以下の理由でメリットが薄くなりました。

  • ボトルネックは画像の最適化処理であり、記事生成の速度はあまり差別化にならなかった

    • 記事数がとっても多くなったらまた考えればいい
    • 画像の最適化処理がHugoで高速な可能性もあるが、必要があれば自前で処理を切り出せばいい
  • 速度が欲しくなるのは全ファイルの生成ではなくlive reload時の速度であり、これはGatsby.jsも十分に速かった

各種の最適化の恩恵を受けたかった

gatsby-is-fast | Gatsby

表示が爆速。フロント周りの知識は弱いのですが、そんなに苦労せずいい感じに作れて満足。

Bulmaの使用

見た目へのこだわりは薄く「それっぽくいい感じ」であれば満足だったのですが、既存のテーマで丁度いいものがなかったので自作しました。

デザインはGatsby.jsのドキュメントで紹介されていたこともありCSSフレームワークのBulmaを使用しました。

Bulma: Free, open source, and modern CSS framework based on Flexbox

さくさくそれっぽく作れていい感じだったのですが、クラス名のスコープがグローバルなのでCode Highlightのライブラリなどと競合して思ったより辛かったです。

please namespace all classes · Issue #302 · jgthms/bulma

作る際に工夫&調整したあれこれ

Gatsby.jsとBulmaでいくつか工夫や調整が必要だったところがあったので参考になるかと思い残しておきます。

BulmaとPrism.jsの競合

Bulmaはシンプルなclass名を使用させたいようなのですが、めっちゃ競合しました。仕方ないので inherit を当てまくって対処。うーむ…

layout.scss
article .content {
  .tag, .number, .table, .title {
    display: inline;
    padding: inherit;
    font-size: inherit;
    line-height: inherit;
    text-align: inherit;
    vertical-align: inherit;
    border-radius: inherit;
    font-weight: inherit;
    white-space: inherit;
    background: inherit;
    margin: inherit;
  }
}

Gatsby.jsでremarkすると li 要素の入れ子が崩れる

article.md
- foo
    - bar
    - baz

みたいな要素があると

article.html
<ul>
    <li>
        <p>foo</p>
        <ul>
            <li>bar</li>
            <li>baz</li>
        </ul>
    </li>
</ul>

のように <p> タグが入るためmarginなどが適用されズレました。

[gatsby-transformer-remark] Nested lists inserting rogue paragraphs · Issue #10870 · gatsbyjs/gatsby

これはmdastのListItem要素でtextがparagraph要素として入ってくることによる挙動だと思います。出力を調整して <p> タグが入らなくしてもいいのですが、意識が低いのでCSSだけで対処。ついでに <ul> <ol> のmarginも気に入らなかったので <li> と同じに調整。

layout.scss
article .content li {
  & > p {
    margin-bottom: 0 !important;
  }
  & > ul, & > ol {
    margin-top: 0.25em !important;
  }
}

日付表示がUTCになる

見かけるサンプルだと以下のような感じで表示したいformatでfrontmatterのdateを取得して、

graphql
node {
  ~~~
  ~~~
  frontmatter {
    date(formatString: "YYYY-MM-DD")
  }
}

使用側では以下のように使っていたりします。

template
<time dateTime={node.frontmatter.date}>{node.frontmatter.date}</time>

これだとUTCでの日付が入ってしまいズレることがあります。

issueもありました。

Add option to get date in local or specified time zone · Issue #11832 · gatsbyjs/gatsby

issueでは以下のように表示側で Moment.js を使って調整する解決策が紹介されています。

template
import moment from 'moment'

<small>{moment(node.frontmatter.date).local().format(`MMMM DD, YYYY`)}</small>

これでもいいのですが表示側で考えることを減らしたかったので、 gatsby-node.jsonCreateNodefieldsjstDate を入れておくようにしてみました。また、Moment.jsはdeprecated化するようなので Day.js に。

gatsby-node.js
const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
dayjs.extend(utc)

exports.onCreateNode = ({ node, actions, getNode }) => {
  const { createNodeField } = actions

  if (node.internal.type === `MarkdownRemark`) {
    if (node.frontmatter.date) {
      createNodeField({
        name: `jstDate`,
        node,
        value: dayjs(node.frontmatter.date).utcOffset(9).format('YYYY-MM-DD'),
      })
    }
  }
}
graphql
node {
  ~~~
  ~~~
  fields {
    jstDate
  }
}
template
<time dateTime={node.fields.jstDate}>{node.fields.jstDate}</time>

別解だと JSTDate みたいなcomponentを作って使うのもいいかもしれませんが、JSTじゃないDateを使うタイミングはないので横断的に適用しやすいfields要素の活用にしてみました。

基本的に表示要素はfrontmatterなどをそのまま使わないでfieldsを使うようにしておいたほうがちょっとした前処理をはさみやすくていいのかもな〜と思うなど。

embed系の埋め込み

HugoではSpeakerdeckはshortcodeで、Twitterはembed用HTMLを直書きしていたのですがGatsbyではプラグインでembed用のHTMLを差し込むようにしました。

まず前提として、このブログでは(今のところ)参考リンク系は以下のような書式で書いています。

> [title](url)

この形式で該当のURLが貼られたときにembed用のHTMLと差し替える処理を入れていきます。

remarkでの処理差し込みは公式ドキュメントに記載があるので、こちらのやり方で実施しました。

Creating a Remark Transformer Plugin | Gatsby

./plugins/remark-custom/package.json
{
  "private": true,
  "name": "remark-custom",
  "main": "index.jsx"
}
./plugins/remark-custom/index.jsx
const visit = require('unist-util-visit')
const fetch = require('node-fetch')

function isOuterUrl(url) {
  return !url.match(/^https?:\/\//);
}

module.exports = async ({ markdownAST }) => {
  const oembedTarget = [];

  // link to iframely
  visit(markdownAST, 'blockquote', async (blockquote) => {
    if (blockquote.children.length != 1) {
      return;
    }
    if (blockquote.children[0].type != 'paragraph') {
      return;
    }
    const paragraph = blockquote.children[0];
    if (paragraph.children.length != 1) {
      return;
    }
    if (paragraph.children[0].type != 'link') {
      return;
    }
    const link = paragraph.children[0];
    if (isOuterUrl(link.url)) {
      return;
    }
    oembedTarget.push(blockquote);
  });

  await Promise.all(
    oembedTarget.map(async (target) => {
      const link = target.children[0].children[0];
      if (link.url.startsWith('https://speakerdeck.com/')) {
        const res = await fetch('https://speakerdeck.com/oembed.json?url=' + link.url);
        const json = await res.json();
        target.type = 'html';
        target.children = undefined;
        target.value = `<div
          style="position: relative; width:100%; height:0; padding-top:${(json.height / json.width) * 100}%;"
          >
          ${json.html.replace(/width="\d+"/, 'width=100%').replace(/height="\d+"/, `height="100%"`).replace('style="', 'style="position:absolute;top:0;left:0;')}
          </div>`;
        return;
      }
      if (link.url.startsWith('https://twitter.com/')) {
        const res = await fetch('https://publish.twitter.com/oembed?omit_script=true&url=' + link.url);
        const json = await res.json();
        target.type = 'html';
        target.children = undefined;
        target.value = json.html;
        return;
      }
    })
  );

  return markdownAST;
};
gatsby-config.js
module.exports = {
~~~
~~~
  plugins: [
~~~
~~~
    {
      resolve: `gatsby-transformer-remark`,
      options: {
~~~
~~~
        plugins: [
~~~
~~~
          `remark-custom`,
        ],
      },
    },
~~~
~~~

いったん動けばいいやのオレオレで作っているので汚いのはご容赦ください。後にリファクタの上、キャッシュを入れてリクエスト頻度を減らしたりsleep処理を入れる予定です。

いくつかの対応が混在しているので分解して紹介します。

該当する書式の取得

./plugins/remark-custom/index.jsx
visit(markdownAST, 'blockquote', async (blockquote) => {
  if (blockquote.children.length != 1) {
    return;
  }
  if (blockquote.children[0].type != 'paragraph') {
    return;
  }
  const paragraph = blockquote.children[0];
  if (paragraph.children.length != 1) {
    return;
  }
  if (paragraph.children[0].type != 'link') {
    return;
  }
  const link = paragraph.children[0];
  if (isOuterUrl(link.url)) {
    return;
  }
  oembedTarget.push(blockquote);
});

blockquote(>)内にlink([]())が1つしかない要素を拾っています。また、内部リンクは処理しないので isOuterUrl(link.url) でフィルタリングしています。

外部リクエストが生じるので一旦 push して保持した上で以下の処理で回しています。

./plugins/remark-custom/index.jsx
await Promise.all(
  oembedTarget.map(async (target) => {
    const link = target.children[0].children[0];
    ~~~
    ~~~
  })
);

oEmbedからembed用のHTMLを取得

SpeakerdeckもTwitterもoEmbed機能に対応しているため、Getリクエストでデータを取得します。以下はTwitterの処理。

./plugins/remark-custom/index.jsx
if (link.url.startsWith('https://twitter.com/')) {
  const res = await fetch('https://publish.twitter.com/oembed?omit_script=true&url=' + link.url);
  const json = await res.json();
  target.type = 'html';
  target.children = undefined;
  target.value = json.html;
  return;
}

ASTを編集するのではなく、 target.typehtml にすれば target.value にHTMLを入れるだけで差し替えが可能です。

Twitterの場合は omit_script=true をつけてwidget用のscriptを読み込まないようにしています。

GET statuses/oembed | Docs | Twitter Developer

その上でpluginを入れて横断的にscriptを読み込んでいます。

gatsby-plugin-twitter | Gatsby

Speakerdeckの表示を調整

現在(2020-09-21)取れるSpeakerdeckのEmbed用HTMLではwidth, heightが固定値でスマートフォンなどでの閲覧時にはみ出ます。これを防ぐためHTMLを置換しています。

./plugins/remark-custom/index.jsx
target.value = `<div
  style="position: relative; width:100%; height:0; padding-top:${(json.height / json.width) * 100}%;"
  >
  ${json.html.replace(/width="\d+"/, 'width=100%').replace(/height="\d+"/, `height="100%"`).replace('style="', 'style="position:absolute;top:0;left:0;')}
  </div>`;

汚い対応ですが、パーツへの対応が凝集されているのでヨシとしています。

その他やったこと

Cloudflareを挟んでいたのをやめた

Cloudflare + GitHub Pagesの構成だったのですが、GitHub PagesがカスタムドメインのHTTPSに対応したのでお役御免となりました。

Custom domains on GitHub Pages gain support for HTTPS - The GitHub Blog

CIでの更新を止めた

masterブランチにpushしたらCircleCIで更新するようにしていたのですが、ブログの更新作業とは合わないのでやめました。

  • 編集 → 更新 → 確認 までが1サイクルであり、確認がCIで自動化できていない場合に中間の 更新 を非同期的にやる利点はない
  • 逆に 更新 を非同期的にCIで行うために生じるオーバーヘッドが大きい
  • 更新処理の冪等性はGatsby.jsにより十分に保たれており、また gatsby clean による掃除もできるので「綺麗な環境」で処理する必要はない

    • もし必要ならDockerとかでいい
  • 自分のプライベートPCでしか作業しないので環境差分を考慮する必要も薄い

    • スマートフォンなどから更新したくなったらgit push起点じゃない明示実行でなにか用意することはあるかも

という感じで、今はpublish用のShell Scriptを用意して使っています。

おわり

Gatsby.jsいいですね。フロント周りの知識は全然追えてないのですが、それでもいい感じのレイアウトとLighthouseでぼちぼちのスコアを手に入れました。爆速です。

Twitterのwidgetを入れるとスコアが下がるのが悲しいところ。

SEOのスコアが Links do not have descriptive text で上がりきっていないですが more ボタンはともかくtagの Go が引っかかってしまっているのはどうするのがいいのやら。

それはそれとして、これ。

サムネイルも必須じゃない形式にしたので更新のハードルは下がったはず。きっとこれからは書く。はず。