Hori Blog

フリーランスでバックエンドエンジニアとして活動している Ryota Hori のブログです。
最近はテック系記事より雑記ブログ気味。

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

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 が引っかかってしまっているのはどうするのがいいのやら。

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

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