WIEL.

WIEL について

公開日:

WIEL は、日々の開発で学んだことを書きため、自分の手で試すための実験場を兼ねた個人ブログです。 ブログサービスを使わず、コンテンツ管理から検索、アクセス計測まであえて実装していることが、まさに WIEL の目的そのものです。

この記事では、このサイトがどう動いているのかを紹介します。

サイトの全体像

構成はシンプルで、記事は完全に静的、動的なのは「いいね」と「アクセス計測」だけです。

MDX で書いた記事を Next.js (Static Exports) で HTML として書き出し、Cloudflare Workers の静的アセットとして配信しています。 いいねやアクセス計測を受け持つ API は、同じ Worker の中に Hono で実装しています。

ブラウザ ─▶ Cloudflare Workers
             ├─ /api/* : Hono → D1 / Workers Analytics Engine
             └─ *      : 静的アセット (Next.js Static Exports)

Workers 内の共存を成立させているのが run_worker_first の 1 行です。 /api/* だけ Worker が実行され、それ以外は静的アセットが返ります。

wrangler.jsonc
{
	"main": "src/api/index.ts",
	"assets": {
		"directory": "./out",
		"binding": "ASSETS",
		"not_found_handling": "404-page",
		"run_worker_first": ["/api/*"]
	}
}

リポジトリ構成は、この役割分担を反映しつつシンプルにしています。

  • contents/
    • posts/
      • about-wiel/
        • index.mdx
  • src/
    • api/
    • app/
    • components/
    • lib/
  • db/
  • scripts/

主な技術スタックは次のとおりです。

領域技術
フロントエンドNext.js (Static Exports)
コンテンツMDX + Velite
検索Pagefind
関連記事Ruri-v3 + Transformers.js
APIHono
ホスティングCloudflare Workers
データベースCloudflare D1
アクセス計測Workers Analytics Engine
ORMDrizzle ORM
スタイリングTailwind CSS
Lint / FormatBiome

ビルドパイプライン

「静的に完結できるものはビルド時に済ませる」がこのサイトの方針です。 ビルドは 6 段のパイプラインになっています。

  1. Velite — MDX の検証とコンパイル

    記事のフロントマター (メタデータ) を Zod スキーマで検証し、本文を React コンポーネントにコンパイルします。 タイトルの長さ、slug の一意性、日付形式などの違反はここでエラーになります。

  2. 関連記事の計算

    日本語埋め込みモデル (Ruri-v3) をローカル実行し、全記事ペアのコサイン類似度から関連記事 TOP 3 を計算します。 詳細は後述。

  3. リンクカード用 OGP の取得

    記事中の <OgpCard> が参照する外部 URL の OGP メタデータを取得し、キャッシュします。 取得に失敗した場合はプレーンなカード表示にフォールバックし、外部要因によってビルドが止まらないようにしています。

  4. OG 画像の生成

    記事ごとの OG 画像を satori + sharp で生成します。 絵文字タイルとタイトルを配置した、記事ヒーローと同じ構図のカードです。

    この記事の OG 画像

  5. Next Build — 静的エクスポート

    全ページを HTML として書き出します。 Step 1 でコンパイルされた React コンポーネントは、ここでレンダリングされて HTML になります。

  6. Pagefind — 検索インデックスの生成

    書き出した HTML をクロールして、静的な全文検索インデックスを生成します。

実は、はじめから「ビルド時に静的化」を決めていたわけではありません。 色々やりたいけど遅くなるのは嫌、を繰り返していたら、結果的にビルドに詰め込む作りになりました。

なお、記事の埋め込みや OGP などをキャッシュしておくことで、ビルド時間を抑えるようにしています。

これで、ランタイムに残る動的処理は「いいねの読み書き」と「アクセス計測イベントの受け口」、そして「1 日 1 回のアクセス計測集計 cron」だけになりました。

MDX で記事を書く

記事は contents/posts/<SLUG>/index.mdx に書きます。

コールアウト (Info / Tip / Warn / Danger)、手順リスト (Steps)、ファイルツリー (FileTree)、折りたたみ (Details) などのカスタムコンポーネントは、import 文なしでそのまま記事に書けます。 コードブロックは Shiki でハイライトされ、ファイル名や行ハイライトの指定にも対応しています。

意識したポイントは 2 つです。

原則クライアント JS ゼロ

装飾を目的とした Steps / FileTree / Details には JavaScript を使用していません。 Steps の番号バッジと接続線は CSS カウンターと疑似要素、FileTree のフォルダー判定は CSS の :has()Details はネイティブの details 要素で実装しています。

クライアント JavaScript が必要なのは、画像クリックで開くライトボックスと、リンクや記事カードのソフトナビゲーションだけです。 ライトボックスはネイティブの dialog 要素なので、Esc での閉じる動作やフォーカストラップはブラウザの標準機能に任せています。

間違いはビルドで落とす

slug の重複、フロントマターの型違反、存在しない slug を指定した内部リンクカード (LinkCard) などはエラーとなり、ビルドパイプラインが停止します。

記事内で使える MDX 記法の一覧は、自分用のリファレンス記事としてまとめています。

MDX スタイルガイド

このサイトで使える MDX 記法のリファレンスです。書き方に迷ったときのサンプルとして、またコンポーネントやスタイル変更時の回帰テストとして利用しています。

機能 1 ― 記事検索

ヘッダー内の虫眼鏡アイコンから使える検索機能に、サーバーはありません。

ビルド Step 6 で Pagefind が生成した検索インデックスから、ブラウザが必要な部分だけを取ってきます。 内部では Rust で開発された検索エンジンが Wasm として実行され、検索はブラウザの中で完結します。

PagefindPagefind is a fully static search library that aims to perform well on large sites, while using as little of your users’ bandwidth as possible, and without hosting any infrastructure.pagefind.app

インデックスの対象は、設定ファイルではなく data-pagefind-bodydata-pagefind-ignore などの JSX 属性として宣言します。 記事本文だけを検索対象にし、後述する関連記事や引用テキストを除外する、といった制御をコンポーネント側に書けます。

<html lang="ja"> を見て日本語としてインデックスされるので、日本語で検索できます。 キーワード入力中はインクリメンタルに上位 8 件を表示し、Esc で閉じられます。

機能 2 ― 関連記事

記事ページの下部に表示される関連記事は、手動指定や単純なタグ一致ではなく、日本語埋め込みモデル (Ruri-v3) による意味的類似度で決定しています。

cl-nagoya/ruri-v3-310m · Hugging FaceWe’re on a journey to advance and democratize artificial intelligence through open source and open science.huggingface.co

ただし、ベクトルデータベースや外部 API は利用していません。 ビルド時に Node.js 上の Transformers.js で推論を行い、出力された埋め込みベクトル同士の類似度からスコアリングしています。

スコアは、タイトル・本文・タグの 3 要素を組み合わせて計算しています。

score = α × cos(タイトル + 説明文) + β × cos(本文) + min(上限, γ × 共有タグ数)

cos は記事間の (埋め込みベクトル同士の) コサイン類似度です。 α / β / γ は各要素の重み係数で、タグ一致は上限付きのボーナスとして加算しています。

テキストのハッシュ値をキーに埋め込みベクトルをキャッシュし、ビルドのたびにすべての埋め込みを再計算しないようにしています。

機能 3 ― アクセス計測

アクセス計測は外部サービスを使用せず、Workers Analytics Engine を使って実装しています。

収集するイベントは、次の 5 つです。

  • ページビュー
  • 関連記事の表示
  • 関連記事のクリック
  • 検索の実行
  • 検索結果のクリック

検索や関連記事の効果測定・チューニングを目的としているので、これ以上は今のところ必要ないと判断しています。

毎日 1 回、直近 28 日分を集計して Cloudflare D1 にスナップショットを保存し、Metrics ページ で公開しています。 集計結果は誰でも確認できます。

設計では、次の 2 点を重視しました。

計測の匿名性

クッキーやセッション ID は使用せず、IP アドレスや User-Agent も保存しません。 リファラーはホスト名だけに縮約します。

検索クエリも原則として送信せず、記録するのはヒット件数が 0 件だったものだけです (次に書く記事のヒントにするため)。 また、記事への「いいね」の連打防止には、IP アドレスを HMAC ハッシュ化した擬似 ID を用いています。

集計の透明性

API では比率などの派生値を計算せず、分子と分母をそのまま返しています。 表示に必要な割合はダッシュボード側で計算し、分母が 0 の場合は「–」と表示します。

また、「値が 0」と「データがない」は区別して扱っています。 スパークラインはデータのない日で線を切り、棒グラフも両者を描き分けています。

関連記事のカードには計算時の類似度スコアを埋め込んでいるので、ダッシュボードではスコア帯ごとのクリック率など、集計の背景も確認できます。

ちょっとしたこだわり

ページ遷移には View Transitions API を使っていますが、制御レイヤーは自前です。

React 標準の仕組みだけでは、同じ記事が同一ページに複数並んだとき (例えば、記事が LATEST と POPULAR の両方に表示されたとき)、共有要素のモーフ元を一意に決められません。 また、Next.js の Static Exports 構成では、ブラウザバックで遷移アニメーションがそもそも走りません。

この 2 つを解決するため、遷移の瞬間にモーフ対象 1 要素だけに view-transition-name を付与して名前の衝突を防ぎ、クリックとブラウザバックの両方を自前で View Transition にラップするレイヤーを挟んでいます。 これにより、検索結果や記事カードの絵文字が、記事ページのヒーローへつながるようにモーフします。

他には、描画前に実行されるインラインスクリプトによって、リロード時にテーマ (ライト / ダーク) が切り替わる際のちらつきを防いでいます。 また、記事内のリンクは、内部リンクであれば「プリフェッチ付きのソフトナビゲーション」、外部リンクであれば「新規タブ + 外部リンクアイコン」に自動でレンダリングするなど、細かな使い勝手にも配慮しています。

おわりに

正直、ここまで自分で作る予定はありませんでした。 楽しかったので良しですが、このサイトを構成する機能は、既製品を使えば簡単に実現できるものばかりです。 それでも自作すると、Web API の仕様による制約やアクセス計測の設計など、使うだけでは出会えない問題にぶつかります。 そこで学んだことが、また次の学びにつながる — そういう循環で、このサイトを育てたいなーと思います。

これまで、技術やノウハウを公開している多くの先人たちに何度も助けられました。 同じように、ここで発信したコンテンツが、誰かの学びや挑戦のお役に立てば嬉しいです。