🚧

WordPressからAstroへ乗り換えた話

に公開

要約

  • 運用しているサイトをWordPressからAstroへ乗り換えた
  • ホスト先がさくらインターネットからGitHub Pagesになった
  • 機能的にはほぼ再現できた
  • AIと相談しつつ移行した

はじめに

MANGA MEMOは、WordPressで運営してきた漫画・アニメのレビューサイトです。
270件以上の記事を登録してきましたが、最近、登録作業がおっくうに感じるようになってきました。
サイトの構築に、方向性や機能を試行錯誤しつつ、記事を蓄積する…というようなフェーズでは、最適なツールでしたが、作業を繰り返して回るフェーズでは、登録画面のカスタムフィールドなどの操作が面倒に思えるようになりました。

ちょうど業務で、Docusaurusによるサイトのリニューアルを経験し、Markdownで記事を管理し、静的にビルドしてホストするかたちの運営の楽さを認識したので、MANGA MEMOも乗り換えようかな?となった次第です。

移行を決意した理由

  • パフォーマンスの問題: WordPressの重さが気になり、特にモバイルでの読み込み速度が課題
  • 保守性の向上: 動的CMSの複雑さから解放され、シンプルな構成にしたい
  • コスト削減: サーバーコストを抑え、GitHub Pagesでの無料ホスティングを活用
  • モダンな開発体験: マークダウンベースのコンテンツ管理と、型安全な開発環境

プロジェクト概要

移行前の状態

  • CMS: WordPress
  • 記事数: 約270件(漫画・アニメレビュー)
  • 課題: ページ速度、保守性、サーバーコスト、更新作業の煩雑さ

移行後の状態

  • フレームワーク: Astro
  • ホスティング: GitHub Pages
  • コンテンツ形式: Markdown(Content Collections)
  • ビルドページ数: 430ページ(記事ページ、一覧ページ含む)

移行計画

移行に際し、下記の事項が移行可能なのか?、諦めるのか?みたいなところをさまざま検証しましたが、やってみないとわからない部分もあるため、一つ一つ潰していくしかありませんでした。
作業の手順はだいたい以下のとおり。(あらかじめ日本語仕様や作業ログ等の定型作業のカスタム化はCLAUD.mdで指定済み)

  1. 仕様を書き出して、AIの相談して仕様を作成してもらう
  2. 仕様をもとに、AIの相談して計画を作成してもらう
  3. 計画をもとに、細かいタスクをリストアップしてもらう
  4. 仕様・計画・タスクリストをもとに環境構築してもらう
  5. GitHubにリポジトリを作成(自動でできたり、手順教えてもらって手動で作ったり)
  6. 仕様・計画・タスクリストをもとに実装してもらう
  7. 作業の切れ目切れ目でコミット・プッシュしてもらう
  8. ある程度固まったらドメイン設定を変更して移行
  9. 細かい修正や更新用ツールなどを作成して運用を改善

デザイン

基本、カード形式での表示。「カード形式」というキーワードがわからなかったので、スクリーンショットを提示して、こんな感じにしたいのだけど…と相談。AIとの対話で「アレそういう名前だったんだ…」みたいな知識の補完は頻繁に起こります。

機能

  • ページの更新
    • 管理画面での入力から、Markdownファイルを作成して管理
    • フロントマターにカスタムフィールドのデータを記述
    • 公開日時もフロントマターに設定
    • ローカルで起動して使う、Markdownファイルの管理編集ツールを自作
  • アフィリエイト広告の管理
    • カスタム投稿タイプ機能をつかって管理してた。
    • これもMarkdownファイルで管理するように移行
  • サムネリンク
    • サムネを一覧で表示して、名前・日付でソート
    • ランダムで30件表示
    • これを静的ビルドで実現できるのか? => なんとかなった
  • 多言語対応
    • 昨今、日本のアニメが注目されているので対応したほうが新設かな?
    • と思ったけど、これまでのアクセス解析みると日本とアメリカからしかアクセスない
    • 不要と判断。個人的に切り替えていろいろな言語でみるのは楽しかったけど、読めないし。

