公式サイトをリニューアルしました
liquid1224 / 華力発電所のオフィシャルサイトを全面リニューアル。Gatsby 5 からの移行で何が変わったか、技術的な背景とともに解説します。
華力発電所 / liquid1224 のオフィシャルサイトを全面リニューアルしました。
旧サイト(liquid1224/FloppOfficial)は Gatsby 5 ベースで構築していました。
今回 Astro 6 へ全面移行し、デザインシステムの再設計と各種機能の強化を行いました。
技術スタック比較
| 項目 | 旧サイト | 新サイト |
|---|---|---|
| フレームワーク | Gatsby 5 | Astro 6 |
| React | 18.2 | 19.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.js | 18 | 22 |
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 します。また、ポインタ/フォーカスが乗った時点でリンク先を先読み(プリフェッチ)するため、遷移は体感上ほぼ瞬時です(ネットワークの状態にもよりますが)。
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.mjs で vite.ssr.external と optimizeDeps.exclude
に追加する必要があります。
今後の予定
サイトの基盤は整ったので、今後はコンテンツの充実に注力していきます。新しい作品情報やブログ記事の更新をお楽しみに。