導入

ひと言
— この動画の時代に個人ブログで勝負するという縛りプレイ

個人ウェブサイト開発に 4 ヶ月程費やし、何とか最低限見せられるレベルまで完成しました。その開発体験についてポイントをかいつまんで書きます。

当サイトのソースコードに興味のある方は、Github レポジトリをご覧ください。百文は一見(コード)に如かず。

想定読者

  • Astro でウェブサイトを作りたいが、プロジェクト全体の構成をどうすればいいのか、どんな機能を実装すればいいのかわからない
  • Astro で作ったプロジェクトの全体像を参考にしたい

現時点での私の習熟度

記事執筆時点での私の習熟度は次の通りです。

  • ソフトウェアエンジニアとして計 3 年の経験あり(勤続ではない)
  • JavaScript、TypeScript、Astro の基本は理解している
  • Next.js や Astro で何度かウェブサイト制作を経験している

本題

Astro

基本は Astro の機能範囲で実装し、一部 JS フレームワーク「SolidJS」を使って実装しています。もちろん全て TypeScript(と TSX) です。

言語: TypeScript, TSX, YAML
データフォーマット: JSON
メタフレームワーク: Astro
フレームワーク: SolidJS
スタイリング: バニラCSS, AstroのScoped CSS, Vanilla Extract
データベース: Cloudflare D1(Sqlite)
ORM: Drizzle ORM
開発環境: Dev Containers, Docker
CI/CD: Github Actions, DangerJS
コミット管理: Git-cz
デプロイ: Cloudflare Pages
CMS: Front Matter CMS
ボット対策: Cloudflare Turnstile
パッケージマネージャ: Bun
パッケージ依存関係管理: Renovate
リンター & フォーマッター: Biome
テキスト校正: Textlint
Gitフックマネージャ: Lefthook
Eメール送信: Resend

全文検索

デモ: 検索機能
デモ: 検索機能

Rust 製の静的検索ライブラリであるPagefindの力を借りることにしました。軽量、高速、そして導入が簡単。
更にサーバーとのやり取りが不要でブラウザ完結なのも魅力的です。仕組みは至ってシンプルで、ビルド時に生成される静的ファイルを読みに行き、検索用のインデックスファイルをあらかじめ生成しておくというものです。

開発に際して、次のポイントにこだわりました。

  • ショートカットキーでも呼び出せる
    • PC からであればCtrl + Kで呼び出し可能
  • キーワードのハイライトをカスタマイズ
  • サイトのどこからでもアクセスできる
    • ナビゲーションバー内に設置
  • 検索窓にオートフォーカス
    • 検索モーダルを開くとカーソルが検索窓にオートフォーカスされる

多言語対応 (i18n)

Astro の Content Collections APIを活用して、言語ごとにディレクトリを切り出し、手動翻訳をする方針にしました。

公式のドキュメントに記載されている手順通りに、言語ごとのディレクトリを作成しました。(私の場合はenjaです。1 つのファイルに対して、言語の数と同じだけ同名のファイルを作成する必要があります)

Astro の Content Collections は Markdown/MDX 形式だけでなく、YAML や JSON 形式にも対応しています。私は記事以外の翻訳文字列群の保存に YAML 形式を選びました。非常に簡潔で好きです。

content ディレクトリツリー
content ディレクトリツリー

OG 画像 (Open Graph)

個々のブログやニュース記事については、OG 画像がAstro の APIエンドポイントを介して動的に生成されます。

OG 画像の全パスはAstro の dynamic routesによって動的に生成され、その意味では動的なのですが、それらがビルド時に生成されるという意味では静的です。

画像生成に際して、satori ライブラリに HTML と CSS を渡せばそこから SVG 形式で画像を生成してくれます。そしてSharp ライブラリにその結果を渡して画像最適化処理など適宜加えつつ PNG 形式の画像を出力します。

パスの例
https://younagi.dev/api/og/blog/astro-website.png
 OG画像の例
OG画像の例

Remark/Rehype プラグイン

このウェブサイトでは、全ての記事はマークダウン、もしくは MDX 形式で書かれています。しかし、カスタマイズなしの標準記法では最小限の表現しかできず味気ないです。

