さようなら Gatsby

JavaScript

Gatsby をやめて Next.js に移行してた。

経緯

ここはもともとポートフォリオを兼ねて作成したブログで、当時の技術スタックを反映していたつもりだった。
その実態は闇のカオス、 Dark-Chaos だった。それも強力な技術同士を悪魔合体させ、オーバーフローした結果できるレベル 1 桁のむしろレアなやつ。よくわからん例え。

WordPress + Nginx 時代

他所からコピペしてきた何もわからん docker-compose.yml で環境を立ち上げて、SimpleNote に初稿を書き WYSIWYG 画面へ貼り付ける。
アップロードは FTP クライアントを使って、さくらサーバーのスタンダード(年 5,000 円)に手動で上げてた。
得体の知れない SEO 最適化プラグインマシマシで、Lighthouse のパフォーマンススコアが 30 ぐらいだった。

Gatsby 時代

いわゆる WordPress 反抗期。
JAMStack(現在は Jamstack) という強そうなワードに惹かれ、型も秩序もない JavaScript を書いて、何やってるか知らんけどいい感じのプラグインを突っ込んで、FLOCSS な設計の CSS を CSS Prop で縦横無尽に設置して、天も地もないコンポーネント郡に GraphQL クエリを流し込んでた。
CD は自動化したけど雰囲気で生きてきたし、思考を止めて「モダン JavaScript 万歳!」してた。
Lighthouse のスコアは 100 を出していたけど、何か大切なものを見失っていた。

反省

WordPress や Gatsby のような「いい感じにやってくれるやつ」は、ビジネス要件に合致してて、使い手も思想を受け入れられれば存分に力を発揮できる。
もちろん「いい感じにやってくれるやつ」を使うこと自体は悪くないし、どちらかと言えば多くのページで使われていて、ノウハウの共有に価値がある分野だと思う。
ただ、自分のブログには必要ない機能が多いし、雰囲気だけで技術を組み合わせても、いずれ自分の足を撃ち抜かれてしまうし、ブログのために固有の知識をストックする気力もなかった。
案の定メンテが不可能な状態になっていたし、もう一度作り直すことにした。

というか上記は建前で、詳細は伏せるものの、ここ最近の Gatsby コミュニティが不穏だったのでやめたかった。

SSG ライブラリはやめて、 SSG もできる Next.js にした。

当初はいっそ jsx も引き剥がしたくて、何かと評判の 11ty を考えていた。
でも、11ty は蓋を開けてみると zero-config が結構 Easy 寄りで、余計なテンプレートエンジンが Core に集まっていることがとても気になってしまった。
ESLint が Sindre Sorhus と並んで投資するぐらい有望で、それ単体で見ればかなり優秀だけど、今のままだと運用しても WP や Gatsby 時代の二の舞を踏みそうだと思い断念。

  • テンプレートが外側、ひいてはライブラリ特有の知識を知らなくていい
  • 記事の Interface はこちらで用意して、それを内に流し込むだけ
  • ↑について IDE で型補完が効く

上記の要件が満たせれば、少なくとも自分のブログで迷子にはならないと思った。
TypeScript + TSX が使えて、特有の知識は Page 層で完結する Next.js を採用した。

やったこと

ローカルの md ファイルから Sitemap, RSS, index を生成する。
記事個別ページは Remark で HTML に変換し、それを Next.js の Page に流し込む。
あとは React コンポーネントを書いていくだけ。Gatsby 時代に書いたそれはなかったことにした。

これまでとの違いは、雰囲気でやってる部分が消えて、コードの是非はともかく説明できるようになったこと。
補完が効くようになったのと、md 単位でバリデーション的なテストが書けるようになったのも嬉しい。
依存関係のメンテナンスが辛そうなのと、CSS は依然負債を抱えてるのがネック。

散々偉そうなことを言いつつ、魔がさして AMP も入れた。
Web Components だし、気に入らなかったら差し替えればいいかな、という軽い気持ち。
AMP の制約は https://amp.dev/ja/documentation/guides-and-tutorials/learn/spec/amphtml/ にある。
Next.js の AMP 設定については割愛。

引っかかったところだけ書き残した。

amp-img

AMP ページでは img 要素でなく、縦幅と横幅を指定した amp-img を用いる。

Remark に amp-img のプラグインはないので作る。
MDAST から img の Node を漁り hNameamp-img に置き換えるもの。
ついでに、imageSize で画像にアクセスし、縦横のサイズも書き出した。

convertImgToAmpImg.tsx

import appRoot from 'app-root-path'
import { imageSize } from 'image-size'
import visit from 'unist-util-visit'

