blog.takurinton.dev

ブログをSSGにした

2022-08-14

こんにちは

どうも、僕です。

今回はブログを SSG にしてみたという話をします。

年始に SSR をするような構成にしたばかりですが、色々あって(後述)作り替えてみました。

SSR にする記事は以下からどうぞ。

ブログを作り直した Rust で GraphQL server を書いてみた

このブログのソースコードはこちら

takurinton/ssg-blog

なぜ作り直したか

そもそもなぜ作り直したか、理由は 2 つあります。

AWS の料金が高すぎる

ポートフォリオ

なぜわざわざ AWS に環境を置いているのか、当初の理由としては、2 年ほど前にプログラミングをやり始めた頃、まずは自分 1 人でサーバを立てて何かを作って公開してみようと思って作り始めたのが発端です。

現在の構成は EC2 に Go で書かれた GraphQL サーバがいて、それがモノレポの API server として常時稼働している状態です。DB は RDS を使用しています。

最初は無料期間があったり、アルバイト等での貯金があったのでよかったものの、無料期間も終わり、無職期間もあり、さらにここ 1 年くらいの請求は毎月 90 ドル弱くらいとなっていたため、若干お財布に厳しい状態になっていました。

aws-billing

しかし、就職した今の自分の金銭感覚で月 10000 円くらいとなると痛いけど別に困るほどではないし、そもそもいつでもサーバにやインフラに近い部分を他人に迷惑をかけずに素振りができる環境を少々割高ではあるが 10000 円で借りてるくらいの感覚だったので別にいいかなくらいの温度感でした。

そこで、どうしてこのタイミングで SSG にしたのかが 2 つ目の理由に続きます。

wmr を試したかった

2 つめは wmr というライブラリを使用したかったというのがあります。

筆者は最近 vite 関連の素振りをしていました。会社のプロダクトに入れたかったからです。(結局のところ、見送る形になりそうですが)

vite の素振りは以下の 2 つのメモにまとめてあります。

vite の所感 vite on docker container

snowpack

そのため、仕事では vite を使ってみたから、次は個人開発で wmr を使ってみようと思ったのが今回のブログリプレイスの背中を押してくれました。(仕事でボトルネックになっていた部分は個人開発では全く気にしなくていい内容だったので使ってみることに)

ちなみに wmr には prerender mode という静的ファイルを吐き出すためのオプションがあり、今回はそれを使用して SSG を実現しています。

開発環境では markdown ファイルを url 越しに読み込み、本番環境ビルドをするときに静的ファイルとして吐き出します。

本番環境では各静的ファイルで JavaScript をロードしており、hydration をすることでクライアントサイドで JavaScript のコードが動くようになっています。詳しいエコシステムについては割愛しますが、Preact チームの気合を感じました。

ちなみに wmr についてはこちらの記事が非常に良くまとまっていてよかったです。

preactjs/wmr について

技術選定

wmr のスターターを使用しました。他のライブラリも使えるようですが、今回はそのまま Preact を使用しました。

また、補助的な機能として以下を利用しています。

- marked

- markdown のレンダリングに

rintonmd

- highlightjs

- 記事内に出現するコードの syntax highlight をつけるのに使いました

- hoofd

- SSG を行う際に meta タグなどの設定を簡単に行えるようにする hooks です

toStatic

- OGP を表示したいため、使用しました

実装

Preact で実装をしています。

リンクの色と文字の太さ以外特に色付けなどはしていませんが、そのうちするつもりです。

markdown 部分は過去のブログで使用していた marked の拡張をそのまま使う形で実装をしていますが、そのうち自作の markdown parser に乗り換えたいと思っています。

また、記事内に登場するコードについて、syntax highlight をつけるために highlight.js を使用していたのですが、これが wmr でバンドルをする際にうまくバンドルできなかったので、この部分だけ hydration 後に CDN 経由で取得するようにしています。

すごい汚いコードですが、以下のような関数を作成し、これを useEffect で読み込む形の実装にしてみました。

