ゲーム開発
Unity
UnrealEngine
C++
ゲーム数学
ゲームAI
サウンド
アニメーション
GBDK
制作日記
3DCG
Houdini
Blender
USD
グラフィックス
テクノロジ
ツール開発
フロントエンド関連
サーバサイド関連
ソフトウェア設計
ハードウェア関連
おすすめ技術書
音楽
DTM
楽器・機材
ピアノ
その他
都会のエレキベア
ラーメン日記
四コマ漫画
おすすめアイテム
おもしろコラム
  • ゲーム開発
    • Unity
    • UnrealEngine
    • C++
    • ゲーム数学
    • ゲームAI
    • サウンド
    • アニメーション
    • GBDK
    • 制作日記
  • 3DCG
    • Houdini
    • Blender
    • USD
    • グラフィックス
  • テクノロジ
    • ツール開発
    • フロントエンド関連
    • サーバサイド関連
    • ソフトウェア設計
    • ハードウェア関連
    • おすすめ技術書
  • 音楽
    • DTM
    • 楽器・機材
    • ピアノ
  • その他
    • 都会のエレキベア
    • ラーメン日記
    • 四コマ漫画
    • おすすめアイテム
    • おもしろコラム
  1. ホーム
  2. 20231231_01_wp_to_next

【Next.js】第二回 WordPressブログをNext.jsに移行する 〜WordPressデータの移行・表示編〜

Next.jsReactJavaScriptWordPress関連フロントエンド関連SSGStorybookEmotionNode.jsp5.js
2023-12-31

マイケル
マイケル
みなさんこんにちは! マイケルです!
エレキベア
エレキベア
こんにちクマ〜〜
マイケル
マイケル
この度、WordPress製だった当ブログをNext.jsで作り直しました! 前回は環境構築周りに触れましたので、今回は実際にWordPressのデータを移行して表示するまでの流れについて紹介します。
【都会のエレキベア】ブログを大幅リニューアル!WordPressからNext.jsに移行するまでの流れをまとめる
2024-01-01
【Next.js】第一回 WordPressブログをNext.jsに移行する 〜全体設計、環境構築編〜
2023-12-31
【Next.js】第二回 WordPressブログをNext.jsに移行する 〜WordPressデータの移行・表示編〜
2023-12-31
【Next.js】第三回 WordPressブログをNext.jsに移行する 〜Markdown執筆環境構築編〜
2023-12-31
【Next.js】第四回 WordPressブログをNext.jsに移行する 〜サーバ移行・SEO・広告設定編〜
2023-12-31
エレキベア
エレキベア
環境構築も中々大変だったクマね
マイケル
マイケル
第二回ということで、「WordPressデータの移行・表示」編です! 取得したデータを使って、下記の2つのページを作るところまで解説します!
20231231_wp_to_next_01
▲記事一覧ページ

20231231_wp_to_next_02
▲記事ページ

20231231_next_app_design_gif_01
▲検索処理やカテゴリによるフィルタにも対応する

エレキベア
エレキベア
今回がNext.js環境移行の肝になりそうクマね
マイケル
マイケル
また、コードについても公開できる範囲でGitHubに上げているので、こちらも合わせてご参考ください!

GitHub - nextjs-elekibear-blog-scripts

エレキベア
エレキベア
中々なボリュームクマ・・・

参考書籍

マイケル
マイケル
Next.jsでの開発を進めるにあたり、下記書籍を参考にさせていただきました!

TypeScriptとReact/Next.jsでつくる実践Webアプリケーション

エレキベア
エレキベア
最近のWeb技術をキャッチアップしたい方にはおすすめクマね
マイケル
マイケル
また、WordPressの学習については下記書籍がおすすめです! WordPressは初心者本が多い中、こちらはアーキテクチャ等エンジニア目線で書かれているため非常に分かりやすいです。

エンジニアのためのWordPress開発入門

エレキベア
エレキベア
WordPressは結構癖があるクマからね

WordPress画像ファイルのサーバ移行

※追記※
Netlifyサーバでの運用では、Bandwidth(帯域幅)の領域が100GBギリギリとなってしまったため、 現在はVPSサーバを用意してそちらに格納する運用にしています。
参考として双方の方法について記載しています。

Netlifyへのアップロード(旧手順)

マイケル
マイケル
まずは画像ファイル類の移行についてです。 前回記事に書いた通りアプリケーションとは別のサーバで管理するため、 [WordPressサーバ]/public_html/wp-content/uploads 配下のファイルを全てNetlifyサーバへアップロードします。
マイケル
マイケル
アップロードはNetlifyCLI経由で行いました。 下記のように作成したサイトと紐づけることでアップロードできます。
# 初回のみ) CLIをインストール
npm install -g netlify-cli
netlify -v

# ログイン
netlify login

# プロジェクトとの紐付け
# Site Name or Site ID で紐づける
netlify link

# デプロイ
# 指定したフォルダ内の構成と完全に同期される
netlify deploy --prod -d [フォルダパス]

NetlifyCLI - get-started

マイケル
マイケル
記事内の画像リンクをアップロードしたURLに合わせるため、 後ほどデータをエクスポートした後に一括置換で対応します。
 
URL
置換前
[WordPressサーバ]/public_html/wp-content/uploads
置換後
[NetlifyサーバURL]/wp-content/uploads
エレキベア
エレキベア
これで画像サーバの準備は完了クマね

VPSサーバへのアップロード(新手順)

マイケル
マイケル
上記のNetlifyへ格納する運用では帯域幅が耐えきれなかったため、独自でVPSサーバを契約して格納する方式にしました。 ファイル同期は下記のようにrsyncコマンドを使用しています。
# 同期情報
LOCAL_CONTENTS_PATH=[ローカルの画像ファイル格納パス]
SERVER_CONTENTS_PATH=[サーバの画像ファイル格納パス]
SYNC_DIR=[同期するフォルダ名]

SSH_KEY=[SSH鍵パス]
SSH_USER=[SSHユーザ名]
SSH_HOST=[SSHホスト名]

# local => server
rsync -avz --rsh="ssh -i $SSH_KEY" "$LOCAL_CONTENTS_PATH$SYNC_DIR" "$SSH_USER@$SSH_HOST:$SERVER_CONTENTS_PATH"

# server => local
rsync -avz --rsh="ssh -i $SSH_KEY" "$SSH_USER@$SSH_HOST:$SERVER_CONTENTS_PATH$SYNC_DIR" "$LOCAL_CONTENTS_PATH"
エレキベア
エレキベア
やはり全てNetlifyでは厳しかったクマか・・・

WordPressDBデータのエクスポート

新形式で必要なデータの策定

マイケル
マイケル
次はWordPressのDBデータの移行についてです。 前回記事に書いた内容通り、下記のようにマスタデータはCSVで管理する方針にしましたが 全てのデータが必要なわけではないため必要なデータの策定から行います。
20231231_next_app_design_01
▲WordPressデータはCSVで管理する

エレキベア
エレキベア
WordPressのデータは結構ごちゃごちゃしてるのもあるクマから これを気に整理したいクマね
マイケル
マイケル
WordPressのDB構成については下記に記載されています。 柔軟に拡張できる形式になっている分、個人ブログだと無駄が多いというのも確かです。

WordPress - Database Desctiption

マイケル
マイケル
この中で当ブログで使用したいデータは主に ・カテゴリとタグ関連 ・記事データ関連 になるため、この辺りを中心に整理していきます。
カテゴリとタグ
マイケル
マイケル
カテゴリ、タグの管理について使用しているテーブルは ・wp_terms ・wp_term_taxonomy ・wp_term_relationships の三つです。
WordPressのデータ構成
テーブル名
概要
wp_terms
分類の定義
wp_term_taxonomy
分類の種類(カテゴリ、タグ)の指定
wp_term_relationships
分類と投稿データの紐付け