export const convertImgToAmpImg = (options: {
  rootPathName: string
}) => {
  const appRootPath = appRoot.path
  const { rootPathName } = options
  // remark の img は p 要素でラップされる。消すプラグインもあるけど、デフォルトはこれ
  const wrapper = 'paragraph'
  return transformer

  function transformer(ast) {
    visit(ast, wrapper, visitor)

    function visitor(node) {
      if (!node.children || !node.children.length) return
      node.children.forEach((data, index) => {
        // img 要素の type は image
        if (data.type === 'image') {
          // imageSize ライブラリで、ルートフォルダから画像のあるパスへアクセスし、寸法を取得
          const dimension = imageSize(
            `${appRootPath}/${rootPathName}${data.url}`,
          )
          node.children[index].data = {
            // 要素と属性を書き換える。
            // layout が responsive に設定されているとレスポンシブになる
            hName: 'amp-img',
            hProperties: {
              layout: 'responsive',
              height: `${dimension.height}`,
              width: `${dimension.width}`,
            },
          }
        }
      })
    }
  }
}

これを Remark に噛ませる。

markdownToHtml.tsx

import { convertImgToAmpImg } from './convertImgToAmpImg'
import html from 'remark-html'
import parser from 'remark-parse'
import unified from 'unified'

export const markdownToHtml = async (markdown: string): Promise<string> => {
  const result = await unified()
    .use(parser)
    // parser の後に噛ませる。
    // rootPathName では Next.js の静的画像を置く public を指定
    .use(convertImgToAmpImg, {
      rootPathName: 'public',
    })
    .use(html)
    .process(markdown)
  return result.toString()
}

下記の md だと "/public/images/foo.jpg" が amp-img として読み込まれる。

markdown

![fooの画像](/images/foo.jpg)

amp-script

書き出したページに React のランタイムはない。
また AMP ページの script 要素は text/plain か JSON-LD しか許されてないので通常 JavaScript は使えない。
ただ、amp-script で紐づけた script は実行できる。150kb までの制限がある。

amp-script.tsx

const AMPSandBox = () => {
  return (
    <div>
      {/* @ts-ignore */}
      <amp-script script="localScript">
          <input
            id="buttonUb"
            name="ButtonUb"
            type="button"
            value="サンプルコードを実行するボタン"
          />
        {/* @ts-ignore */}
      </amp-script>
      <div
        dangerouslySetInnerHTML={{
          __html: `
        <script id="localScript" type="text/plain" target="amp-script">/* ここにコードが入る */</script>
          `,
        }}
      />
    </div>
  )
}

export default AMPSandBox

これも amp-img も、 d.ts で型定義できる。
この記事を書いた 2020/10/05 時点では、公式の型定義が用意されてないので、自分で書く必要あり。
https://nextjs.org/docs/advanced-features/amp-support/typescript

next-env.d.ts

declare namespace JSX {
  interface AmpScript {
    script: string
    children: ReactElement
  }
  interface IntrinsicElements {
    'amp-script': AmpScript
  }
}

window.alert はないので途中で終わってしまうけど、動かせることはわかった。
デモページ

結局使わなかった。

コードタイトル

Gatsby でコードブロックに表示していたタイトル部分の再現。
gatsby-remark-prismjs-title のような Remark プラグインは公式にも存在しないし、ラベル付けするためにユニーク ID を持ったコードブロックを置きたかったので、これも作ることにした。

remark-prism に挟めば良さそうな気がしたけど、このプラグインは HTML Node を書き出し ID 属性などの情報は拾わない(それはそう)。
悲しいけどここは宿題ということで、いったん fork してオレオレ処理を挿した。完全敗亡。

remark-prism/index.js

const parseLang = (str) => {
  /* 省略 */
  const title =
    // esLint に引っかかるのでこう
    str.indexOf('title=') === -1
      ? null
      : str.replace(/.+:title=(.[^{]+).*$/, '$1');
  /* 省略 */

  return {
    /* 省略 */
    title,
  };

ユニーク ID は offset などから取った。

記事

自分が書いた記事の中で、ライブラリをなぞるだけの記事、体系的な解説記事がノイズに思えてきたので全て消した。
前者は公式ドキュメントをアップデートした方が健全だし、後者は鮮度を保ち続けるモチベがないため。
とは言え手抜きをしたいわけではなくて、執筆当時の Why と What を書き残しておくことに重きを置きたい。
あと、曖昧な口調を統一するため、analyze-desumasu-dearu で「です・ます」口調の記事を調べ修正した。

終わり

ありがとう Gatsby さようなら。