はじめに

前回の記事では、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のコンテンツコレクションで管理しています。

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

// 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.pydownload-images.py)をそのまま流用し、convert-wp/ディレクトリに配置しています。

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

  1. WordPressエクスポートXMLをパースし、記事データを抽出
  2. HTML/GutenbergブロックをMarkdownに変換
  3. Astro互換のfrontmatter付きMarkdownファイルとして出力
  4. 画像をダウンロードして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.logdebuggerの自動削除
  • ソースマップの無効化
  • 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.techwork.223n.tech
コレクションpostsworks
スキーマ基本的なブログ用period/role/技術追加
ページ構成記事一覧/タグ/RSS実績/スキル/お問い合わせ
URL構造/posts/{slug}//{slug}/
AdminLTEレイアウト共通共通
ビルド設定共通共通
DevContainer共通共通
デプロイ先Cloudflare PagesCloudflare Pages

共通部分が多いため、ブログを先に構築しておいたことで、ポートフォリオの構築は効率よく進められました。

おわりに

ブログ移行で確立したAstro + AdminLTE 4 + Cloudflare Pagesの構成を活かし、ポートフォリオサイトを構築しました。

変換スクリプトやビルド設定を再利用できたことで、ポートフォリオ固有の機能(スキル表示、タイムライン、実績詳細など)の実装に集中できました。

同じ技術スタックで複数サイトを運用する場合、最初のサイト構築を丁寧に行っておくと、2サイト目以降の立ち上げが格段に楽になります。

参考サイト