そこで、既製品のRemark/Rehypeプラグインを利用、無いものは自作することにしました。書き味はブロガーにとって命です。

Unifiedという文書変換系のエコシステムに属するプロセッサ群
Remark: マークダウンにASTの状態で変換処理を加える(mdast)
Rehype: HTMLにASTの状態で変換処理を加える(hast)
ASTはAbstract Syntax Tree(抽象構文木)の略です。詳細は他の文献をあたってみてください。
KaTex

KaTeX\KaTeX はウェブページ上で数式などをきれいに表示してくれます。その呪文を数式に変換するのに、remark-math と rehype-katexを追加しました。

入力:

$$
x = {-b \pm \sqrt{b^2-4ac} \over 2a}
$$
$$
( \sum_{k=1}^{n} a_k b_k )^2 \leq ( \sum_{k=1}^{n} {a_k}^2 ) ( \sum_{k=1}^{n} {b_k}^2 )
$$
$$
\int_{0}^{1} f(x) \ dx
= \lim_{n \to \infty} \dfrac{1}{n} \sum_{k=0}^{n-1} f \left (\dfrac{k}{n} \right)
$$

出力:

x=b±b24ac2ax = {-b \pm \sqrt{b^2-4ac} \over 2a} (k=1nakbk)2(k=1nak2)(k=1nbk2)( \sum_{k=1}^{n} a_k b_k )^2 \leq ( \sum_{k=1}^{n} {a_k}^2 ) ( \sum_{k=1}^{n} {b_k}^2 ) 01f(x) dx=limn1nk=0n1f(kn)\int_{0}^{1} f(x) \ dx = \lim_{n \to \infty} \dfrac{1}{n} \sum_{k=0}^{n-1} f \left (\dfrac{k}{n} \right)
コードブロック

開☆発☆者として、コードの具体例を交えた説明は避けられない宿命です。 rehype-pretty-codeを使ってコードのシンタックスハイライトを実装しました。ちなみにシンタックスハイライターはShikiです。

example.astro
---
type Props = {
  title: string
}

const { title } = Astro.props
---

<div>{`これは例です。タイトルは ${title}`}</div>
コールアウト

数年間 Obsidian をナレッジベースとして愛用していて、マークダウン記法で書けるコールアウトが好きでした。このブログでも使いたくなり、実装しました。

実装に際して、こだわった点は次の通りです。

  • 開閉可能
    • コールアウトタイトルの横に "+" もしくは "-" のマークがある場合、そのコールアウトは開閉可能と判断される
    • "+" が開く、"-" が畳む
    • 入れ子(入れ孫、入れひ孫、...)のコールアウトはそれぞれ親コールアウトが開閉すると一緒に開閉される
  • 多彩な種類と色

以下がその例です。

Info コールアウト
> [!info]+Info
> Info コールアウトの例 (デフォルトで開く)
Info コールアウトの例 (デフォルトで開く)

Caution コールアウト
> [!warning]-warning
> Caution コールアウトの例 (デフォルトで閉じる)
Caution コールアウトの例 (デフォルトで閉じる)

Check コールアウト
> [!check] Check
> Check コールアウトの例 (開閉なし)
Check
Check コールアウトの例 (開閉なし)

入れ子コールアウト
> [!note]+
> コールアウト
>
> > [!info]+
> > 子コールアウト
> >
> > > [!warning]+
> > > 孫コールアウト

> [!question]+
> Question!
>
> > [!failure]+
> > Failure!
> >
> > > [!check]+
> > > Check!
> > >
> > > > [!quote]+
> > > > Quote!
コールアウト
子コールアウト
孫コールアウト
Question!
Failure!
Check!
Quote!
OEmbed(埋め込み)

エディタに直接ペーストされた URL は、埋め込み対応をしているものであればメディアフレームに変身します。

自前で Canva、YouTube、そして Google Slides の変換処理を追加し、それ以外のメディアはOEmbedの規格に応じて適宜メディアフレームに変身するように Remark プラグインを自作しました。
ちなみに裏ではunfurlというライブラリが頑張ってくれていて、上記の規格に必要なメディアのメタデータを URL 経由で取得しています。

Canva
https://www.canva.com/design/DAGKC41Tjws/zSEw1hvi9r30o5KiF96AGA/view
 Canva埋め込みイメージ
