はじめに
前回の記事では、WordPressで運営していたブログをAstro + Cloudflare Pagesへ移行した記録を書きました。
今回は、同じ技術スタックをベースにポートフォリオサイト「work.223n.tech」を構築した記録です。
ブログ移行で得たAstroの知見を活かしつつ、ポートフォリオに必要な実績一覧・スキル表示・タイムラインといった機能を実装しました。
構築の動機
ポートフォリオサイトを構築した理由は、次の3点です。
- 実績やスキルセットを体系的にまとめる場所がなかった
- ブログ移行でAstroの扱いに慣れたため、同じ構成で効率よく構築できると考えた
- 静的サイトとしてCloudflare Pagesに載せれば、運用コストがほぼゼロで済む
ブログと同様に、Claudeと相談しながら構築を進めました。
技術スタック
技術スタックは、下表のとおりです。ブログ(blog.223n.tech)とほぼ同じ構成を採用しています。
| 項目 | 技術 |
|---|---|
| フレームワーク | Astro 5(output: "static") |
| コンテンツ形式 | Markdown |
| UIフレームワーク | AdminLTE 4(rc4)+ Bootstrap 5.3 |
| スタイル | SCSS(sass) |
| パッケージマネージャー | pnpm 10 |
| ホスティング | Cloudflare Pages |
| デプロイ | wrangler / GitHub連携 |
| 画像最適化 | sharp |
| 型チェック | TypeScript + astro check |
ブログ構築時に選定した技術をそのまま流用したため、技術選定にかかる時間を大幅に削減できました。
プロジェクト構成
ディレクトリ構成は、次のようになっています。
src/
├── content/
│ ├── config.ts # コンテンツコレクションのスキーマ定義
│ └── works/ # 実績ファイル(Markdown)
│ ├── 35.md
│ ├── 55.md
│ └── ...
├── layouts/
│ └── AdminLayout.astro # 共通レイアウト(OGP・メタ情報含む)
├── pages/
│ ├── index.astro # トップページ
│ ├── [slug].astro # 実績詳細(動的ルーティング)
│ ├── works/
│ │ └── index.astro # 実績一覧
│ ├── skills/
│ │ └── index.astro # スキル・経歴
│ └── contact/
│ └── index.astro # お問い合わせ
├── styles/
│ ├── global.scss # グローバルスタイル
│ └── post-content.scss # 記事本文のスタイル
├── types/
│ └── index.ts # 型定義
└── utils/
└── date.ts # 日付フォーマット
ブログとの大きな違いは、posts/の代わりにworks/コレクションを使っている点と、タグ別一覧やRSSフィードがない点です。
代わりに、スキルページやお問い合わせページなど、ポートフォリオに特化したページ構成になっています。
コンテンツコレクション
実績データはAstroのコンテンツコレクションで管理しています。
ブログのスキーマをベースに、ポートフォリオ向けのフィールドを追加しました。
// src/content/config.ts
import { defineCollection, z } from "astro:content";
export const worksSchema = z.object({
title: z.string(),
date: z.coerce.date(),
tags: z.array(z.string()).optional().default([]),
draft: z.boolean().optional().default(false),
description: z.string().optional(),
slug: z.string().optional(),
thumbnail: z.string().optional(),
period: z.string().optional(),
role: z.string().optional(),
technologies: z.array(z.string()).optional().default([]),
});
const works = defineCollection({
type: "content",
schema: worksSchema,
});
export const collections = { works };
ブログのpostsSchemaとの違いは、次の4つのフィールドです。
slug: カスタムURLパスの指定用(省略時はファイル名がスラッグになる)thumbnail: 実績のサムネイル画像パスperiod: プロジェクトの実施期間role: 担当した役割technologies: 使用した技術の一覧
dateフィールドにはz.coerce.date()を使い、文字列から日付型への自動変換を行っています。
レイアウト
共通レイアウトAdminLayout.astroは、ブログと同じAdminLTE 4ベースの構成です。
主な構成要素は、次のとおりです。
- ヘッダーナビバー: サイト名、ブログ・GitHubへのリンク
- サイドバー: ホーム、実績、スキル、お問い合わせのナビゲーション
- フッター: コピーライト表示
- OGPメタタグ: Open Graph、Twitter Card対応
- Cloudflare Web Analytics: アクセス解析用ビーコン
サイドバーのアクティブ状態は、現在のパスをAstro.url.pathnameから取得し、class:listディレクティブで動的に切り替えています。
<a
href="/"
class:list={[
"nav-link",
{ active: currentPath === "/" }
]}
>
各ページの実装
トップページ
トップページでは、最新の実績3件とスキルサマリーを表示しています。
const allWorks = await getCollection("works", ({ data }) => !data.draft);
const latestWorks = allWorks
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 3);
実績はカード形式で並べ、サムネイル画像が「ある場合」は表示し、「ない場合」はデフォルト画像を使用しています。
スキルカテゴリはバッジとして一覧表示し、提供可能なサービスを2カラムで配置しました。
実績一覧ページ
実績一覧では、すべての公開済み実績を日付の新しい順に表示します。
サイドバーには実績の総数とタグごとの件数を表示しています。タグの集計は、次のように行っています。
const tagCounts: Record<string, number> = {};
allWorks.forEach((work) => {
work.data.tags?.forEach((tag) => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
});
実績詳細ページ
実績詳細は[slug].astroで動的ルーティングしています。
getStaticPaths()で全実績のパスを生成し、各実績の前後ナビゲーションも実装しました。
export async function getStaticPaths() {
const works = await getCollection("works", ({ data }) => !data.draft);
const sorted = works.sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
return sorted.map((work, index) => ({
params: { slug: work.data.slug || work.slug },
props: {
work,
prevWork: sorted[index - 1] || null,
nextWork: sorted[index + 1] || null,
},
}));
}
スラッグはdata.slug(frontmatterで指定した値)を優先し、未指定の場合はファイル名をスラッグとして使用します。
詳細ページでは使用技術をスキルバッジとして表示し、本文はMDXでレンダリングしています。
スキルページ
スキルページは、このサイト独自のページです。次の4つのセクションで構成しています。
- スキルカテゴリ: 各スキルの習熟度をプログレスバーで表示
- 経歴タイムライン: 2010年から現在までのキャリアを時系列で表示
- 資格一覧: 取得した資格をリスト表示
- 対応可能なサービス: 提供できるサービスの概要
習熟度のプログレスバーは、レベルに応じて色を動的に変更しています。
function getLevelColor(level: number): string {
if (level >= 80) return "success";
if (level >= 60) return "primary";
return "secondary";
}
80以上は緑(success)、60以上は青(primary)、それ未満はグレー(secondary)で表示されます。
タイムラインはCSSの疑似要素で縦線と丸印を描画し、各エントリを時系列順に配置しました。
お問い合わせページ
お問い合わせページでは、提供サービスの概要、連絡方法(メール・GitHub)、FAQを掲載しています。
プロフィール情報はサイドバーに配置し、SNSリンクをアイコンボタンで表示しています。
スタイリング
グローバルスタイル
グローバルスタイル(global.scss)では、AdminLTE 4とBootstrap 5をインポートしたうえで、カスタム変数を定義しています。
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
}
body {
font-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN",
"Hiragino Sans", Meiryo, sans-serif;
}
フォントには日本語Webフォント「Noto Sans JP」を指定し、フォールバックとして主要な日本語フォントを設定しています。
主なカスタムスタイル
ポートフォリオ特有のスタイルとして、次のコンポーネントを実装しました。
- 実績カード: ホバー時にtransformアニメーションで浮き上がる効果
- スキルバッジ: 3色のカラーバリエーション(success、primary、secondary)
- タイムライン: 疑似要素による縦線と丸印の描画
- SNSリンク: 円形ボタンでホバー時にscale変形
記事本文スタイル
記事本文のスタイル(post-content.scss)は、ブログとほぼ同じものを使っています。
行間を1.8に設定し、見出しには下線アクセントを付けています。
コードブロックはダーク背景(#1e1e1e)で表示し、テーブルには交互の行色を適用しています。
WordPress記事の変換
<work.223n.tech>にも、WordPress時代のコンテンツがありました。
ブログ移行時に作成した変換スクリプト(wp2md_convert.py、download-images.py)をそのまま流用し、convert-wp/ディレクトリに配置しています。
処理の流れはブログ移行時と同じです。
- WordPressエクスポートXMLをパースし、記事データを抽出
- HTML/GutenbergブロックをMarkdownに変換
- Astro互換のfrontmatter付きMarkdownファイルとして出力
- 画像をダウンロードしてWebP形式に変換
ブログ向けスクリプトとの変更点は、frontmatterのスキーマをworksコレクションに合わせた程度です。
変換スクリプトを汎用的に作っておいたことで、再利用がスムーズに進みました。
ビルド最適化
ビルド最適化の設定も、ブログとほぼ同じ内容をastro.config.mjsに記述しています。
チャンク分割
manualChunks: (id) => {
if (id.includes("node_modules")) {
if (id.includes("admin-lte")) return "vendor-adminlte";
if (id.includes("bootstrap")) return "vendor-bootstrap";
return "vendor";
}
},
圧縮
gzipとBrotliの事前圧縮を、閾値10KBで設定しています。
plugins: [
compress({ algorithm: "gzip", ext: ".gz", threshold: 10240 }),
compress({
algorithm: "brotliCompress",
ext: ".br",
threshold: 10240,
}),
],
その他の最適化
- terserによる
console.logとdebuggerの自動削除 - ソースマップの無効化
- CSSコード分割の有効化
- アセットファイルの種類別ディレクトリ配置(
assets/css/、assets/js/、assets/images/、assets/fonts/)
これらの設定はブログから丸ごとコピーしたものです。同じ構成にしておくことで、今後の保守も統一的に行えます。
デプロイ
Cloudflare Pagesへのデプロイは、ブログと同じ方法です。
wrangler.toml
name = "work-223n-tech"
compatibility_date = "2026-01-01"
pages_build_output_dir = "dist"
[vars]
PUBLIC_SITE_URL = "https://work.223n.tech/"
手動デプロイ
pnpm build
npx wrangler pages deploy dist --project-name=work-223n-tech
GitHub連携による自動デプロイも設定しており、git pushだけで本番環境に反映されます。
開発環境
開発環境はDevContainerを使用しています。
Dockerfile
FROM node:20-alpine
RUN apk add --no-cache \
git openssh bash curl \
python3 py3-pip py3-pillow py3-requests py3-lxml
RUN corepack enable && corepack prepare pnpm@latest --activate
EXPOSE 4321
Node.js 20 Alpineをベースに、WordPress変換スクリプト用のPython環境も含めています。
devcontainer.json
VS Codeの拡張機能として、Astro、Prettier、ESLint、Tailwind CSSを自動インストールする設定にしています。
保存時の自動フォーマットも有効化しており、コードスタイルの統一を自動化しています。
主要なコマンド
pnpm dev # 開発サーバー(port 4321)
pnpm build # 本番ビルド
pnpm check # 型チェック(astro check + tsc --noEmit)
pnpm format # Prettierフォーマット
ブログとの共通点・相違点
ブログ(blog.223n.tech)とポートフォリオ(work.223n.tech)の構成を比較すると、次のようになります。
| 項目 | blog.223n.tech | work.223n.tech |
|---|---|---|
| コレクション | posts | works |
| スキーマ | 基本的なブログ用 | period/role/技術追加 |
| ページ構成 | 記事一覧/タグ/RSS | 実績/スキル/お問い合わせ |
| URL構造 | /posts/{slug}/ | /{slug}/ |
| AdminLTEレイアウト | 共通 | 共通 |
| ビルド設定 | 共通 | 共通 |
| DevContainer | 共通 | 共通 |
| デプロイ先 | Cloudflare Pages | Cloudflare Pages |
共通部分が多いため、ブログを先に構築しておいたことで、ポートフォリオの構築は効率よく進められました。
おわりに
ブログ移行で確立したAstro + AdminLTE 4 + Cloudflare Pagesの構成を活かし、ポートフォリオサイトを構築しました。
変換スクリプトやビルド設定を再利用できたことで、ポートフォリオ固有の機能(スキル表示、タイムライン、実績詳細など)の実装に集中できました。
同じ技術スタックで複数サイトを運用する場合、最初のサイト構築を丁寧に行っておくと、2サイト目以降の立ち上げが格段に楽になります。