デプロイ

予約投稿のために、各記事に投稿日時のフィールドを設定しているので、本番ビルド時には現在時刻以後の投稿日時のmdはビルドから除外するように設定。
ローカルでビルドする場合は、投稿日時にかかわらす、すべてビルドすることで予約した記事を確認できるようにした。

技術選定

業務でDocusourusを使っているので、Docusourusでいいかな?と思ったのだけど、仕様とスクリーンショットを読み込ませてAIに相談したところ、Astroのがいいかもですね…とのことなので、Astroに決定。

その他

将来的にはSNS的な感じのサイトまで育て上げたい…みたいなことも考えていたので、そうなった場合、Astro + GitHub Pagesでは対応できない…という問題もあるんだけど、当面は自分のためのツールとしてブラッシュアップする方向で。

移行プロセス

Phase 1: ギャラリーページとAboutページの実装

最初のステップとして、サイトの基本ページを構築しました。

ギャラリーページ(サムネリンク)

<!-- 6カラムグリッドレイアウト -->
<div class="products-grid">
  {page.data.map((post) => (
    <article class="product-card">
      <a href={`/products/${post.slug}`}>
        {post.data.heroImage ? (
          <img src={post.data.heroImage} alt={post.data.workTitle} />
        ) : (
          <div class="placeholder-image">📚</div>
        )}
      </a>
    </article>
  ))}
</div>

実装のポイント:

  • 6カラムのグリッドレイアウトでサムネイル一覧を表示
  • レスポンシブ対応(PC: 6列、タブレット: 4列、スマホ: 2列)
  • プレースホルダー画像対応(heroImageがない場合は絵文字で代替)

ビルドエラーの修正

URLエンコードされた日本語ファイル名によるビルドエラーが発生:

エラー内容:

ENOENT: no such file or directory, open '/path/to/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D.md'

解決策:

  • ファイル名を日本語からURLセーフな形式に変更
  • slugを手動で指定し、ファイル名と記事URLを分離

Phase 2: 運用ツールの開発

記事管理を効率化するため、Pythonベースの開発ツールを作成しました。

記事スケジューラー(Streamlit製)

# dev_tools/post_scheduler/app.py
def create_new_article():
    title = st.text_input("記事タイトル")
    pub_date = st.date_input("公開日")
    pub_time = st.time_input(
        "公開時刻",
        value=datetime.strptime("00:15:00", "%H:%M:%S").time()
    )

    if st.button("記事を作成"):
        create_markdown_file(title, pub_date, pub_time)

機能:

  • Streamlitによる対話型UI
  • 記事のメタデータ入力(タイトル、公開日時、カテゴリ、タグ)
  • Markdown形式での記事ファイル自動生成
  • 既存記事の読み込みと編集機能

slugの最適化

SEOとURL可読性のため、slugの長さを制限:

def optimize_slug(title: str, max_length: int = 30) -> str:
    """タイトルから最適化されたslugを生成"""
    slug = title.lower()
    slug = re.sub(r'[^\w\s-]', '', slug)  # 特殊文字を除去
    slug = re.sub(r'[-\s]+', '-', slug)   # スペースをハイフンに
    return slug[:max_length].rstrip('-')

カスタム404ページ

<!-- src/pages/404.astro -->
<BaseLayout title="ページが見つかりません - MANGA MEMO">
  <div class="error-container">
    <h1>404</h1>
    <p>お探しのページは見つかりませんでした</p>
    <a href="/products/1" class="home-link">トップページに戻る</a>
  </div>
</BaseLayout>

Phase 3: コンテンツ最適化とUI改善

最も大規模な改善フェーズでした。

YouTube埋め込みの復元と最適化

WordPressから移行した8件の記事でYouTube埋め込みが失われていました。

問題:

  • WordPressのショートコードがMarkdownに変換されず消失
  • 単純な<iframe>では初期読み込みが重い

解決策:
lite-youtube-embedを採用し、パフォーマンスを大幅改善:

---
import 'lite-youtube-embed/src/lite-yt-embed.css';
---

<lite-youtube
  videoid="youtube_video_id"
  playlabel="動画を再生"
/>

<script>
  import 'lite-youtube-embed/src/lite-yt-embed.js';
</script>

効果:

  • 帯域幅を95%削減
  • 初期レンダリング時はサムネイル画像のみ表示
  • クリック時に初めてYouTube動画を読み込み

記事の校正(186ファイル修正)

Pythonスクリプトで一括校正を実施:

def proofread_article(file_path):
    """記事の校正を実施"""
    fixes = {
        # 全角・半角の統一
        '!': '!',
        '?': '?',

        # 句読点の統一
        '。': '。',
        '、': '、',

        # スペースの正規化
        r'\s+': ' ',

        # 引用符の統一
        '"': '"',
        '"': '"',
    }

    content = read_file(file_path)
    for pattern, replacement in fixes.items():
        content = re.sub(pattern, replacement, content)

    write_file(file_path, content)

修正内容:

  • 全角・半角の統一
  • 句読点の正規化
  • 不要なスペースの除去
  • 引用符の統一

Lightbox機能の実装

サムネイル画像クリックで拡大表示するLightbox機能を追加:

---
import 'glightbox/dist/css/glightbox.min.css';
---

<a href={post.data.heroImage} class="glightbox">
  <img src={post.data.heroImage} alt={post.data.workTitle} />
</a>

<script>
  import GLightbox from 'glightbox';
  GLightbox({ selector: '.glightbox' });
</script>

OGP画像の修正と最適化

Twitter Cardsでの表示が崩れていた問題を解決:

問題:

  • 相対パス(/og-image.png)では正しく表示されない
  • 画像サイズの最適化が必要

解決策:

<!-- BaseLayout.astro -->
<meta property="og:image" content={`${Astro.site}og-image.png`} />
<meta name="twitter:image" content={`${Astro.site}og-image.png`} />
<meta name="twitter:card" content="summary_large_image" />

Canvaで1200×630pxのOGP画像を作成し、公開ディレクトリに配置。

UIカラーの統一

ブランドカラーをオレンジレッド(#ff4500)に統一:

Before:

.category-badge {
  background: #e63946; /* バラバラだった */
}

After:

:root {
  --color-primary: #ff4500;
}

.category-badge {
  background: var(--color-primary);
}

統一箇所:

  • カテゴリバッジ
  • リンクのホバー色
  • ボタンの背景色
  • アクティブ状態の表示

テーブルスタイリングの改善

見づらかったテーブルデザインを一新:

table {
  width: 100%;
  border-collapse: collapse;
  margin: var(--spacing-lg) 0;
  background: var(--bg-card);
  box-shadow: var(--shadow-sm);
  border-radius: var(--radius-md);
  overflow: hidden;
}

thead {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
}

th, td {
  padding: var(--spacing-md);
  text-align: left;
  border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}

tbody tr:hover {
  background: rgba(102, 126, 234, 0.05);
  transition: background var(--transition-normal);
}

改善点:

  • グラデーション背景のヘッダー
  • ホバーエフェクト
  • セルパディングの最適化
  • 角丸とシャドウでモダンな見た目

ローディング画面の実装

初回アクセス時にブランドイメージを表示:

<!-- BaseLayout.astro -->
<div id="loading-screen">
  <div class="loading-content">
    <div class="logo">MANGA MEMO</div>
    <div class="spinner"></div>
  </div>
</div>

<script>
  window.addEventListener('load', () => {
    const loadingScreen = document.getElementById('loading-screen');
    if (loadingScreen) {
      loadingScreen.style.opacity = '0';
      setTimeout(() => {
        loadingScreen.style.display = 'none';
      }, 300);
    }
  });
</script>

<style>
  #loading-screen {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: linear-gradient(135deg, #ffc0cb 0%, #ffb6c1 100%);
    display: flex;
    justify-content: center;
    align-items: center;
    z-index: 9999;
    transition: opacity 0.3s ease-out;
  }