Canva埋め込みイメージ
YouTube
https://www.youtube.com/watch?v=dsTXcSeAZq8
 YouTube埋め込みイメージ
YouTube埋め込みイメージ
Spotify
https://open.spotify.com/intl-ja/track/04z1fwsw1gXI8HWQpoETa9?si=862c02d3e52a49b0

Google Slides
https://docs.google.com/presentation/d/1CbeSiVYta0VTuENQ-25OLcIV5vK8pkcBJ-8DfKqlE2I/edit?usp=sharing
 Google Slides埋め込みイメージ
Google Slides埋め込みイメージ
埋め込みはしばしばページのロードやSEOに悪影響を及ぼします。
これは、ホストから膨大なJSコードや最適化されていない画像などをそのまま使用するからで、現にこのページのPageSpeed Insightsスコアは悲惨なことになっていました。
そういう訳で、一部画像にすり替えました。実装したはいいものの、今後埋め込み機能はあまり使わないかも...
リンクカード

OEmbed 非対応の裸リンクは、フォールバックとして全てリンクカード形式に変身します。先述の OEmbed に加えて、この部分も Remark プラグインを自作しました。

ここでもunfurlが裏で暗躍していて、リンクカードに表示するサイト名やディスクリプション、OG 画像などを取得してくれています。

https://younagi.dev

お問い合わせフォーム

デモ: お問い合わせフォーム
デモ: お問い合わせフォーム

最も頭を悩ませたのがお問い合わせフォームです。幾度かの大きな仕様変更を経て、最終的に SolidJS + TSX で実装する形に着地しました。

とはいえ、お問い合わせフォームを丸ごとクライアントサイドで読み込ませると1、ページロード時に甚大なレイアウトシフトが発生します。これでは SEO に悪いということで、同じくクライアントサイドでレンダリングされるモーダルの中に押し込むという荒業に着地しました2

簡単のため、フォーム制御にModular Forms、UI にKobalte、バリデーションチェックにValibotなど有力なライブラリを使用しました。
ポイントは下記の通りです。

  • クライアントサイドのバリデーション
    • Modular Forms x Valibot x Cloudflare Turnstile
  • サーバーサイドのバリデーション
    • Valibot x Cloudflare Turnstile
  • 送信ボタンのラベルが送信中に変わる
    • ユーザがわかり易いように
    • Modular Forms のフォーム制御機能のおかげで簡単に実装できました
  • 送信完了後、サンクスページにリダイレクトされる

ボット対策にはどうしても Google reCAPTCHA ではなくCloudflare Turnstileを導入したく、この点はかなりこだわりました。(あの車のパズルに費やした合計時間を考えると...しんどい) 無料ですし、他の Cloudflare サービスとの連携も考えると、私にとっては一択でした。

Cloudflare

Cloudflare Pages

Next.js に執心だった頃、 私のお気に入りのホスティングサービスは Vercel でしたが、Astro への移行に際して、見直す必要があると感じました。そしてたどり着きました。Cloudflare Pagesという結論に。

どうして Cloudflare Pages?

Cloudflare Pages をホスティング先に選んだのには次のような理由がありました。

  • 寛大なフリープラン
    • 月あたりの帯域幅上限が無制限という素晴らしさ (2024 年 5 月 9 日現在)
  • 非常に高速なデプロイ
  • ドメイン用のカスタム E メールアドレスが取得できる
    • お問い合わせフォーム用に使用している
    • フォームから送信されたお問い合わせはこのカスタム E メールアドレス経由で私用のメールアドレスに届くため、プライベートアドレスを公に晒さず済む(もちろん、返信時にもメールの送信元をカスタム E メールアドレスに設定できます)
  • 独自ドメイン取得可能 (有料)
    • https://younagi.dev

フリープランでも当サイトのような小規模プロジェクトならば十分運用可能だと思います。この点が最も魅力的でした。

Cloudflare D1

バックエンドについては、Cloudflare D1をデータベースとして使用しています。Pages と同様に寛大なフリープランが魅力的だったのですが、ここでは深くは触れません。

いいねボタン
デモ: いいねボタン
デモ: いいねボタン

