公式サイトをリニューアルしました

liquid1224 / 華力発電所のオフィシャルサイトを全面リニューアル。Gatsby 5 からの移行で何が変わったか、技術的な背景とともに解説します。

華力発電所 / liquid1224 のオフィシャルサイトを全面リニューアルしました。

旧サイト(liquid1224/FloppOfficial)は Gatsby 5 ベースで構築していました。

今回 Astro 6 へ全面移行し、デザインシステムの再設計と各種機能の強化を行いました。


技術スタック比較

項目旧サイト新サイト
フレームワークGatsby 5Astro 6
React18.219.2
スタイリングvanilla-extract(CSS-in-TS)Astro Scoped CSS + CSS Variables
データ取得GraphQL(gatsby-transformer-remark)Content Collections + Zod
ページ遷移Gatsby Routerカスタム soft-navigate
デザイン言語ニューモーフィズムグラスモーフィズム
OG 画像共通PNG + Blog各記事に手動で用意Satori + resvg-js でビルド時自動生成
3D 背景なしThree.js
アナリティクスGoogle Analytics(gtag)Cloudflare Web Analytics(予定)
Node.js1822

Gatsby から Astro へ — データ層の刷新

旧サイトでは作品情報・ブログ記事を Markdown で管理し、gatsby-node.ts の GraphQL クエリでページを生成していました。

// 旧: gatsby-node.ts
export const createPages: GatsbyNode["createPages"] = async ({ graphql, actions: { createPage } }) => {
  const result = await graphql<Queries.AllWorksSlugQuery>(`
    query AllWorksSlug {
      allMarkdownRemark(filter: { fileAbsolutePath: { regex: "/works/" } }) {
        nodes {
          frontmatter {
            slug
          }
          id
        }
      }
    }
  `);
  result.data?.allMarkdownRemark.nodes.forEach((node) => {
    createPage({
      path: `/works/${node.frontmatter?.slug}`,
      component: path.resolve(`./src/templates/work.tsx`),
      context: { id: node.id },
    });
  });
};

GraphQL 自体は強力ですが、フロントマターの型は自動生成に頼っていたため、スキーマ変更時の追跡コストが高い状態でした。

新サイトでは Astro の Content Collections + Zod で管理しています。Zodスキーマを src/content.config.ts に一元定義することで、型はビルド時に検証されるため、誤ったフロントマターはビルドエラーとして即座に検出されます。

// 新: src/content.config.ts
const works = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/contents/works" }),
  schema: ({ image }) =>
    z.object({
      slug: z.string(),
      title: z.string(),
      format: z.enum(["album", "single", "compilation"]),
      project: z.enum(["flopp", "uma", "collaborations", "trap", "tnr", "other"]),
      date: z.coerce.date(),
      idSpotify: z.string().nullish(),
      idAppleMusic: z.string().nullish(),
      // Bandcamp / YouTube / LINE MUSIC / Amazon Music も同様
      featured: z.boolean().default(false),
    }),
});

image() ヘルパーで参照するジャケット画像は Astro の最適化パイプラインを通り、AVIF/WebP 変換とレイジーロードが自動的に適用されます。


スタイリング — vanilla-extract からネイティブ CSS 変数へ

旧サイトは vanilla-extract を採用し、デザイントークンを TypeScript のオブジェクトとして管理していました。

// 旧: src/styles/global.tsx
export const globalParams = {
  backgroundLight: "#e3e3e3",
  backgroundDark: "#333333",
  accentColor: "#B03B3B",
  shadowLight: "10px 10px 8px #a4a4a4, -10px -10px 8px #ffffff",
  shadowDark: "10px 10px 8px #252525, -10px -10px 8px #414141",
  borderRadius: "10px",
  // …
};

スタイルファイルが about.css.ts / blog.css.ts / work.css.ts と機能ごとに分割されており、参照時は import { globalParams } from "../styles/global" と TS の import でした。vanilla-extract 自体は型安全なゼロランタイム CSS ツールとして優秀ですが、box-shadow の値を TS 文字列で扱うのは補完が効きにくく、デザイン変更時の影響範囲も把握しづらい状態でした。

新サイトでは CSS カスタムプロパティ に一本化し、src/styles/global.css だけを参照すれば全トークンが揃う構成にしました。

/* 新: src/styles/global.css(抜粋) */
:root {
  --cl-bg: #333333;
  --cl-txt: #ffffff;
  --cl-accent: #a93838;

  /* テキスト階層(コントラスト比を明示) */
  --cl-txt-faint: rgba(255, 255, 255, 0.53); /* 4.0:1 — WCAG AAA for large */
  --cl-txt-muted: rgba(255, 255, 255, 0.63); /* 5.1:1 — WCAG AA */

  /* グラスモーフィズム用アルファ */
  --gl-bg-1: rgba(255, 255, 255, 0.04);
  --gl-bg-2: rgba(255, 255, 255, 0.06);
  --gl-bg-3: rgba(255, 255, 255, 0.08);

  /* 角丸スケール */
  --r-sm: 10px;
  --r-md: 14px;
  --r-lg: 16px;
  --r-xl: 18px;
  --r-pill: 20px;
}