wp_termsテーブル構成

wp_term_taxonomyテーブル構成

wp_term_relationshipsテーブル構成

マイケル
マイケル
この中でwp_term_taxonomyのデータについてはまとめられそうだったので、 ・mst_terms ・mst_term_relationships の二つのデータに下記形式で整理することにしました。
新形式のデータ構成
マスタ名
概要
mst_terms
分類(カテゴリorタグ)の定義
mst_term_relationships
分類と投稿データの紐付け
mst_terms
カラム名
概要
id
分類ID
name
名前
slug
スラッグ
taxonomy
分類種別(カテゴリ or タグ)
parent
親ID
SELECT
    wp_terms.term_id as id,
    wp_terms.name,
    wp_terms.slug,
    wp_term_taxonomy.taxonomy,
    wp_term_taxonomy.parent
FROM
    wp_terms
    INNER JOIN
        wp_term_taxonomy
    ON  wp_terms.term_id = wp_term_taxonomy.term_id
WHERE
    taxonomy IN('category', 'post_tag')
mst_term_relationships
カラム名
概要
post_id
投稿データのID
term_id
分類ID
SELECT
    object_id as post_id,
    term_taxonomy_id as term_id
FROM
    wp_term_relationships
エレキベア
エレキベア
基本はmst_termsで管理して、記事データとの紐付けのみmst_term_relationshipsを使用するクマね この方がシンプルで分かりやすそうクマ
投稿データ
マイケル
マイケル
そして記事の投稿データについても ・mst_posts の一つのデータとして集約することにしました。 WordPress、新形式のデータ構成はそれぞれ下記のようになります。
WordPressのデータ構成

wp_postsテーブル構成

新形式のデータ構成
mst_posts
カラム名
概要
id
投稿ID
post_date
投稿日
post_title
タイトル
post_name
投稿名(slug)
post_modified
更新日
post_type
投稿種別
featured_image
アイキャッチ画像
SELECT
    wp_posts_A.id,
    wp_posts_A.post_date,
    wp_posts_A.post_title,
    wp_posts_A.post_name,
    wp_posts_A.post_modified,
    wp_posts_A.post_type,
    wp_postmeta.meta_value,
    wp_posts_B.guid as featured_image
FROM
    wp_posts as wp_posts_A
    LEFT JOIN
        wp_postmeta
    ON  wp_posts_A.id = wp_postmeta.post_id
    AND wp_postmeta.meta_key = '_thumbnail_id'
    LEFT JOIN
        wp_posts as wp_posts_B
    ON  wp_posts_B.id = wp_postmeta.meta_value
WHERE
    wp_posts_A.post_type IN('post', 'page')
AND wp_posts_A.post_status = 'publish'
エレキベア
エレキベア
元々metaデータ等で分かれていた部分も集約したのクマね
マイケル
マイケル
一点、実際の記事内容(post_content)については、 ・一つ一つの容量がある ・WordPress内で変換された後の記事データを使用したい といった理由から、別途APIでアクセスしてテキストファイル形式で書き出すことにしました。
マイケル
マイケル
今回は下記のような形で WPGraphQL 経由で取得したデータを出力することにします。
# posts情報取得
query AllPosts {
    posts(first: 1000) {
        edges {
        node {
            title
            slug
            content
        }
        }
    }
}

# pages情報取得
query AllPages {
    pages(first: 1000) {
        edges {
        node {
            title
            slug
            content
        }
        }
    }
}
エレキベア
エレキベア
ショートコードとかWordPress内で変換されるデータがあるクマね 確かにその辺りも含めてHTMLに変換されたデータを使用した方が楽そうクマ

エクスポート処理の作成

マイケル
マイケル
以上のデータ構成からCSV、テキストファイルを書き出すツールを作成してみます。 今回はPythonで実装して、GitHubにもアップしています。

GitHub - python-wp-db-output-tools

from common.csv_util import *
from common.mysql_db_manager import *
from db_settings import *

# 出力フォルダ
OUTPUT_DIR = 'output/db/'

# SQL定義

・・・略・・・


# 出力定義
OUTPUT_MST_DATA_INFOS = [
    ['mst_posts', SQL_MST_POSTS],
    ['mst_terms', SQL_MST_TERMS],
    ['mst_term_relationships', SQL_MST_TERM_RELATIONSHIPS],
]


# 出力ファイル名生成
def create_output_file_path(file_name):
    return OUTPUT_DIR + file_name + '.csv'


# SQL実行結果をCSV書き出し
def output_sql_result_to_csv(output_path, sql):
    # SQL実行
    db_manager = MySqlDbManager(WORDPRESS_DB_USER, WORDPRESS_DB_PASSWORD, WORDPRESS_DB_HOST, WORDPRESS_DB_NAME)
    rows = db_manager.executeSqlWithColumnName(sql)
    # CSV書き込み
    CsvUtil.writeCsvFile(output_path, rows)


####################
# マスタデータ出力
####################
if __name__ == "__main__":
    for output_mst_data_info in OUTPUT_MST_DATA_INFOS:
        # 出力定義を取得
        output_file_name = output_mst_data_info[0]
        output_sql = output_mst_data_info[1]
        # SQLを実行してファイル出力
        output_path = create_output_file_path(output_file_name)
        output_sql_result_to_csv(output_path, output_sql)
▲マスタCSVデータ出力処理の例
from common.file_util import *
from db_settings import *
from gql import gql, Client
from gql.transport.aiohttp import AIOHTTPTransport
import urllib.parse

# 出力フォルダ
OUTPUT_DIR = 'output/posts_gql/'

# クエリ