// bundle に混ぜ込むと謎にエラーになるので、分ける
// この命令的なコードが俺の技術力の低さの証明になっている気がしてならない、こんなことしてないでバンドルチューニングをすればいいだけでは
const highlightJsSetup = () => {
  const highlightjs = document.getElementById("highlightjs");
  const highlightcss = document.getElementById("highlightcss");
  const hljsScript = document.getElementById("hljsScript");
  if (highlightjs !== null) {
    document.head.removeChild(highlightjs);
  }
  if (highlightcss !== null) {
    document.head.removeChild(highlightcss);
  }
  if (hljsScript !== null) {
    document.head.removeChild(document.getElementById("hljsScript"));
  }

  const script = Object.assign(document.createElement("script"), {
    id: "highlightjs",
    src: "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/highlight.min.js",
    async: true,
    type: "text/javascript",
  });
  const style = Object.assign(document.createElement("link"), {
    id: "highlightcss",
    rel: "stylesheet",
    href: "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/atom-one-dark.min.css",
  });
  document.head.appendChild(script);
  document.head.appendChild(style);

  script.addEventListener("load", () => {
    const hljsScript = Object.assign(document.createElement("script"), {
      id: "hljsScript",
      innerHTML: `hljs.highlightAll();`,
    });
    document.head.appendChild(hljsScript);
  });
};

本番ビルド

usePost usePost

import { useState } from "preact/hooks";

const CACHE = new Map();

async function fetchPost(url) {
  const mdStr = await fetch(url).then((res) => res.text());
  let meta = {};
  return { mdStr, meta };
}

export function usePost(id: string): { mdStr: string; meta: any } {
  const url = `/contents/${id}.md`;
  let update = useState(0)[1];
  let post = CACHE.get(url);
  if (!post) {
    post = fetchPost(url);
    CACHE.set(url, post);
    post.then(
      (value) => update((post.value = value)),
      (error) => update((post.error = error))
    );
  }

  if (post.value) return post.value;
  if (post.error) throw post.error;
  throw post;
}

usePost

prerender prerender

import { VNode } from "preact";

let initialized = false;

export async function prerender(vnode: VNode) {
  if (!initialized) {
    initialized = true;
    const fs = (await eval("u=>import(u)")("fs")).promises;
    // fetch API を定義
    globalThis.fetch = async (url) => {
      const text = () =>
        fs.readFile(`dist/${String(url).replace(/^\//, "")}`, "utf-8");
      return { text, json: () => text().then(JSON.parse) };
    };
  }

  // ...
  // meta タグを取得したり静的ファイルを生成する処理が続く
}

まとめ

金が高い!!!という動機で始めた実装でしたが、ウェブアプリケーションを作るための技術を 1 から理解しようとして作成した最初は空っぽだった EC2 の Linux 環境、頑張って立ち上げた RDS 環境、たまに UPDATE 文に where をつけ忘れたこともありました。さらには見よう見まねで書いた nginx の conf、一生懸命設定したセキュリティグループなどに別れを告げるのは普通に寂しいです。

この 2 年間、少しばかりお金はかかりましたが、自分自身の技術の試し打ちの場所として動いてくれ、成長させてくれた AWS 環境には感謝しかないです。

また、新しくなったブログも読んでいただけると嬉しいです。

おまけ

ブログを書き始めるときに、タイトルを入れるとテンプレートを作成してくれる CLI を作りました。scaffold みたいなやつです。

ソースコードは以下のようになっていて、タイトルをコマンドラインから受け取り、記事一覧を保持してる json に値を追加し、マークダウンのテンプレートを作成するといったシンプルなものです。

import { writeFileSync, readFileSync } from "fs";
import { createInterface } from "readline";

(() => {
  const readline = createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  readline.question("title: ", (title) => {
    const posts = JSON.parse(
      readFileSync(`${process.cwd()}/public/contents/posts.json`, "utf8")
    );
    const ids = posts.map((post) => post.id);
    const newId = Math.max(...ids) + 1;

    const date = new Date();
    let newPost = {
      id: newId,
      title,
      description: `${title} について`,
      created_at: `${date.getFullYear()}-${(date.getMonth() + 1)
        .toString()
        .padStart(2, "0")}-${date
        .getDate()
        .toString()
        .padStart(2, "0")}`.replace(/\n/g, ""),
    };

    writeFileSync(
      `${process.cwd()}/public/contents/${newId}.md`,
      `---
id: ${newPost.id}
title: ${newPost.title}
description: ${newPost.description}
created_at: ${newPost.created_at}
---
`,
      (err) => {
        if (err) throw err;
        console.log(`${newId} created.`);
      }
    );

    const newPosts = [...posts, newPost];
    writeFileSync(
      `${process.cwd()}/public/contents/posts.json`,
      JSON.stringify(newPosts),
      (err) => {
        if (err) throw err;
        console.log(`posts.json updated.`);
      }
    );
    readline.close();
  });
})();

これを以下のように実行するとファイルを作成してくれます。

yarn gen

title: ブログをSSGにした
109.md created.
posts.json updated.

生成された markdown

---
id: 109
title: ブログをSSGにした
description: ブログをSSGにした について
created_at: 2022-08-14
---