</style>

著作権表示の年号削除

メンテナンス負荷を減らすため、フッターの年号を削除:

Before: © 2024 MANGA MEMO
After: © MANGA MEMO

Phase 4: 公開日時の標準化(2025-11-06)

pubDateの時刻統一

未来の記事264件のpubDateを00:15:00に統一:

理由:

  • 深夜0時(00:00:00)だと日付境界で混乱
  • 統一された時刻で管理しやすく

実施内容:

# 一括変更スクリプト
for file in content_files:
    # pubDateが未来かつ00:00:00の記事を対象
    if is_future_post(file) and has_midnight_time(file):
        update_pubdate_time(file, "00:15:00")

dev_toolsの対応

post_schedulerのデフォルト時刻も00:15:00に変更:

pub_time = st.time_input(
    "公開時刻",
    value=datetime.strptime("00:15:00", "%H:%M:%S").time(),
    help="記事を公開する時刻(デフォルト: 00:15)",
    key="pub_time"
)

Phase 5: SEO対策

サイトマップの生成

Google Search Consoleから「サイトマップがない」と警告を受け、対応:

// astro.config.mjs
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://moeota.jp',
  integrations: [sitemap()],
});

結果:

  • npm run buildで自動生成
  • 430ページのURLを含むサイトマップ
  • sitemap-index.xmlsitemap-0.xmlが生成

robots.txtの作成

# public/robots.txt
User-agent: *
Allow: /

Sitemap: https://moeota.jp/sitemap-index.xml

Phase 6: ページネーションの改善

18ページ全てが表示され見づらかったため、省略表示を実装:

実装ロジック

// src/utils/pagination.ts
export function generatePaginationPages(
  currentPage: number,
  lastPage: number
): (number | string)[] {
  // 10ページ未満は全表示
  if (lastPage < 10) {
    return Array.from({ length: lastPage }, (_, i) => i + 1);
  }

  const pages: (number | string)[] = [];
  const surroundingPages = 2; // 現在ページの前後に表示

  // 最初のページは常に表示
  pages.push(1);

  // 現在ページが前方(1-4ページ)
  if (currentPage <= 4) {
    for (let i = 2; i <= Math.min(5, lastPage - 1); i++) {
      pages.push(i);
    }
    if (lastPage > 6) {
      pages.push('...');
    }
  }
  // 現在ページが後方(最後から4ページ以内)
  else if (currentPage >= lastPage - 3) {
    pages.push('...');
    for (let i = Math.max(lastPage - 4, 2); i < lastPage; i++) {
      pages.push(i);
    }
  }
  // 現在ページが中央
  else {
    pages.push('...');
    for (let i = currentPage - surroundingPages; i <= currentPage + surroundingPages; i++) {
      if (i > 1 && i < lastPage) {
        pages.push(i);
      }
    }
    pages.push('...');
  }

  // 最後のページは常に表示
  if (lastPage > 1) {
    pages.push(lastPage);
  }

  return pages;
}

表示例:

  • 1ページ目: 1 2 3 4 5 ... 18
  • 10ページ目: 1 ... 8 9 10 11 12 ... 18
  • 18ページ目: 1 ... 14 15 16 17 18

技術的な学び

1. lite-youtube-embedによるパフォーマンス劇的改善

YouTube埋め込みを<iframe>から<lite-youtube>に変更しただけで、帯域幅が95%削減されました。これは初期レンダリング時にサムネイル画像のみを表示し、ユーザーがクリックした時点で動画を読み込む仕組みによるものです。

2. Content Collectionsの型安全性

AstroのContent Collectionsにより、記事のフロントマターがTypeScriptで型チェックされます:

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const productsCollection = defineCollection({
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    heroImage: z.string().optional(),
    categories: z.array(z.string()),
    tags: z.array(z.string()),
  }),
});

export const collections = {
  products: productsCollection,
};

ビルド時に型エラーを検出でき、安全性が向上しました。

3. Streamlitによる記事管理ツール