デザイン言語 — ニューモーフィズムからグラスモーフィズムへ

旧サイトはライト・ダーク両モードに対応したニューモーフィズムデザインで、box-shadow の内外方向を切り替えることで「浮き上がり / 押し込み」の立体感を表現していました。しかしページ数が増えるにつれてページが重く感じられるようになったり、先述したCSSの保守性が低かったりして不満が溜まっていたので、今回はよりシンプルで、ブランドイメージにも合わせやすいグラスモーフィズムへ刷新しています。

背景色は旧サイトのダークモードと同じ #333333 に固定し、backdrop-filter: blur() による半透明レイヤーで奥行きを出しています。


カスタム soft-navigate — View Transition API を使わない理由

Gatsby Router の代替として Astro 標準の ClientRouter(View Transition API ベース)を試しましたが、本サイトでは採用しませんでした。理由は グラスモーフィズムのフリッカー です。

View Transition API はページ遷移時にコンポジットレイヤーのスナップショットを取ります。このとき backdrop-filter を持つ要素が平坦化されるため、ヘッダーや各カードのブラーが消えてただの透明になってしまいます。

この問題を解決するために src/scripts/soft-navigate.ts として独自実装しました。<main> の innerHTML だけを差し替え、Header・Footerなどの共通コンポーネントは DOM に残し続けます。

// soft-navigate.ts(コアロジック抜粋)
async function navigate(href: string, { push, back = false }) {
  const html = await fetch(href).then((r) => r.text());
  const doc = new DOMParser().parseFromString(html, "text/html");

  // <main> だけ差し替える — Header / Footer / 背景は触らない
  const main = document.querySelector("main")!;
  main.innerHTML = doc.querySelector("main")!.innerHTML;

  syncHead(doc); // <title> / OGP / JSON-LD を更新
  await loadNewStyles(doc); // ページスコープの CSS を追加読み込み

  document.dispatchEvent(new CustomEvent("softnav:ready"));
}

ページごとの再初期化が必要なスクリプトは softnav:ready イベントを listen します。また、ポインタ/フォーカスが乗った時点でリンク先を先読み(プリフェッチ)するため、遷移は体感上ほぼ瞬時です(ネットワークの状態にもよりますが)。

Chromium では Speculation Rules API によりリンクがプリレンダリングされます。prefers-reduced-motion が有効なときはフェードアニメーションをスキップします。

OG 画像の自動生成

旧サイトでは全ページ共通の OG 画像を 1 枚だけ用意していましたが、SNS シェア時にページの内容が伝わりにくいため、各ページにいちいちOG画像を用意していました。それが面倒でサイトの更新を怠るようになっていたので、今回は Satori + @resvg/resvg-js でビルド時に各ページの OG 画像(1200×630 px)を自動生成する仕組みを構築しています。

// src/lib/og.tsx(ブログ記事用 OG 画像の生成・抜粋)
export async function renderOgPng(input: OgCardInput): Promise<Uint8Array> {
  const opts = await buildSatoriOpts(input.title);
  // タイトルの文字数に応じてフォントサイズを調整
  const titleFontSize = input.title.length > 24 ? 60 : input.title.length > 16 ? 70 : 82;

  const node = (
    <div style={{ background: COLORS.bg, color: TEXT_WHITE }}>
      <div style={{ height: "4px", background: COLORS.accent }} /> {/* アクセントバー */}
      <div style={{ fontSize: `${titleFontSize}px`, fontWeight: 700 }}>{input.title}</div>
      {/* ワードマーク SVG をインライン展開 */}
      <svg viewBox="0 0 5300 1200" width="500" height="114">
        {WORDMARK_PATHS.map((p, i) => (
          <path key={i} {...p} />
        ))}
      </svg>
    </div>
  );

  const svg = await satori(node, opts);
  return new Resvg(svg).render().asPng();
}

Satori が JSX を SVG に変換し、resvg が PNG にラスタライズします。フォントは Noto Sans JP を動的にサブセット取得しているため、日本語タイトルもちゃんど描画されます。

Tip

@resvg/resvg-js は Node.js ネイティブバインディングを含むため、Vite の SSR バンドルに巻き込まれるとビルドが壊れます。astro.config.mjsvite.ssr.externaloptimizeDeps.exclude に追加する必要があります。


今後の予定

サイトの基盤は整ったので、今後はコンテンツの充実に注力していきます。新しい作品情報やブログ記事の更新をお楽しみに。

← Blog 一覧