D1 データベースとDrizzle ORM(Object-relational Mapping)を連携させ、よくあるいいねボタンを実装しました。ブログ記事の各ページに設置しています。

仕組みを簡潔に述べます。

  • その記事のいいねの合計数が表示される
    • ページのロードに際して、いいね用の API エンドポイントに GET リクエストが送られ、その時点でのいいねの合計数を取得、ユーザがいいねをしているか否かはデータベースに登録されているクッキーのセッション ID カラムを照合して判断する
    • データベースには、セッション ID とともに該当ページの判別に必要な情報も登録される
  • ボタン押下時、ユーザがまだ「いいね」をしていなければカウントが 1 つ増え、そうでない場合は 1 つ減る
    • API エンドポイントに POST リクエストが送られ、リクエストデータがデータベースに登録される

Front Matter CMS

デモ: 記事を新規作成する
デモ: 記事を新規作成する

フレームワーク選定と並んで、CMS(Content Management System)選定にもかなりの時間を要します。私は Front Matter CMS に惹かれました。理由は次の通りです。

  • ローカルで記事の執筆や保存ができる
  • マークダウン/MDX 形式

VS Code の拡張機能であり、ローカルで動くという点で、他のヘッドレス CMS とは一線を画しています。つまり、コードの修正や記事の執筆、サイトのデプロイなどの作業が VS Code エディタで一元化できます。 これは特に開発者にとって大きなメリットとなるのではないでしょうか。

初期セットアップについてはFront Matter 公式ドキュメントをご覧あれ。手順通りに進めると、Astro のcontentフォルダを自動で検知し、その中身に応じてfrontmatter.jsonに各コンテンツの項目の定義などを書き出してくれます。基本、Front Matter CMS の設定はプロジェクトルート直下に置かれたこのファイルで管理します。

Front Matter CMS のカスタマイズ

frontmatter.jsonのカスタマイズについて、幾つか嬉しいポイントがあります。

  • ディレクトリによる多言語化との互換性
    • コンテンツのディレクトリ内にある言語用のディレクトリを指定して、Front Matter CMS の設定と紐づけができます(私の場合、今のところenja)
    • こうして、VSCode のコマンドから記事を作成する際に、書きたい言語を選べるようになった。記事は自動で指定のディレクトリに作成される
  • Astro の Content Collections との互換性(コンテンツ、データタイプ両方)
    • Front Matter CMS にもコンテンツ、データの概念があり、それぞれを Astro のコンテンツ、データと関連させられる
    • ブログやニュースなどの「コンテンツ」はコンテンツとして、翻訳文字列やサイトメタデータ、ブログのカテゴリ・タグなどの「データ」はデータとして扱う

結び

この長い開発の旅の途中で、沢山の情報源にあたりました。これらの先達なしに今回の旅の成功はあり得なかったでしょう。以下に特によく訪問したウェブサイトやページを載せます。有益な情報やコードをありがとうございました。

プロジェクト全般:

全文検索:

お問い合わせフォーム:

DB のセットアップ:

Front Matter CMS のセットアップ:

スタイリング:


  1. 他の JS フレームワーク(私のケースでは SolidJS)を Astro のページやコンポーネント内で使用し、かつそれが JavaScript による処理を含む場合、それがどのフレームワークのコンポーネントなのか、Astro のコンパイラに明示的に教えてあげる必要があります。(クライアントディレクティブなるものをコンポーネント呼び出し箇所に追記すれば OK)これらフレームワークは、Astro の仕様によって、JS を含む時点でクライアントサイドでのレンダリング扱いとなり、故にコンパイラはビルド時やサーバーサイドで全く関知できないからです。お問い合わせフォームはユーザインタラクティブであり、簡素なものでなければ大抵は JavaScript が必要になるため、クライアントサイドのコンポーネントになります。

  2. 前提として、Astro のページを含め、.astroのファイルはサーバーサイドでレンダリングされます。その上にクライアントで読み込まれるコンポーネントを置くと、ページ表示後にドサッと降ってくるような見た目の挙動になります。これが小さいコンポーネントならレイアウトシフトは深刻にはなりませんが、お問い合わせフォームのような巨大な部品だと被害は甚大になります。


励みになります

◀ トップに戻る ページ上部へ ▲