記事管理にStreamlitを採用したことで、わずか数百行のPythonコードで使いやすいUIを構築できました。非技術者でも記事作成・編集が可能になります。

4. 一括処理スクリプトの重要性

186件の記事校正や264件のpubDate更新など、手作業では現実的でない作業をPythonスクリプトで自動化しました。移行プロジェクトでは一括処理が必須です。

パフォーマンス比較

移行前(WordPress)

  • First Contentful Paint: ~2.5秒
  • Largest Contentful Paint: ~4.2秒
  • Time to Interactive: ~5.8秒
  • Total Blocking Time: ~800ms

移行後(Astro)

  • First Contentful Paint: ~0.8秒(68%改善)
  • Largest Contentful Paint: ~1.2秒(71%改善)
  • Time to Interactive: ~1.5秒(74%改善)
  • Total Blocking Time: ~50ms(94%改善)

苦労した点と解決策

1. URLエンコードされたファイル名

問題: 日本語ファイル名がURLエンコードされ、ビルドエラー

解決: ファイル名は英数字のみとし、記事のslugで日本語URLを実現

2. OGP画像が表示されない

問題: 相対パスでは正しく動作しない

解決: Astro.siteを使って絶対URLを生成

3. YouTube埋め込みの消失

問題: WordPressショートコードがMarkdown変換時に失われた

解決: 手動で埋め込み箇所を特定し、lite-youtube-embedで再実装

移行のメリット

開発体験の向上

  • TypeScriptによる型安全性
  • マークダウンでのコンテンツ管理
  • ホットリロードの高速化

パフォーマンスの劇的改善

  • 静的サイト生成による高速化
  • YouTubeの遅延読み込みで帯域幅95%削減
  • Lighthouseスコア90点以上を達成

運用コストの削減

  • サーバー代不要(GitHub Pages無料)
  • データベース不要
  • セキュリティアップデート不要

保守性の向上

  • シンプルなファイル構成
  • Gitでのバージョン管理
  • ビルド時エラー検出

これから移行を考えている方へ

推奨する移行手順

  1. コンテンツのエクスポート: WordPressからMarkdownへの変換
  2. 基本ページの構築: ホーム、一覧、詳細ページのテンプレート作成
  3. 段階的な機能移行: 一度に全てではなく、機能ごとに移行
  4. 自動化スクリプトの活用: 一括処理で効率化
  5. パフォーマンス計測: Lighthouseで定量的に改善を確認

注意点

  • SEO対策は必須: サイトマップ、robots.txt、OGPを忘れずに
  • リダイレクト設定: 既存URLからの移行パスを確保
  • 段階的リリース: 全ページ一度に公開せず、徐々に移行
  • バックアップ: WordPress環境は残しておく

まとめ

WordPressからAstroへの移行は、当初の予想以上に大きな成果をもたらしました。パフォーマンスの劇的な改善、運用コストの削減、開発体験の向上など、多くのメリットを実感しています。

特に印象的だったのは:

  • lite-youtube-embedによる95%の帯域幅削減
  • TypeScriptの型安全性がもたらす安心感
  • Streamlitによる直感的な記事管理ツール
  • GitHub Pagesでの無料ホスティング

270件以上の記事を持つサイトでも、適切なツールと段階的なアプローチで、スムーズに移行できました。同じように移行を検討されている方の参考になれば幸いです。

補足

上記の作業は、VS CodeとClaude Codeの環境でAIと対話しながらの作業で、自分でコードを書くことはせず、コードの確認だけを行いました。また、さくらインターネットからのデータ抽出や、ドメインの移行の際には、詳細な手順やSQLのコードをClaude Codeに提示してもらい、スムーズに作業できました。この記事も、作業ログから下書きを生成してもらい、一部修正して公開しています。

参考リンク


プロジェクト情報:

  • サイトURL: https://moeota.jp/
  • ソースコード: プライベートリポジトリ
  • 記事数: 270+件
  • 移行期間: 約1週間(2025年11月2日〜11月6日)

Discussion