・・・略・・・

    """


# 出力ファイル名生成
def create_output_file_path(file_name):
    return OUTPUT_DIR + file_name + '.txt'


# lowercaseのエンコード結果を返却
def encode_lowercase(value):
    return urllib.parse.quote(value).lower()


####################
# 記事データ出力
# WordPress内で変換後のデータが欲しいためWPGraphQLで実行
####################
if __name__ == "__main__":
    # WPGraphQLを実行
    transport = AIOHTTPTransport(url=WP_GRAPH_QL_URL)
    client = Client(transport=transport)

    # 投稿記事
    query = gql(QUERY_GET_POSTS)
    result = client.execute(query)
    posts = result["posts"]["edges"]
    for post in posts:
        slug = post["node"]["slug"]
        content = post["node"]["content"]
        if slug is not None and content is not None:
            FileUtil.writeTextFile(create_output_file_path(encode_lowercase(slug)), content)

    # 固定ページ
    query = gql(QUERY_GET_PAGES)
    result = client.execute(query)
    pages = result["pages"]["edges"]
    for page in pages:
        slug = page["node"]["slug"]
        content = page["node"]["content"]
        if slug is not None and content is not None:
            FileUtil.writeTextFile(create_output_file_path(encode_lowercase(slug)), content)

▲記事テキストデータの出力例
エレキベア
エレキベア
ちょっとした処理を書くのにPythonは便利クマね
マイケル
マイケル
最後に、出力した記事テキスト内のURLを新しい形式に変換します。 1. で用意した画像ファイルサーバURLに置き換えるのと、ルーティングパスも新しい形式に置き換えています。
#!/bin/bash

##############################
# WordPress記事ファイル内のURLを変換する
##############################

# 画像参照先を画像サーバURLに置き換え
# http://localhost:8000 => https://content.elekibear.com
cd /Users/plasmo/workspace/GitProjects/wp-next-elekibear/data
find ./ -name '*.txt' -exec sed -i '' 's@http://localhost:8000/wp-content/uploads@https://content.elekibear.com/wp-content/uploads@gI' {} \;
find ./ -name '*.csv' -exec sed -i '' 's@http://localhost:8000/wp-content/uploads@https://content.elekibear.com/wp-content/uploads@gI' {} \;

# 記事リンクをelekibear.comに置き換え
find ./ -name '*.txt' -exec sed -i '' 's@http://localhost:8000@https://elekibear.com@gI' {} \;

# 記事リンクを新スタイルに合わせる
# /[slug] => /post/[slug]
# /category/[cateogory] => /?category=[category]
find ./ -name '*.txt' -exec sed -i '' 's@https://elekibear.com/category/@/?category=@gI' {} \;
find ./ -name '*.txt' -exec sed -i '' 's@href="https://elekibear.com@href="/post@gI' {} \;

エレキベア
エレキベア
これでデータの用意はできたクマね

Next.js上でデータを表示する

マイケル
マイケル
それでは用意したデータを、実際にNext.jsプロジェクト側で読み込んで表示してみます。

マスタデータの読込

マイケル
マイケル
マスタデータを読み込むため、まずはデータ型の定義を追加します。 今回はマスタデータ自体の型と、それを用いて返すレスポンス型を定義しました。
/**
 * マスタデータ
 */
export namespace mstData {
  // 記事データ
  export type mstPostsRow = {
    id: string;
    post_type: string; // post or page
    post_title: string;
    post_name: string;
    featured_image: string;
    post_date: string;
    post_modified: string;
  };

  // 分類データ
  export type mstTermsRow = {
    id: string;
    taxonomy: string; // category or post_tag
    name: string;
    slug: string;
    parent: string;
  };

  // 記事データと分類データの紐付け
  export type mstTermRelationshipsRow = {
    post_id: string;
    term_id: string;
  };
}

/**
 * マスタデータ関連API
 */
export namespace apiMst {
  // 記事データ
  export type Posts = {
    posts: Post[];
  };
  export type Post = {
    id: string;
    title: string;
    slug: string;
    date: string;
    featuredImage: string;
    categories: PostCategory[];
    tags: PostTag[];
  };
  export type PostCategory = {
    id: string;
    name: string;
    slug: string;
  };
  export type PostTag = {
    id: string;
    name: string;
    slug: string;
  };

  // カテゴリ情報
  export type Categories = {
    categories: Category[];
  };
  export type Category = {
    id: string;
    name: string;
    slug: string;
    children: CategoryChildren[];
  };
  export type CategoryChildren = {
    id: string;
    name: string;
    slug: string;
  };

  // 子カテゴリのIDを全て持たせた情報
  export type AllCategoryIdIncludeChildInfo = {
    [key: string]: { name: string; categoryIds: string[] };
  };

  // タグ情報
  export type Tags = {
    tags: Tag[];
  };
  export type Tag = {
    id: string;
    name: string;
    slug: string;
  };
}

▲マスタデータ関連の型定義
マイケル
マイケル
そして実際の読込処理を実装します。 下記のようにCSVから読み込んだデータを定義した型に変換して返す処理にしました。
import fs from 'fs';
import path from 'path';
import SiteSettings from 'settings/SiteSettings';
import { apiMst, mstData } from 'types/mst-api';

// ========== マスタデータ読み込み ==========

/**
 * マスタデータファイル読み込み共通
 * @param fileName
 * @returns
 */
function readMstDataCsvFile(fileName: string): string[][] {
  // ファイル読み込み
  const filePath: string = path.join(
    process.cwd(),
    SiteSettings.MST_DATA_PATH,
    fileName,
  );
  const readContent: string = fs.readFileSync(filePath, 'utf-8');

  // 1行目は項目名のため、それ以降を対象として読み込む
  const result: string[][] = [];
  const rows: string[] = readContent.split('\n');
  for (let i = 1; i < rows.length; i++) {
    if (rows[i].indexOf(',') < 0) {
      continue;
    }
    const values = rows[i].split(',');
    result.push(values);
  }
  return result;
}

/**
 * 記事マスタの読み込み
 * @returns
 */
function readMstPosts(): mstData.mstPostsRow[] {
  const data = readMstDataCsvFile('mst_posts.csv');

  const result: mstData.mstPostsRow[] = [];
  for (let i = 0; i < data.length; i++) {
    let index = 0;
    const columns = data[i];
    const row: mstData.mstPostsRow = {
      id: columns[index++],
      post_type: columns[index++],
      post_title: columns[index++],
      post_name: columns[index++],
      featured_image: columns[index++],
      post_date: columns[index++],
      post_modified: columns[index++],
    };
    result.push(row);
  }
  return result;
}

/**
 * 分類マスタの読み込み
 * @returns
 */
function readMstTerms(): mstData.mstTermsRow[] {
  const data = readMstDataCsvFile('mst_terms.csv');

  const result: mstData.mstTermsRow[] = [];
  for (let i = 0; i < data.length; i++) {
    let index = 0;
    const columns = data[i];
    const row: mstData.mstTermsRow = {
      id: columns[index++],
      taxonomy: columns[index++],
      name: columns[index++],
      slug: columns[index++],
      parent: columns[index++],
    };
    result.push(row);
  }
  return result;
}

/**
 * 記事、分類の紐付けマスタの読み込み
 * @returns
 */
function readMstTermRelationships(): mstData.mstTermRelationshipsRow[] {
  const data = readMstDataCsvFile('mst_term_relationships.csv');

  const result: mstData.mstTermRelationshipsRow[] = [];
  for (let i = 0; i < data.length; i++) {
    let index = 0;
    const columns = data[i];
    const row: mstData.mstTermRelationshipsRow = {
      post_id: columns[index++],
      term_id: columns[index++],
    };
    result.push(row);
  }
  return result;
}

// ========== マスタデータを使用したAPI==========

・・・略・・・

▲CSVファイルからのデータ読込
マイケル
マイケル
あとはこれらのデータをレスポンス形式に変換する関数を用意してあげれば準備は完了です。

・・・略・・・

// ========== マスタデータを使用したAPI==========

/**
 * 全ての投稿データ取得
 * @param isOnlyPost Postページだけを取得するか?
 * @returns
 */
export function getAllPosts(isOnlyPost: boolean = false): apiMst.Posts {
  // 投稿データを作成日降順で取得
  let mstPosts = readMstPosts();
  if (isOnlyPost) {
    mstPosts = mstPosts.filter((post) => post.post_type == 'post');
  }
  mstPosts.sort((a, b) => (a.post_date > b.post_date ? -1 : 1));

  // レスポンス形式に変換
  const posts: apiMst.Post[] = [];
  for (let i = 0; i < mstPosts.length; i++) {
    const mstPost = mstPosts[i];
    const post: apiMst.Post = {
      id: mstPost.id,
      title: mstPost.post_title,
      slug: mstPost.post_name,
      date: mstPost.post_date,
      featuredImage: mstPost.featured_image,
      categories: [],
      tags: [],
    };
    posts.push(post);
  }

  // カテゴリとタグを付与する
  const mstTerms = readMstTerms();
  const mstTermRelationships = readMstTermRelationships();
  for (let i = 0; i < posts.length; i++) {
    const post = posts[i];
    posts[i] = addTermsInfoForToPost(post, mstTerms, mstTermRelationships);
  }

  return {
    posts: posts,
  };
}

/**
 * 投稿データor固定ページデータ取得 (slug指定)
 * @returns
 */
export function getPostOrPageBySlug(
  slug: string,
  isOnlyPost: boolean = false,
): apiMst.Post {
  // 全ての投稿データを取得
  let mstPosts = readMstPosts();
  if (isOnlyPost) {
    mstPosts = mstPosts.filter((post) => post.post_type == 'post');
  }

  // 指定slugの投稿データを取得
  const mstPost = mstPosts.find((post) => post.post_name == slug);
  if (mstPost == null) {
    return {
      id: '',
      title: '',
      slug: '',
      date: '',
      featuredImage: '',
      categories: [],
      tags: [],
    };
  }

  // レスポンス形式に変換して返却
  let post: apiMst.Post = {
    id: mstPost.id,
    title: mstPost.post_title,
    slug: mstPost.post_name,
    date: mstPost.post_date,
    featuredImage: mstPost.featured_image,
    categories: [],
    tags: [],
  };
  const mstTerms = readMstTerms();
  const mstTermRelationships = readMstTermRelationships();
  post = addTermsInfoForToPost(post, mstTerms, mstTermRelationships);
  return post;
}

/**
 * 投稿データにカテゴリ、タグを付与して返却
 * @param post
 * @param mstTerms
 * @param mstTermRelationships
 * @returns
 */
function addTermsInfoForToPost(
  post: apiMst.Post,
  mstTerms: mstData.mstTermsRow[],
  mstTermRelationships: mstData.mstTermRelationshipsRow[],
): apiMst.Post {
  // 投稿データに紐づく分類情報を取得
  const postRelationships = mstTermRelationships.filter(
    (mstTermRelationShip) => mstTermRelationShip.post_id == post.id,
  );
  if (postRelationships == null || postRelationships.length <= 0) {
    return post;
  }

  // カテゴリとタグをそれぞれ設定
  const relationshipIds = postRelationships.map((r) => r.term_id);
  post.categories = mstTerms.filter(
    (term) =>
      relationshipIds.findIndex(
        (r) => term.taxonomy == 'category' && Number(r) == Number(term.id),
      ) > -1,
  );
  post.tags = mstTerms.filter(
    (term) =>
      relationshipIds.findIndex(
        (r) => term.taxonomy == 'post_tag' && Number(r) == Number(term.id),
      ) > -1,
  );
  return post;
}

・・・略・・・

読み込んだデータをレスポンス形式に変換する
エレキベア
エレキベア
あとはこのAPIから取得したデータを表示するクマね

マスタデータの表示

記事一覧ページ
マイケル
マイケル
用意したAPIを呼び出して、データを表示してみます。 まずトップとなる記事一覧ページは下記のようになっていました。
20231231_wp_to_next_01

マイケル
マイケル
今回は記事データを事前に全て取得しておき、SPAでページに表示させる記事カードを制御する方向で実装しました。 実装としては下記のようになります。

・・・略・・・

/**
 * 記事リストページ(ホーム)
 * @returns
 */
const HomePage: NextPage<HomePageProps> = (props: HomePageProps) => {
  // State定義

・・・略・・・

  const displayPostCount = SiteSettings.DisplayPostsCount;
  let allPosts: apiMst.Post[] = props.allPosts.posts;

  // 検索ワード、カテゴリ、タグでフィルタ
  allPosts = filterAllPosts(
    allPosts,
    props.allCategoryIdIncludeChildInfo,
    currentSearchWord,
    currentCategorySlug,
    currentTagSlug,
  );

  // 指定ページの記事のみにslice
  const startIndex = (currentPage - 1) * SiteSettings.DisplayPostsCount;
  const endIndex = startIndex + SiteSettings.DisplayPostsCount;
  const posts = allPosts.slice(startIndex, endIndex);

  return (
    <Layout
      mainContent={
        <>
          <BreadcrumbItems
            searchWord={currentSearchWord}
            setSearchWordState={SetSearchWordState}
            categorySlug={currentCategorySlug}
            allCategories={props.allCategories}
            setCategoryState={SetCategorySlugState}
            tagSlug={currentTagSlug}
            allTags={props.allTags}
            setTagState={SetTagSlugState}
          ></BreadcrumbItems>
          <PostCount
            allPostCount={allPosts.length}
            pageNo={currentPage}
            displayCount={SiteSettings.DisplayPostsCount}
          />
          <PostCardList allPosts={posts} />
          <Pagination
            displayPostCount={displayPostCount}
            totalPostCount={allPosts.length}
            currentPage={currentPage}
            setPageStateAction={SetPageState}
          />
        </>
      }

・・・略・・・

      }
      allCategories={props.allCategories}
      setCategoryStateAction={SetCategorySlugState}
      setSearchWordStateAction={SetSearchWordState}
    />
  );
};
export default HomePage;

type HomePageProps = {
  allPosts: apiMst.Posts;
  allCategories: apiMst.Categories;
  allCategoryIdIncludeChildInfo: apiMst.AllCategoryIdIncludeChildInfo;
  allTags: apiMst.Tags;
};

export const getStaticProps: GetStaticProps = async ({ preview = false }) => {
  const allPosts: apiMst.Posts = getAllPosts(true);
  const allCategories: apiMst.Categories = getAllCategories();
  const allCategoryIdIncludeChildInfo =
    getAllCategoryIdIncludeChildInfo(allCategories);
  const allTags: apiMst.Tags = getAllTags();

・・・略・・・

  return {
    props: {
      allPosts,
      allCategories,
      allCategoryIdIncludeChildInfo,
      allTags,
    },
  };
};

▲記事一覧ページの実装
エレキベア
エレキベア
SSGで実装する場合、getStaticPropsで事前にデータを取得しておくのだったクマね
マイケル
マイケル
PostCardListコンポーネント内でデータをループすることで、記事カードを表示しています。
import { Theme, css } from '@emotion/react';
import PostCard from 'components/page/Index/PostCard';
import { apiMst } from 'types/mst-api';

const styleRoot = css`
  // 中央寄せにしつつ中身を右寄せにする
  // https://www.memory-lovers.blog/entry/2020/01/23/123000
  display: grid;
  grid-template-columns: repeat(auto-fit, 47.5%);
  justify-content: center;
`;

const styleNotPost = (theme: Theme) => css`
  color: ${theme.colors.primaryWhite};
  text-align: center;
  height: 60px;
  line-height: 60px;
`;

interface PostCardListProps {
  allPosts: apiMst.Post[];
}

/**
 * 投稿カードリスト
 * @param props
 * @returns
 */
const PostCardList = (props: PostCardListProps) => {
  const postCardIdPrefix = 'postcard_';

  if (props.allPosts.length <= 0) {
    return <div css={styleNotPost}>該当する記事はありません。</div>;
  }

  return (
    <div css={styleRoot}>
      {props.allPosts.map((post: apiMst.Post, i: number) => (
        <PostCard
          key={post.slug}
          id={postCardIdPrefix + i}
          src={post.featuredImage}
          category={post.categories[0].name}
          title={post.title}
          date={post.date}
          slug={post.slug}
        />
      ))}
    </div>
  );
};
export default PostCardList;

▲記事カードリストコンポーネントの実装
エレキベア
エレキベア
基本データ取得して表示するだけクマからシンプルクマね
記事ページ
マイケル
マイケル
次に記事ページの実装についてです。
20231231_wp_to_next_02

マイケル
マイケル
こちらもデータ取得して表示する流れは同じですが、記事データの数だけpageが存在するため、getStaticPaths内で事前にパスを定義しておく必要があります。

・・・略・・・

/**
 * トップページ
 * @returns
 */
const PostPage: NextPage<PostPageProps> = (props: PostPageProps) => {
  let post: apiMst.Post = props.post;

  // dangerouslySetInnerHTML のWarning防止
  // https://stackoverflow.com/questions/58266356/what-is-happening-such-i-receive-dangerouslysetinnerhtml-warning-and-empty-conte
  const [render, setRender] = useState(false);
  useEffect(() => {
    setRender(true);
  }, []);


・・・略・・・

  return (
    <Layout
      mainContent={
        <>
          <Breadcrumb>
            <BreadcrumbItem href="/" isHome={true}>
              ホーム
            </BreadcrumbItem>
            <BreadcrumbItem href={'/post/' + props.slug}>
              {props.slug}
            </BreadcrumbItem>
          </Breadcrumb>
          <div css={styleContentRoot}>
            <Title title={post.title} />
            <PostInfo post={post} />
            <EyeCatchImage sourceUrl={post.featuredImage} />
            {render && postContentElement}
            <RelatedPosts post={props.post} allPosts={props.allPosts} />
          </div>
        </>
      }

・・・略・・・

    />
  );
};
export default PostPage;

type PostPageProps = {
  post: apiMst.Post;
  postContent: string;
  isMarkdown: boolean;
  slug: string;
  allPosts: apiMst.Posts;
  allCategories: apiMst.Categories;
  allCategoryIdIncludeChildInfo: apiMst.AllCategoryIdIncludeChildInfo;
  allTags: apiMst.Tags;
};

// ファイル読み込み
// 読み込めなければ空文字で返す
function tryReadFileSync(filePath: string): string {
  let result: string = '';
  try {
    result = fs.readFileSync(filePath, 'utf-8');
  } catch {
    result = '';
  }
  return result;
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const slug: string = typeof params?.slug === 'string' ? params?.slug : '';
  const allPosts: apiMst.Posts = getAllPosts(true);
  const allCategories = getAllCategories();
  const allCategoryIdIncludeChildInfo =
    getAllCategoryIdIncludeChildInfo(allCategories);
  const allTags: apiMst.Tags = getAllTags();

・・・略・・・

  // マークダウン or HTMLText記事でそれぞれ取得する
  let post: apiMst.Post;
  let postContent: string;
  let isMarkdown: boolean = false;

  // 記事データ読込

・・・略・・・

    // ===== HTMLText記事 =====
    post = getPostOrPageBySlug(slug);
    const postFilePath = path.join(
      process.cwd(),
      SiteSettings.HTML_TEXT_POSTS_PATH,
      encodeURI(slug).toLowerCase() + '.txt',
    );
    postContent = tryReadFileSync(postFilePath);
    postContent = encodeURI(postContent); // Netlifyでのビルド時に上手く渡せない場合があったので一旦エンコード

・・・略・・・

  return {
    props: {
      post,
      postContent,
      isMarkdown,
      slug,
      allPosts,
      allCategories,
      allCategoryIdIncludeChildInfo,
      allTags,
    },
  };
};

// 全てのslugを取得してページ生成
export const getStaticPaths: GetStaticPaths = async () => {
  const allPosts: apiMst.Posts = getAllPosts();

  // マークダウンの記事データも追加
  const allMarkdownPost = getAllMarkdownPosts(null, null);
  allMarkdownPost.forEach((markdownPost) => {
    allPosts.posts.push(markdownPost.meta.mstPost);
  });

  return {
    paths: allPosts.posts.map((post) => `/post/${post.slug}`) || [],
    fallback: false,
  };
};

▲記事ページの実装
エレキベア
エレキベア
getStaticPathsは最初の一度のみパスを定義するために呼ばれて getStaticPropsはページ毎に呼ばれるクマね
マイケル
マイケル
実際に記事データを表示しているHtmlTextPostContentコンポーネントは下記のようになっています。 初めはreact-html-parser等を使用してReactElementに変換するのも検討したのですが、それだと外部スクリプトによる影響でエラーが発生してしまう場合があったため最終的にdangerouslySetInnerHTMLに設定することで表示しています。
import * as cheerio from 'cheerio';
import { TocContent } from 'common/TocElementUtil';
import SiteSettings from 'settings/SiteSettings';

/**
 * HTMLテキスト用 記事コンテンツ
 * @param props
 * @returns
 */
const HtmlTextPostContent = (props: {
  postContent: string;
  pushPostTocContent: (tocContent: TocContent) => void;
}) => {
  // react-html-parser等でReactElementに変換すると
  // Prism等の外部スクリプトによる変更でDOMエラーが発生してしまう (removeChild...)
  // そのため、cheerioでHTML文字列のまま編集し、dangeraouslySetInnerHTMLに設定する方針とした.
  const $ = cheerio.load(decodeURI(props.postContent));

  // 目次要素の抽出
  $(SiteSettings.TocHeadTags).each((index, elem) => {
    const spanElem = $(elem).find('span');
    if (!spanElem || !spanElem[0]) {
      return;
    }
    const id = spanElem[0].attribs.id;
    const title = spanElem.html() ?? '';
    const tag = $(elem)[0].name;
    props.pushPostTocContent({
      id: id,
      title: title,
      tag: tag,
    });
  });

  // 画像をポップアップ表示できるようにする (CSSのみで実装)
  // https://www.rectus.co.jp/archives/4247
  $('img').each((index, elem: any) => {
    if (
      elem.parent.name === 'figure' &&
      elem.parent.attribs.class.indexOf('wp-block-image') >= 0 &&
      elem.parent.attribs.class.indexOf('ramen') < 0 &&
      !(elem.parent.parent?.name === 'a')
    ) {
      // indexからidを生成
      const popupImageId = `popup-img-${index}`;
      const popupCheckboxId = `popup-checkbox-${index}`;

      // 画像クリックでcheckboxをONにする
      $(elem).wrap(`<label for="${popupCheckboxId}"></label>`);

      // 画像ポップアップ部分
      // 背景マスククリックで閉じる、ポップアップ画像クリックで別タブで開く
      const src = elem.attribs?.src;
      $(elem.parent.parent).append(
        `
          <input id="${popupCheckboxId}" type="checkbox">
          <label id="${popupImageId}" class="popup-image-wrapper" for="${popupCheckboxId}">
            <a href="${src}" target="_brank" style="display: inline-block;">
              <img src="${src}" class="popup-image">
            </a>
          </label>

          <style type="text/css" media="screen">
          #${popupImageId} {
            display: none;
          }
          #${popupCheckboxId} {
            display: none;
          }
          #${popupCheckboxId}:checked + #${popupImageId} {
            display: flex !important;
          }
          </style>
          `,
      );
    }
  });

  return (
    <div
      className="entry-content"
      dangerouslySetInnerHTML={{ __html: $.html() }}
    />
  );
};
export default HtmlTextPostContent;

▲テキスト記事の表示コンポーネント実装
マイケル
マイケル
なお、記事データの目次抽出と画像をポップアップさせる対応を入れるためにcheerioというライブラリを利用して操作しています。 HTML文字列をjQueryライクに操作できるので楽しいです!
エレキベア
エレキベア
これでメインの二画面は表示できるようになったクマね

その他の機能移行

マイケル
マイケル
その他、行った対応についてもいくつか紹介します!

CSSスタイリングの移行

マイケル
マイケル
まずはWordPressで実装されているCSSの移行についてです。 テキスト記事内に埋め込まれたクラス等を全て移行するのはかなり手間がかかりそうだったため、今回は一部をグローバルCSSとして設定することで対処しています。
import { Theme, css } from '@emotion/react';
import { styleWpMathJax } from './wp-mathjax';
import { styleWpPrism } from './wp-prism';
import { md } from 'style/media';

/**
 * wordpress投稿済の記事を表示するためのstyle
 * Globalに適用されてしまうため、最終的には移行したい
 * @param theme
 * @returns
 */
export const styleWordpress = (theme: Theme) => css`
  ${styleCommon(theme)}
  ${styleSpeech(theme)}
  ${styleToc(theme)}
  ${styleHeadTag(theme)}
  ${styleImage}
  ${styleTable(theme)}
  ${styleAppInfo}
  ${styleBlogCard(theme)}
  ${styleComic(theme)}
  ${styleRamenBox}
  ${stylePointBox(theme)}
  /** Plugins */
  ${styleWpPrism(theme)}
  ${styleWpMathJax(theme)}
`;

/** 共通 */
const styleCommon = (theme: Theme) => css`
  /** 全てのcontentに付与 */
  .entry-content > * {
    margin-bottom: 1.6em;
  }
  iframe {
    max-width: 100%;
  }

  /** ポップアップ画像用 */
  .popup-image-wrapper {
    display: flex;
    justify-content: center;
    align-items: center;
    position: fixed;
    width: 100%;
    height: 100vh;
    top: 0;
    left: 0;
    background-color: rgba(0, 0, 0, 0.7);
    z-index: ${theme.zindex.imagePopup};
    overflow-y: scroll;
  }

・・・略・・・

▲WordPressのCSS定義

・・・略・・・

// グローバルのスタイル
const styleGlobal = (theme: Theme) => css`

・・・略・・・

  ${styleWordpress(theme)}
`;

const MyApp = ({ Component, pageProps }: AppProps) => {
  return (
    <>

・・・略・・・

      {/* Themeを有効にする */}
      <ThemeProvider theme={elekibear}>
        <Global styles={styleGlobal} />
        <Component {...pageProps} />
      </ThemeProvider>
    </>
  );
};
export default MyApp;

▲グローバルCSSとして設定
エレキベア
エレキベア
むむむ・・・ まあコストを考えるとある程度は仕方ないクマか・・・

サイドバーウィジェット

マイケル
マイケル
次にサイドバーに表示していたウィジェットについて!
20231231_wp_to_next_03
▲サイドバーウィジェット

マイケル
マイケル
こちらはSideWidgetBaseコンポーネントというベースとなるコンポーネントを作成し、 各ウィジェットごとに中身を渡すことで実装しました。
import { SerializedStyles, Theme, css, keyframes } from '@emotion/react';

const fadeInKeyframes = keyframes`
  0% {
    opacity: 0;
  }
  100% {
    opacity: 100;
  }
`;

const styleRoot = (theme: Theme) => css`
  background-color: ${theme.colors.simpleWhite};
  border-radius: 12px;
  box-shadow: 8px 8px 2px 1px rgba(0, 0, 0, 0.3);
  animation: ${fadeInKeyframes} 0.7s ease 0s 1 normal;
  margin-bottom: 1.4em;
`;

const styleTitle = (theme: Theme) => css`
  background: ${theme.colors.primaryLightGray};
  color: ${theme.colors.primaryWhite};
  font-size: 16px;
  margin: 0px;
  border-radius: 10px 10px 0px 0px;
  padding: 12px;
`;

const styleContent = css`
  margin: 0px;
  box-sizing: border-box;
  overflow-wrap: break-word;
`;

interface SideWidgetBaseProps {
  children: React.ReactNode;
  title: string;
  addCss?: SerializedStyles;
}

/**
 * サイドバーウィジェット
 * @param props
 * @returns
 */
const SideWidgetBase = (props: SideWidgetBaseProps) => {
  return (
    <aside css={[styleRoot, props.addCss]}>
      <h3 css={styleTitle}>{props.title}</h3>
      <div css={styleContent}>{props.children}</div>
    </aside>
  );
};
export default SideWidgetBase;

▲サイドバーウィジェットのベースコンポーネント
import { css, Theme } from '@emotion/react';
import SideWidgetBase from '../WidgetBase';
import WidgetSettings from 'settings/WidgetSettings';

const styleRoot = (theme: Theme) => css`
  padding: 12px;
  color: ${theme.colors.primaryLightGray};
`;

/**
 * お知らせウィジェット
 * @returns
 */
const NoticeWidget = () => {
  const noticeContent = WidgetSettings.NoticeContent;
  if (!noticeContent) {
    return <></>;
  }

  return (
    <SideWidgetBase title="お知らせ">
      <div css={styleRoot}>
        <div dangerouslySetInnerHTML={{ __html: noticeContent }} />
      </div>
    </SideWidgetBase>
  );
};
export default NoticeWidget;

▲お知らせウィジェットの実装例
エレキベア
エレキベア
ベースコンポーネントを用意しておけば実装しやすそうクマね
マイケル
マイケル
上記のような形で各ウィジェットも移行し、最終的には下記のような形となりました。

・・・略・・・

  return (
    <Layout

・・・略・・・

      sideBarContent={
        <>
          <SearchWidget
            setSearchWordStateAction={SetSearchWordState}
          ></SearchWidget>
          <NoticeWidget />
          <CharactersWidget />
          <AppWidget />
          <NewPostWidget posts={props.allPosts.posts.slice(0, 5)} />
          <AssetPostWidget />
          <CategoryWidget
            allCategories={props.allCategories}
            allCategoryIdIncludeChildInfo={props.allCategoryIdIncludeChildInfo}
            allPosts={props.allPosts.posts}
            menuPathArray={WidgetSettings.CategoryWidgetPathArray}
            setCategoryStateAction={SetCategorySlugState}
          />
          <TagWidget
            tags={props.allTags.tags}
            allPosts={props.allPosts.posts}
            setTagStateAction={SetTagSlugState}
          />
        </>
      }
      allCategories={props.allCategories}
      setCategoryStateAction={SetCategorySlugState}
      setSearchWordStateAction={SetSearchWordState}
    />
  );

・・・略・・・

▲サイドバーの設定
エレキベア
エレキベア
盛りだくさんクマが綺麗に整理できたクマね

カテゴリや検索ワードによる絞り込み

マイケル
マイケル
次にカテゴリや検索ワードによる記事カードの絞り込みについてです。 こちらはSEOからページを除外して問題ない & SPAで実装したかったため、クエリパラメータで指定された場合にフィルタする方針にしました。
20231231_wp_to_next_04
▲カテゴリによる絞り込み (/?category=[XXX])

20231231_wp_to_next_05
▲検索ワードによる絞り込み (/?search=[XXX])

エレキベア
エレキベア
あくまで記事一覧ページ内で制御するクマね
マイケル
マイケル
SPAでの実装となるため、下記のように高速な遷移処理を実現できます。
20231231_next_app_design_gif_01
▲SPAのため表示の切替が高速

エレキベア
エレキベア
これは気持ちいいクマ〜〜〜〜
マイケル
マイケル
実装は下記のようになっています。 クエリパラメータから取得した値から表示する記事カードをフィルタする処理を挟んでいます。

・・・略・・・

/**
 * クエリパラメータのチェック
 */
function checkQueryParameters(
  setCurrentPageState: (page: number) => void,
  setCurrentSearchWord: (slug: string) => void,
  setCurrentCategorySlug: (slug: string) => void,
  setCurrentTagSlug: (slug: string) => void,
): void {
  const url = new URL(window.location.href);

  // ページ指定
  const paramPage = url.searchParams.get(SiteSettings.UrlParamNamePage);
  if (paramPage && !Number.isNaN(parseInt(paramPage))) {
    const pageno = parseInt(paramPage);
    setCurrentPageState(pageno);
  }

  // フィルタ指定 (検索 -> カテゴリ -> タグ の優先順)
  // 検索ワード指定
  const paramSearchWord = url.searchParams.get(SiteSettings.UrlParamNameSearch);
  if (paramSearchWord) {
    setCurrentSearchWord(encodeURI(paramSearchWord).toLowerCase());
    return;
  }

  // カテゴリ指定
  const paramCategorySlug = url.searchParams.get(
    SiteSettings.UrlParamNameCateogry,
  );
  if (paramCategorySlug) {
    setCurrentCategorySlug(encodeURI(paramCategorySlug).toLowerCase());
    return;
  }

  // タグ指定
  const paramTagSlug = url.searchParams.get(SiteSettings.UrlParamNameTag);
  if (paramTagSlug) {
    setCurrentTagSlug(encodeURI(paramTagSlug).toLowerCase());
    return;
  }
}

/**
 * 投稿記事のフィルタ処理
 */
function filterAllPosts(
  allPosts: apiMst.Post[],
  allCategoryIdIncludeChildInfo: apiMst.AllCategoryIdIncludeChildInfo,
  currentSearchWord: string,
  currentCategorySlug: string,
  currentTagSlug: string,
): apiMst.Post[] {
  // フィルタ処理 (検索 -> カテゴリ -> タグ の優先順)
  // 指定検索ワードでフィルタ
  if (currentSearchWord) {
    console.log(currentSearchWord);
    return allPosts.filter((post) => {
      return encodeURI(post.title).toLowerCase().includes(currentSearchWord);
    });
  }

  // 指定カテゴリのみにフィルタ
  if (currentCategorySlug) {
    const categoryIdArray: string[] =
      allCategoryIdIncludeChildInfo[currentCategorySlug].categoryIds;
    if (categoryIdArray) {
      return allPosts.filter((post) => {
        const categories = post.categories;
        const isContainCategory = categories.some((category) =>
          categoryIdArray.includes(category.id.toString()),
        );
        return isContainCategory;
      });
    }
    return allPosts;
  }

  // 指定タグのみにフィルタ
  if (currentTagSlug) {
    return allPosts.filter((post) => {
      const tags = post.tags;
      const isContainTag = tags.some(
        (tag) => encodeURI(tag.slug).toLowerCase() == currentTagSlug,
      );
      return isContainTag;
    });
  }
  return allPosts;
}

/**
 * 記事リストページ(ホーム)
 * @returns
 */
const HomePage: NextPage<HomePageProps> = (props: HomePageProps) => {
  // State定義
  const [currentPage, setCurrentPage] = useState<number>(1);
  const [currentSearchWord, setCurrentSearchWord] = useState<string>('');
  const [currentCategorySlug, setCurrentCategorySlug] = useState<string>('');
  const [currentTagSlug, setCurrentTagSlug] = useState<string>('');

  function SetPageState(page: number) {
    setCurrentPage(page);
  }
  // フィルタ条件が指定された場合、ページは最初に戻す
  function SetSearchWordState(searchWord: string) {
    setCurrentSearchWord(searchWord);
    setCurrentCategorySlug('');
    setCurrentTagSlug('');
    SetPageState(1);
  }
  function SetCategorySlugState(slug: string) {
    setCurrentSearchWord('');
    setCurrentCategorySlug(slug);
    setCurrentTagSlug('');
    SetPageState(1);
  }
  function SetTagSlugState(slug: string) {
    setCurrentSearchWord('');
    setCurrentCategorySlug('');
    setCurrentTagSlug(slug);
    SetPageState(1);
  }

  // クエリパラメータのチェック
  useEffect(() => {
    checkQueryParameters(
      SetPageState,
      SetSearchWordState,
      SetCategorySlugState,
      SetTagSlugState,
    );
  }, []);

  const displayPostCount = SiteSettings.DisplayPostsCount;
  let allPosts: apiMst.Post[] = props.allPosts.posts;

  // 検索ワード、カテゴリ、タグでフィルタ
  allPosts = filterAllPosts(
    allPosts,
    props.allCategoryIdIncludeChildInfo,
    currentSearchWord,
    currentCategorySlug,
    currentTagSlug,
  );

・・・略・・・

};
export default HomePage;

・・・略・・・

▲表示する記事カードをフィルタする
マイケル
マイケル
一例にはなりますが、検索ウィジェットで検索を行った場合の処理は下記のようになっています。

・・・略・・・

type SearchFormData = {
  searchWord: string;
};

/**
 * 検索ウィジェット
 * @returns
 */
const SearchWidget = (props: {
  setSearchWordStateAction?: (slug: string) => void;
}) => {
  const router = useRouter();
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SearchFormData>();

  const onSubmit: SubmitHandler<SearchFormData> = async (data) => {
    // 検索ワードをクリパラメータに設定
    const searchWord = data.searchWord.replaceAll(' ', '');
    const encodeSearchWord = encodeURI(searchWord).toLowerCase();
    router.push(
      `/?${SiteSettings.UrlParamNameSearch}=${encodeSearchWord}`,
      undefined,
      {
        shallow: true,
        scroll: true,
      },
    );
    if (props.setSearchWordStateAction) {
      props.setSearchWordStateAction(encodeSearchWord);
    }
  };
  return (
    <aside css={styleRoot}>
      <form css={styleSearchForm} onSubmit={handleSubmit(onSubmit)}>
        <input
          {...register('searchWord', { required: false })}
          css={styleSearchInput}
          type="text"
          placeholder="サイト内を検索"
          maxLength={20}
        ></input>
        <button css={styleSearchButton} type="submit">
          <BiSearch size={'1.2rem'} />
        </button>
      </form>
    </aside>
  );
};
export default SearchWidget;

検索ウィジェットの実装
エレキベア
エレキベア
useRouterを使用してクエリパラメータを指定しているクマね

ペジネーション

マイケル
マイケル
ペジネーションについても基本は同じような実装になっています。 記事カードの数、ページ数によって表示するページ数を切り替えて、ページ変更した場合にはクエリパラメータに指定するよう実装しています。
20231231_wp_to_next_06
▲ペジネーションにより表示する記事を切り替える

20231231_next_app_design_gif_02
▲こちらもSPAのため遷移が高速


・・・略・・・

type PaginationProps = {
  displayPostCount: number; // 表示する投稿数
  totalPostCount: number; // 全ての投稿数
  currentPage: number; // 現在のページ数
  urlPathPrefix?: string; // URLパスに付与するPrefix
  setPageStateAction: (page: number) => void; // ページ状態設定
};

/**
 * ページネーション
 * @param props
 * @returns
 */
const Pagination = (props: PaginationProps) => {
  // shallow: ページをロードせずに遷移させる
  // scroll: ページトップにスクロールさせる
  // https://zenn.dev/sak/articles/fa88580b133b24431303
  const router = useRouter();
  function Paginate(page: number) {
    let url = `/?`;
    // カテゴリ指定されている場合
    const paramCategory = new URL(window.location.href).searchParams.get(
      SiteSettings.UrlParamNameCateogry,
    );
    if (paramCategory) {
      url += `${SiteSettings.UrlParamNameCateogry}=${paramCategory}&`;
    }
    // ページ数を指定して遷移
    url += `${SiteSettings.UrlParamNamePage}=${page}`;
    router.push(url, undefined, {
      shallow: true,
      scroll: true,
    });
    props.setPageStateAction(page);
  }

  // ページ数の配列を生成
  const firstPage = 1;
  const lastPage = Math.ceil(props.totalPostCount / props.displayPostCount);
  const pageNumbers = [];
  for (let i = firstPage; i <= lastPage; i++) {
    pageNumbers.push(i);
  }

  const prevPage = Math.max(1, props.currentPage - 1);
  const nextPage = Math.min(props.totalPostCount, props.currentPage + 1);

  return (
    <div css={styleRoot}>
      {/** 最初のページへ */}
      {(() => {
        if (firstPage != props.currentPage) {
          return (
            <div css={stylePageDisplay} onClick={() => Paginate(firstPage)}>
              <BiFirstPage css={stylePageMoveIcon} size={'1.2em'} />
            </div>
          );
        }
      })()}
      {/** 各ページ数 */}
      {pageNumbers.map((number) => {
        // 選択ページの前後2つまで表示する
        const isDisplay =
          number == prevPage ||
          number == prevPage - 1 ||
          number == nextPage ||
          number == nextPage + 1 ||
          number == firstPage ||
          number == lastPage;
        if (props.currentPage == number) {
          // 現在のページ数
          return (
            <div key={number} css={stylePageSelected}>
              {number}
            </div>
          );
        } else if (isDisplay) {
          // 表示するページ数
          return (
            <div
              key={number}
              css={stylePageDisplay}
              onClick={() => Paginate(number)}
            >
              {number}
            </div>
          );
        } else if (number == firstPage + 1 || number == lastPage - 1) {
          // ... の表示
          return (
            <div key={number} css={stylePageDisplay}>
              ...
            </div>
          );
        }
      })}
      {/** 最後のページへ */}
      {(() => {
        if (lastPage != props.currentPage) {
          return (
            <div css={stylePageDisplay} onClick={() => Paginate(lastPage)}>
              <BiLastPage css={stylePageMoveIcon} size={'1.2em'} />
            </div>
          );
        }
      })()}
    </div>
  );
};
export default Pagination;

▲ペジネーションの実装
エレキベア
エレキベア
記事の内容に関連しないものはクエリパラメータで完結させたクマね

記事内の外部スクリプト読込

マイケル
マイケル
最後に、記事内で使用していたPrism.js等の外部スクリプト読込についてです。 Mathjaxによる数式表示やCodepen等は別途外部スクリプトをimportしておく必要があります。
20231231_wp_to_next_07
▲数式表示やCodepen等は外部スクリプトのimportが必要

マイケル
マイケル
ここが意外とハマりポイントで、読み込むタイミングによって記事が表示されなかったりエラーが発生したりなど最適解が中々見つかりませんでした・・・。
エレキベア
エレキベア
こればっかりは外部に依存しているクマからね・・・
マイケル
マイケル
いろいろ試した結果、下記のような形でuseEffect内でscriptタグをimportしなおす対応を入れることで正常に表示できるようになりました。 next/script で実装できると綺麗だったのですが、仕方ないですね・・・

・・・略・・・

/**
 * ページで使用する全てのScirpts定義を挿入する
 * next/script ではSPAでのページ更新時に反映されない場合があったため、
 * useEffect内で再度importさせることで対処
 * https://qiita.com/Sotq_17/items/66e43ac261b80c6ee612
 */
const appendAllScripts = () => {
  SiteSettings.PostImportScriptArray.map((importScript) => {
    ElementUtil.appendScript(
      importScript.src,
      importScript.id,
      importScript.isAsync,
      importScript.onLoad,
    );
  });
};
const removeAllScirpts = () => {
  SiteSettings.PostImportScriptArray.map((importScript) => {
    ElementUtil.removeById(importScript.id);
  });
};

・・・略・・・

/**
 * トップページ
 * @returns
 */
const PostPage: NextPage<PostPageProps> = (props: PostPageProps) => {

・・・略・・・

  // 記事用scriptタグのimport
  useEffect(() => {
    appendAllScripts();
    return () => {
      removeAllScirpts();
    };
  }, [props]);

・・・略・・・

};
export default PostPage;

・・・略・・・

▲useEffect内でscriptを再importする
/**
 * Element操作関連
 */
export class ElementUtil {

・・・略・・・

  // scriptタグを作成して追加
  public static appendScript(
    url: string,
    id: string,
    isAsync: boolean,
    onLoad: () => void = () => {},
  ) {
    const elem = document.createElement('script');
    elem.id = id;
    elem.onload = () => {};
    elem.onerror = () => {};
    if (isAsync) {
      elem.async = true;
    }
    elem.src = url;
    if (onLoad != null) {
      elem.onload = onLoad;
    }
    document.body.append(elem);
  }
}

▲scriptタグの追加・削除処理
マイケル
マイケル
importするscriptについては、別途Settingsクラス内に定義するようにしておきました。 今回追加したものは下記になります。
/**
 * サイト全体の設定
 */
namespace SiteSettings {

・・・略・・・

  /**
   * 記事ページで動的にimportする外部script
   */
  export const PostImportScriptArray: ImportScript[] = [
    // Prism.js
    {
      id: 'prism-script',
      src: '/lib/prism.js',
      isAsync: false,
    },
    // Mathjax
    {
      id: 'MathJax-script',
      src: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js',
      isAsync: true,
      onLoad: () => {
        // Loadが完了したら明示的にtypesetを呼び出す
        MathJax.typesetPromise();
      },
    },
    // CodePen
    {
      id: 'codepen-script',
      src: 'https://cpwebassets.codepen.io/assets/embed/ei.js',
      isAsync: true,
    },
    // Tweet埋め込み
    {
      id: 'tweet-script',
      src: 'https://platform.twitter.com/widgets.js',
      isAsync: true,
    },
  ];

  export type ImportScript = {
    id: string;
    src: string;
    isAsync: boolean;
    onLoad?: () => void;
  };

・・・略・・・

}

export default SiteSettings;

importするscriptの設定
エレキベア
エレキベア
他にいい方法があれば教えてほしいクマ〜〜〜

未実装の機能

マイケル
マイケル
WordPressのデータ移行に関する紹介は以上になります。 大体の機能は移行できたと思うのですが、下記についてはまだ未実装です。
  • コメント機能
  • 広告機能
  • SNS機能
マイケル
マイケル
この辺りは公開後にぼちぼち対応していく予定です。 コメント機能はサーバが必要になりそうなのでどうするか悩みどころですね・・・
エレキベア
エレキベア
これを機にAPIサーバを一つ立てて持っておくのもいいかもしれないクマね

おわりに

マイケル
マイケル
というわけで今回はWordPressのデータ移行についてでした! どうだったかな??
エレキベア
エレキベア
既存ブログをどうNext.jsに持っていくかは悩みどころクマがなんとかなりそうでよかったクマ やっぱりパフォーマンスがいいと気持ちいいクマね
マイケル
マイケル
次回は今後の執筆環境の改善を目指して、Markdownによる執筆環境構築について紹介します。 お楽しみに!!
エレキベア
エレキベア
クマ〜〜〜〜〜

【Next.js】第二回 WordPressブログをNext.jsに移行する 〜WordPressデータの移行・表示編〜 〜完〜

【都会のエレキベア】ブログを大幅リニューアル!WordPressからNext.jsに移行するまでの流れをまとめる
2024-01-01
【Next.js】第一回 WordPressブログをNext.jsに移行する 〜全体設計、環境構築編〜
2023-12-31
【Next.js】第二回 WordPressブログをNext.jsに移行する 〜WordPressデータの移行・表示編〜
2023-12-31
【Next.js】第三回 WordPressブログをNext.jsに移行する 〜Markdown執筆環境構築編〜
2023-12-31
【Next.js】第四回 WordPressブログをNext.jsに移行する 〜サーバ移行・SEO・広告設定編〜
2023-12-31

Next.jsReactJavaScriptWordPress関連フロントエンド関連SSGStorybookEmotionNode.jsp5.js
2023-12-31
記事をSNSで共有する
X
Facebook
LINE
はてなブックマーク
Pocket
LinkedIn
Reddit

著者の各種アカウント
フォローいただけると大変励みになります!
X
GitHub

関連記事
【Three.js】カスタムシェーダーでトゥーン+背面法アウトラインを実装する
2026-02-15
【Three.js】Three.js入門 - シーン構築・モデル読み込み・ポストプロセスまで
2026-02-15
【Astro】Astroの使い方と複数UIフレームワーク(React、Vue、Svelte)を組み合わせるサンプル
2026-02-01
【Houdini21.0】3Dビル群っぽいブログヘッダー画像を作成する
2026-01-10
【VSCode】ドラッグ&ドロップで画像ファイルをリサイズ・保存する拡張機能を作る
2025-11-22
【ゲーム数学】第十回 p5.js(+α)で学ぶゲーム数学「複素数とフラクタル」
2025-11-02
【ゲーム数学】第九回 p5.jsで学ぶゲーム数学「フーリエ解析」
2024-05-12
【Node.js】廃止されたAmazonアソシエイト画像リンクをAmazon Product Advertising API経由で復活させる
2024-01-08