---
title: "ポートフォリオサイト（work.223n.tech）をAstroで構築した記録 | C#もぐもぐ"
description: "Astro 5 + AdminLTE 4でポートフォリオサイトを構築し、Cloudflare Pagesにデプロイした作業の記録です。"
url: "https://blog.223n.tech/posts/234/"
---

# ポートフォリオサイト（work.223n.tech）をAstroで構築した記録

2026年4月13日

[Astro](https://blog.223n.tech/tags/Astro/) [WordPress](https://blog.223n.tech/tags/WordPress/) [Cloudflare](https://blog.223n.tech/tags/Cloudflare/) [TypeScript](https://blog.223n.tech/tags/TypeScript/) [Program](https://blog.223n.tech/tags/Program/)

## はじめに

[前回の記事](https://blog.223n.tech/posts/233/)では、WordPressで運営していたブログをAstro + Cloudflare Pagesへ移行した記録を書きました。

今回は、同じ技術スタックをベースにポートフォリオサイト「work.223n.tech」を構築した記録です。

ブログ移行で得たAstroの知見を活かしつつ、ポートフォリオに必要な実績一覧・スキル表示・タイムラインといった機能を実装しました。

## 構築の動機

ポートフォリオサイトを構築した理由は、次の3点です。

1. 実績やスキルセットを体系的にまとめる場所がなかった
2. ブログ移行でAstroの扱いに慣れたため、同じ構成で効率よく構築できると考えた
3. 静的サイトとして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のコンテンツコレクションで管理しています。

ブログのスキーマをベースに、ポートフォリオ向けのフィールドを追加しました。

```typescript
// 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`ディレクティブで動的に切り替えています。

```astro
<a
  href="/"
  class:list={[
    "nav-link",
    { active: currentPath === "/" }
  ]}
>
```

## 各ページの実装

### トップページ

トップページでは、最新の実績3件とスキルサマリーを表示しています。

```typescript
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カラムで配置しました。

### 実績一覧ページ

実績一覧では、すべての公開済み実績を日付の新しい順に表示します。

サイドバーには実績の総数とタグごとの件数を表示しています。タグの集計は、次のように行っています。

```typescript
const tagCounts: Record<string, number> = {};
allWorks.forEach((work) => {
  work.data.tags?.forEach((tag) => {
    tagCounts[tag] = (tagCounts[tag] || 0) + 1;
  });
});
```

### 実績詳細ページ

実績詳細は`[slug].astro`で動的ルーティングしています。

`getStaticPaths()`で全実績のパスを生成し、各実績の前後ナビゲーションも実装しました。

```typescript
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年から現在までのキャリアを時系列で表示
- **資格一覧**: 取得した資格をリスト表示
- **対応可能なサービス**: 提供できるサービスの概要

習熟度のプログレスバーは、レベルに応じて色を動的に変更しています。

```typescript
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をインポートしたうえで、カスタム変数を定義しています。

```scss
: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/`ディレクトリに配置しています。

処理の流れはブログ移行時と同じです。

1. WordPressエクスポートXMLをパースし、記事データを抽出
2. HTML/GutenbergブロックをMarkdownに変換
3. Astro互換のfrontmatter付きMarkdownファイルとして出力
4. 画像をダウンロードしてWebP形式に変換

ブログ向けスクリプトとの変更点は、frontmatterのスキーマを`works`コレクションに合わせた程度です。

変換スクリプトを汎用的に作っておいたことで、再利用がスムーズに進みました。

## ビルド最適化

ビルド最適化の設定も、ブログとほぼ同じ内容を`astro.config.mjs`に記述しています。

### チャンク分割

```javascript
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で設定しています。

```javascript
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

```toml
name = "work-223n-tech"
compatibility_date = "2026-01-01"
pages_build_output_dir = "dist"

[vars]
PUBLIC_SITE_URL = "https://work.223n.tech/"
```

### 手動デプロイ

```bash
pnpm build
npx wrangler pages deploy dist --project-name=work-223n-tech
```

GitHub連携による自動デプロイも設定しており、`git push`だけで本番環境に反映されます。

## 開発環境

開発環境はDevContainerを使用しています。

### Dockerfile

```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を自動インストールする設定にしています。

保存時の自動フォーマットも有効化しており、コードスタイルの統一を自動化しています。

### 主要なコマンド

```bash
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サイト目以降の立ち上げが格段に楽になります。

## 参考サイト

- [Astro公式ドキュメント](https://docs.astro.build/)
- [Astro コンテンツコレクション](https://docs.astro.build/ja/guides/content-collections/)
- [AdminLTE 4](https://github.com/ColorlibHQ/AdminLTE)
- [Bootstrap 5.3](https://getbootstrap.com/docs/5.3/)
- [Cloudflare Pagesドキュメント](https://developers.cloudflare.com/pages/)
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)
- [sharp（Node.js画像処理ライブラリ）](https://sharp.pixelplumbing.com/)
- [Viteビルド設定](https://vite.dev/config/build-options)
- [Noto Sans JP](https://fonts.google.com/noto/specimen/Noto+Sans+JP)

[記事一覧に戻る](https://blog.223n.tech/posts/)
