WordPress Logo WordPress REST API カスタムエンドポイントの作成

以下では、WordPress の REST API を拡張し、カスタムエンドポイントを作成する方法について、基礎から応用まで詳しく解説します。register_rest_route() の使い方や主要なパラメータの説明をはじめ、callback や permission_callback の指定方法、アイキャッチ画像や投稿のカテゴリー・タグ情報の取得方法など、実践的なカスタムエンドポイントの実装例を紹介します。

さらに、REST API と連携するフロントエンドの JavaScript 実装についても、追加読み込み(Load More)やページネーション、カテゴリー・タグの選択や動的なデータ取得の例を交えて解説。WP_REST_Request オブジェクトの活用法や、sanitize_callback・validate_callback の定義方法、rest_ensure_response() を使ったレスポンスの整形方法など、より柔軟で安全なエンドポイントを構築するための知識も掲載しています。

関連ページ:WordPress REST API の使い方

更新日:2025年04月16日

作成日:2025年04月16日

カスタムエンドポイント

WP REST API には、あらかじめいろいろなエンドポイントが用意されていますが、標準のエンドポイントだけでは細かい条件でデータを取得したいときなどに対応できないことがあります。

そのような場合、独自のエンドポイント(カスタムエンドポイント)を作成すれば、「どのような条件でどのようなデータを返すか」を決めることができるので、必要な情報を効率よく取得できます。

以下のコードは、/wp-json/myplugin/v1/latest-posts というカスタムエンドポイントを作成し、最新の投稿(タイトル・リンク・抜粋)を3件取得するサンプルです。

カスタムエンドポイントの登録には register_rest_route() 関数を使用します。

エンドポイントが返すデータは、 register_rest_route() の callback パラメータで指定された関数 myplugin_get_latest_posts 内で処理されます。以下の例では、get_posts() を使って投稿を取得し、それぞれのタイトル、リンク、抜粋を HTMLタグ除去・エスケープ処理したうえで返しています。

また、permission_callback に __return_true を指定することで、このエンドポイントは誰でも認証なしでアクセスできるようになっています。

// register_rest_route() を使って API ルートを登録する関数
function myplugin_register_rest_routes() {
  register_rest_route('myplugin/v1', '/latest-posts/', array(
    'methods'  => 'GET',  // HTTPメソッド(GETリクエスト)
    'callback' => 'myplugin_get_latest_posts', // リクエストを処理するコールバック関数
    'permission_callback' => '__return_true',  // 認証なしでアクセス可能にする(明示的に指定)
  ));
}
// rest_api_init アクションフックを使用してカスタムエンドポイントを登録
add_action('rest_api_init', 'myplugin_register_rest_routes');

// 投稿データ(タイトル、リンク、抜粋)を取得する関数
function myplugin_get_latest_posts() {
  // 投稿取得のためのクエリパラメータを設定
  $postargs = array(
    'numberposts' => 3,
    'post_status' => 'publish',
  );

  // 上記パラメータを指定して投稿を取得
  $postslist = get_posts($postargs);
  // 投稿データを格納する配列
  $data = array();

  // 投稿データを作成
  foreach ($postslist as $post) {
    $data[] = array(
      // 投稿タイトルから HTML を除去してエスケープ
      'title' => esc_html(wp_strip_all_tags(get_the_title($post))),
      // 投稿 URL をエスケープ
      'link'  => esc_url(get_permalink($post)),
      // 抜粋から HTML を除去してエスケープ
      'excerpt' => esc_html(wp_strip_all_tags(get_the_excerpt($post))),
    );
  }
  // グローバルな投稿データをリセット
  wp_reset_postdata();
  // REST API レスポンスとしてデータを返す
  return rest_ensure_response($data);
}

使い方

上記のコードを追加した状態で、以下の URL(example.com の部分は適宜変更)にアクセスすると、最新の投稿情報が JSON 形式で取得できます。

https://example.com/wp-json/myplugin/v1/latest-posts

上記は Firefox での表示例です。Chrome などでは読みやすく表示するには拡張機能(例 JSON Formatter)が必要です。または、REST API テストツールの Postman などを使うと簡単にリクエストを送信して確認できます。

カスタムエンドポイントのメリット

標準の投稿の /wp/v2/posts エンドポイントでは多くの情報が含まれますが、カスタムエンドポイントでは必要な情報だけを返すことができ、投稿数を変更したり、カテゴリーで絞り込んだり、認証無しでアイキャッチ画像を付け加えたりすることもできます。

また、WordPress が標準で提供している REST API エンドポイントでは、タイトルや抜粋などの値は「レンダリング済みの HTML を含む形式」で返されるため、フロント側でサニタイズや HTML 除去を別途行う必要がありますが、カスタムエンドポイントではエスケープ済みで返すことができます(但し、その場合でもフロントエンド側でエスケープ処理した方がより安全ですが)。

目的に応じて、タイトルはプレーンに、本文は HTML 付きなどレスポンスの柔軟な構造設計が可能です。

公式ドキュメント:Adding Custom Endpoints

register_rest_route()

register_rest_route() 関数を使うことで、独自の API エンドポイント(カスタムエンドポイント)を追加し、GET や POST などのリクエストに対応できます。

register_rest_route() の書式と引数

register_rest_route( string $namespace, string $route, array $args )
引数
引数 説明
$namespace エンドポイント(API)の名前空間(例: myplugin/v1)。通常は plugin-name/v1 のようにバージョンを含めて指定します。前後にスラッシュは付けません。推奨される $namespace の指定方法
$route ルートの URL パス。最終的な URL は /wp-json/namespace/route になります。先頭にスラッシュを付けますが、末尾のスラッシュはあってもなくてもOK。
$args ルートの詳細設定(HTTPメソッド、コールバック関数、パーミッションなど)を含む配列です。以下参照。
$args の主なプロパティ
プロパティ 説明
methods 許可する HTTP メソッド('GET', 'POST', 'PUT', 'DELETE' など)
callback リクエストを処理するコールバック関数
permission_callback アクセス権限をチェックする関数(例:ログイン中のユーザーのみ許可など)。すべてのユーザーに許可する場合は __return_true を使用。必須。
args パラメータの定義。リクエストで受け取る値をここで定義することで、バリデーションやデフォルト値などを設定できます。詳細
function myplugin_register_rest_routes() {
  register_rest_route(
    'myplugin/v1',  /* $namespace */
    '/latest-posts/', /* $route */
    array( /* $args */
      'methods'  => 'GET',
      'callback' => 'myplugin_get_latest_posts',
      'permission_callback' => '__return_true',
    )
  );
}
add_action('rest_api_init', 'myplugin_register_rest_routes');

methods

methods には 'GET' や 'POST' などの許可する HTTP メソッドを指定しますが、WP_REST_Server::READABLE などの定数を使うこともできます。

定数 対応するHTTPメソッド
WP_REST_Server::READABLE 'GET', 'HEAD'
WP_REST_Server::CREATABLE 'POST'
WP_REST_Server::EDITABLE 'PUT', 'PATCH'
WP_REST_Server::DELETABLE 'DELETE'
WP_REST_Server::ALLMETHODS 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'

定数を使うメリットとしては、例えば、'methods' => WP_REST_Server::READABLE, と記述すれば「読み取り専用」であることがわかりやすいですし、複数メソッドの処理に対応できるということがあります。

単一のメソッドしか使わない場合は、単に 'GET' や 'POST' と指定するのが簡単です。

callback の戻り値

callback の戻り値に求められる形式は以下のいずれかであれば問題ありません。

配列やオブジェクト

WordPress の REST API では、カスタムエンドポイントのコールバック関数が配列やオブジェクトを返す場合、自動的に WP_REST_Response にラップして処理してくれます。

return array( 'message' => 'Hello world' );

以下はオブジェクトを返す例です。

$data = new stdClass();
$data->message = 'これはオブジェクトで返されたデータです';
$data->timestamp = current_time( 'mysql' );
$data->user = is_user_logged_in() ? wp_get_current_user()->user_login : 'ゲスト';
return $data;

WP_REST_Response オブジェクト

より細かく HTTP ステータスやヘッダーを制御したい場合に使います。

$response = new WP_REST_Response( array( 'message' => 'OK' ), 200 );
$response->header( 'X-Custom-Header', 'Value' );
return $response;

WP_Error オブジェクト(エラーを返す場合)

return new WP_Error( 'custom_error', 'Something went wrong', array( 'status' => 400 ) );
rest_ensure_response()

rest_ensure_response() はREST API のレスポンス形式を自動的に整えてくれる便利な関数です。

この関数を使えば、戻り値が配列・オブジェクト・エラーなどであっても、WordPress REST API に適した形(WP_REST_Response)に自動変換して返してくれます。

入力値の型 処理内容
配列 or オブジェクト WP_REST_Response にラップして返す
WP_REST_Response そのまま返す(変更なし)
WP_Error そのまま返す(変更なし)

配列を返す場合

$data = array(
  'title' => 'Hello',
  'content' => 'This is a post'
);
// 配列を WP_REST_Response にラップして返す
return rest_ensure_response( $data );

オブジェクトを返す場合

$response_obj = new stdClass();
$response_obj->message = 'Success';
$response_obj->status = 200;
return rest_ensure_response( $response_obj );

WP_REST_Response を使って直接レスポンスを操作したい場合

$posts = array(
  array(
    'title' => 'Sample Post',
    'link'  => 'https://example.com/sample-post',
    'featured_image' => 'https://example.com/sample.jpg',
  ),
);

// レスポンスオブジェクトを作成し、ヘッダーを追加
$response = new WP_REST_Response( $posts, 200 );
$response->header( 'Cache-Control', 'max-age=3600' );

// 一貫性のために rest_ensure_response() を通して返す
return rest_ensure_response( $response );

エラーを返す場合(WP_Error)

REST API では、WP_Error を使ってエラーレスポンスを返すことができます。rest_ensure_response() を通すことで、正しい HTTP ステータスとともに返されます。

return rest_ensure_response( new WP_Error(
  'no_posts',
  '投稿が見つかりません',
  array( 'status' => 404 )
));

rest_ensure_response() を使うメリット

  • レスポンス形式の一貫性を保てる
  • 配列・オブジェクト・WP_Error に対応
  • WP_REST_Response の拡張性を活かせる(ヘッダー・ステータスコードなど)
  • 今はシンプルに使い、後から柔軟に拡張できる設計になる

permission_callback の指定について

register_rest_route() を使って REST API エンドポイントを登録する際、permission_callback を省略すると、**デフォルトでは「認証不要の公開 API」**として扱われます。

しかし、WordPress のデバッグモード(WP_DEBUG)を有効にしている場合、permission_callback を指定しないと、管理画面やソースコード上に次のような警告(Notice)が表示されます。

Notice:関数 register_rest_route が誤って呼び出されました。<br>public REST API ルートに対しては、パーミッションコールバックとして __return_true を使用してください。

これは WordPress 5.5.0 以降の変更によるもので、Changelog にも以下のように記載されています

Version 5.5.0 – 必須の permission_callback 引数が設定されていない場合に _doing_it_wrong() 通知を追加。

そのため、**認証不要のエンドポイント(公開 API)**として明示する場合でも、以下のように permission_callback に __return_true を明示的に指定することが推奨されます。

'permission_callback' => '__return_true'

認証が必要な場合の指定方法

一方、API に認証が必要な場合や、アクセス制限を設けたい場合は、permission_callback でユーザーの権限をチェックする必要があります。たとえば、投稿の編集権限を持つユーザーのみに限定したい場合は以下のように記述できます。

'permission_callback' => function() {
  return current_user_can('edit_posts'); // 投稿編集権限があるユーザーのみ
}

このように permission_callback は、API の公開・非公開を制御する重要なセキュリティ機能です。意図せずデータを誰にでも公開してしまうことがないように注意する必要があります。

ページネーション情報を取得

フロントエンドでの「Load More」やページネーションを実装するために必要な情報(投稿データと総ページ数など)を提供する、カスタム REST API エンドポイントを作成する例です。

例えば、以下のようなリクエストにより、2ページ目の投稿5件分のデータを JSON 形式で取得できます。

GET /wp-json/custom/v2/posts/?page=2&per_page=5

また、アイキャッチ画像のデータが不要な場合は、以下のようなリクエストにより、アイキャッチ画像の URL を取得しません。

GET wp-json/custom/v2/posts?page=2&per_page=5&images=false

以下のコールバック関数では、リクエストオブジェクト $request を受け取り、$request->get_param() メソッドを使って、per_page や page パラメータを取得し、WP_Query 用の引数を構築しています。

WP_REST_Request は REST API リクエスト全体を表すオブジェクトで、パラメータ・ヘッダー・メソッド・ルートなどにアクセスできます。

総件数やページ数を取得する必要があるため、この場合、WP_Query 用のクエリパラメータに 'no_found_rows' => true は指定しません。

また、register_rest_route() の args 配列(パラメータの定義)を使って、リクエストで受け取る各パラメータの説明、型、デフォルト値、バリデーション、サニタイズ処理を定義しています。

args の type や validate_callback、sanitize_callback を活用することで、コールバック関数内のコードを簡潔に保ちつつ、安全性も確保することができます。

// カスタム REST API エンドポイントのコールバック関数
function my_custom_get_posts_v2(WP_REST_Request $request) {
  // クエリパラメータから `per_page` を取得(デフォルトは10件)。最小値は1。
  $per_page = max(1, intval($request->get_param('per_page') ?: 10));
  // クエリパラメータから `page` を取得(デフォルトは1ページ目)。最小値は1。
  $paged = max(1, intval($request->get_param('page') ?: 1));
  // クエリパラメータから `images` を取得
  $show_images = $request->get_param('images');

  // WP_Query 用の引数を定義
  $args = array(
    'post_type'           => 'post',             // 投稿タイプ:通常の投稿
    'posts_per_page'      => $per_page,          // 1ページに表示する投稿数
    'paged'               => $paged,             // 現在のページ番号
    'ignore_sticky_posts' => true,               // スティッキーポスト(固定表示投稿)を無視する
  );

  // WP_Query により投稿データを取得
  $query = new WP_Query($args);

  // レスポンスに含める投稿データを格納する配列
  $posts_data = array();

  // 投稿が存在する場合、1件ずつループで処理
  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();
      // 投稿ごとのデータを配列に追加
      $post_data = array(
        'title'                   => esc_html(wp_strip_all_tags(get_the_title())),             // タイトル(HTMLタグ除去+エスケープ)
        'excerpt'                 => esc_html(wp_strip_all_tags(get_the_excerpt())),           // 抜粋(同上)
        'link'                    => esc_url(get_permalink()),                                 // 投稿のパーマリンク
      );
      // アイキャッチ画像 URL($show_images が true であれば)
      if ($show_images) {
        $post_data['featured_image_full'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'full'));     // アイキャッチ(フルサイズ)
        $post_data['featured_image_large'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'large'));     // アイキャッチ(ラージサイズ)
        $post_data['featured_image_medium'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'medium'));    // アイキャッチ(ミディアムサイズ)
        $post_data['featured_image_thumbnail'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'thumbnail'));  // アイキャッチ(サムネイルサイズ)
      }
      $posts_data[] = $post_data;
    }
    // クエリ後のグローバル変数をリセット(the_post によって上書きされたグローバルを元に戻す)
    wp_reset_postdata();
  }

  // API レスポンス用の配列を作成
  $response = array(
    'posts'        => $posts_data,           // 投稿データの配列
    'total_pages'  => $query->max_num_pages, // 総ページ数(WP_Query が提供)
    'current_page' => $paged,                // 現在のページ番号
    'per_page'     => $per_page,             // 1ページあたりの件数
  );

  // REST API レスポンスとして返却(適切な HTTP レスポンスに整形)
  return rest_ensure_response($response);
}

// カスタム REST API エンドポイントを登録
function register_my_custom_rest_route_v2() {
  // 'custom/v2/posts/' というエンドポイントを登録
  register_rest_route('custom/v2', '/posts/', array(
    'methods'             => WP_REST_Server::READABLE,      // HTTPメソッド:GET(及び HEAD)のみ許可
    'callback'            => 'my_custom_get_posts_v2', // データ取得のためのコールバック関数
    'permission_callback' => '__return_true',               // 認証は不要(全ユーザーが利用可能)
    // クエリパラメータの定義
    'args'                => array(
      'per_page' => array(
        'description' => '1ページあたりの取得件数',
        'type'        => 'integer',
        'required'    => false,
        'default'     => 10,
        'sanitize_callback' => 'absint', // 整数に変換
        'validate_callback' => function ($value) {
          return $value > 0 && $value <= 100; // 1〜100件まで許可
        },
      ),
      'page' => array(
        'description' => 'ページ番号',
        'type'        => 'integer',
        'required'    => false,
        'default'     => 1,
        'sanitize_callback' => 'absint',
        'validate_callback' => function ($value) {
          return $value > 0;
        },
      ),
      'images' => array(
        'description' => 'true でアイキャッチ画像 URL を含める(デフォルト)、false で除外',
        'type'        => 'boolean',
        'required'    => false,
        'default'     => true,
        'sanitize_callback' => 'rest_sanitize_boolean',  // 文字列を真偽値に変換
      ),
    ),
  ));
}
// REST API 初期化フックでエンドポイントを登録
add_action('rest_api_init', 'register_my_custom_rest_route_v2');

確認

上記のコードを記述して、以下の URL(example.com の部分は適宜変更)にアクセスすると、2ページ目の投稿3件分のデータと total_pages や current_page、per_page の値を JSON 形式で取得できます。

https://example.com/wp-json/custom/v1/posts/?page=2&per_page=3

WP_REST_Request $request

WP_REST_Request $request は、エンドポイントに送られたリクエストのすべての情報を取り扱うためのオブジェクトです。つまり、$_GET や $_POST のようなグローバル変数を使わず、REST API の形式に合った方法で安全に、柔軟に、リクエスト情報にアクセスする手段です。

WP_REST_Request オブジェクトは、WordPress が内部的に REST API 用に用意しているリクエスト情報のラッパークラスで、次のような情報をまとめて持っています。

  • クエリパラメータ(例: ?page=2)
  • パスパラメータ(例: /posts/123 の 123 など)
  • リクエストボディ(POST や PUT など)
  • ヘッダー情報
  • メソッド(GET / POST など)
よく使うメソッド
メソッド 説明
$request -> get_param('name') すべてのパラメータから 'name' を取得
$request -> get_query_params() クエリ(URL)パラメータのみを配列で取得
$request -> get_body_params() POST / PUT などのリクエストボディからパラメータを取得
$request -> get_json_params() JSON リクエストボディを配列で取得
$request -> get_headers() 全リクエストヘッダーを配列で取得
$request -> get_method() メソッド(GET / POST / PUTなど)を取得

例えば、以下のコードでは、?per_page=5 のように URL に含まれるパラメータを安全に取得しています。get_param() は、内部的に $_GET, $_POST, application/json など全てを統一して取り扱ってくれます。

$per_page = intval($request->get_param('per_page') ?: 10);

register_rest_route() の args

register_rest_route() の args は、各パラメータごとに詳細な説明・型情報・サニタイズ・バリデーションなどを設定できる機能です。

'args' => [
  'param_name' => [
    'description'       => 'パラメータの説明(APIドキュメント用)',
    'type'              => 'string' | 'integer' | 'boolean' | 'array' | 'object',
    'default'           => デフォルト値,
    'required'          => true / false,
    'enum'              => ['許容値1', '許容値2'], // 値の候補を制限
    'sanitize_callback' => 'コールバック関数名', // サニタイズ用
    'validate_callback' => 'コールバック関数名', // バリデーション用
  ]
]
各項目の解説
項目 説明
description API ドキュメントに表示される説明文(読み手向け)
type パラメータの型を定義(自動的に型チェックされる)。有効な値: 'string', 'integer', 'boolean', 'array', 'object' など。但し、厳密な型チェックをするには validate_callback を使用します。
default パラメータが省略されたときのデフォルト値
required 必須パラメータかどうか(true/false)
enum 指定した値以外は許可しない
sanitize_callback 値を適切に整形・変換して callback に渡すための関数(例:数値にキャストなど)
validate_callback 値が有効かどうかを検証する関数。有効であれば true を返して処理を継続し、無効であれば false を返して処理を中断します。sanitize_callback が定義されていれば、その後に実行されます。

パラメータ処理の流れ

WordPress REST API のパラメータ処理の流れは以下のようになっています。

  1. クライアントがリクエストを送る(例:?orderby=name&number=10)
  2. register_rest_route() の args 配列に定義された各パラメータごとに処理:
    1. sanitize_callback(あれば)を実行 → 戻り値を次に渡す
    2. そのサニタイズ済みの値を validate_callback に渡す (以下参照
    3. validate_callback が true を返せば通過、false または WP_Error を返せばエラーとして処理終了
  3. すべてのパラメータの検証が通過した場合に、エンドポイントの callback 関数が呼び出されます。このとき、callback 関数に渡される WP_REST_Request オブジェクトの中の値は、sanitize_callback が定義されていれば、その処理を経たサニタイズ済みの値となっています。

sanitize_callback から validate_callback へ渡される値

本来、sanitize_callback で加工された値は validate_callback に渡るのが望ましいですが、WordPress REST API の現在の実装(特に GET パラメータの処理)では、sanitize_callback の戻り値がそのまま validate_callback に渡らず、元の値のまま処理されてしまうことがあるようです。

このような仕様上の不整合があるため、場合によっては validate_callback 側でも値を整形・検証する必要があります。

使用例

'page' => [
  'description'       => 'ページ番号',
  'type'              => 'integer',
  'default'           => 1,
  'sanitize_callback' => 'absint', // 0以下を0にする整数キャスト
  'validate_callback' => function($value, $request, $param) {
    return is_numeric($value) && $value >= 1;
  },
],
'sort' => [
  'description' => 'ソート方法',
  'type'        => 'string',
  'enum'        => ['asc', 'desc'], // asc または desc のみ許可 ※ 入力ミスに厳しい(以下参照)
  'default'     => 'desc',
],

enum を使う場合の注意点

enum を使う場合、入力が完全に一致している必要があります。上記の場合、'asc' は OK ですが、'ASC' や 'Asc' は NG です(許容されません)。

REST API では、クライアント側の実装や外部からの呼び出しを考慮すると、大小文字の揺れを許容する方が現実的です。

例えば、以下のように sanitize_callback と validate_callback を使って、ASC や DESC のように大文字で渡された値も許容しつつ、安全に小文字に変換することができます。

'order' => array(
  'description'       => '昇順/降順(asc または desc)',
  'type'              => 'string',
  'required'          => false,
  'default'           => 'asc',
  'sanitize_callback' => function ($value) {
    // 小文字に変換
    return strtolower($value);
  },
  'validate_callback' => function ($value) {
    // 小文字に変換後の値が 'asc' または  'desc' なら OK
    return in_array(strtolower($value), ['asc', 'desc']);
  },
),
sanitize_callback

sanitize_callback には、値を適切に整形・変換して callback に渡すための関数を指定します。sanitize_callback でよく使われる関数には以下のようなものがあります。

関数名 用途 説明
sanitize_text_field テキスト HTMLタグ・改行などを除去したプレーンなテキスト。フォームの入力などに最適。
esc_html HTMLエスケープ 表示用の HTML エスケープ。基本的には出力用に使うことが多い。
esc_url_raw URL 保存時の URL サニタイズ。
rest_sanitize_boolean 真偽値 "true" や "false"、1 や 0 を true/false に変換。REST API に特化した関数
absint 正の整数 整数に変換し、負数は 0 に丸める。per_page や page などでよく使う。
intval 整数 整数化。負数も許容したい場合はこちらを使用。
sanitize_email メールアドレス 無効な形式の文字列は空文字にされる。
sanitize_user ユーザー名 ログイン名などに使用。
sanitize_key スラッグやキー 小文字英数字とアンダースコアに限定。オプション名や配列のキー向け。
wp_unslash スラッシュ除去 \ を取り除く。JSON データなどで使用。

但し、sanitize_callback でサニタイズした値が validate_callback に渡らない場合もあるので、場合によっては validate_callback 側でも値を整形・検証する必要があります。

以下は REST API での sanitize_callback の使用例です。

$args = array(
  'search' => array(
    'description' => '検索キーワード',
    'type'        => 'string',
    'sanitize_callback' => 'sanitize_text_field',
  ),
  'url' => array(
    'description' => 'リンク先URL',
    'type'        => 'string',
    'sanitize_callback' => 'esc_url_raw',
  ),
  'show_image' => array(
    'description' => 'アイキャッチを表示するか',
    'type'        => 'boolean',
    'sanitize_callback' => 'rest_sanitize_boolean',
  ),
  'per_page' => array(
    'description' => '1ページあたりの取得件数',
    'type'        => 'integer',
    'sanitize_callback' => 'absint',
  ),
);

カスタム関数も使用可能

独自のサニタイズ処理をしたいときは、無名関数やカスタム関数も設定できます。

'sanitize_callback' => function($param) {
  return strtoupper(sanitize_text_field($param)); // 例:大文字変換
}
validate_callback

validate_callback は、パラメータの値が「正しいか」「期待通りか」を検証し、正しければ true を返して次の処理(callback 関数)に進ませ、間違っていれば false または WP_Error を返してその時点でリクエスト処理を止めます。

以下は is_numeric() を使って値が数値なら true を返し、そうでなければ false を返す例です。

'validate_callback' => function($value) {
  return is_numeric($value); // 数値なら true を返し、そうでなければ false を返す
}

false を返すと、そのリクエストは 400 エラーになり、レスポンスに例えば以下のような汎用的なエラーメッセージが返されます。

{
  "code": "rest_invalid_param",
  "message": "Invalid parameter(s): include",
  "data": {
    "status": 400,
    "params": {
      "include": "include is not valid."
    }
  }
}

カスタムエラーメッセージを出したい場合は、WP_Error を返すこともできます。

'validate_callback' => function($value) {
  if (!is_array($value)) {
    return new WP_Error('rest_invalid_param', '配列で指定してください', array('status' => 400));
  }
  return true;
}
validate_callback の引数

validate_callback は、最大で3つの引数を受け取ることができます。

function ($value, $request, $param) {
  // バリデーション処理
}
引数 内容 よく使う用途
$value リクエストされたパラメータの値(サニタイズ後) 値の形式・型・範囲などのチェック
$request WP_REST_Request オブジェクト全体 他のパラメータとの関連チェックなど
$param 検証中のパラメータの名前(文字列) エラーログや汎用関数での条件分岐など

$value

sanitize_callback が定義されていれば、$value にはサニタイズ処理後の値が渡され、sanitize_callback が定義されていない場合は、リクエストから送られた値がそのまま渡されます(パラメータ処理の流れ)。

$request

第2引数の $request はリクエストオブジェクト(WP_REST_Request)で、全パラメータの値が参照でき、他のパラメータに依存するバリデーションに使用できます。

'order' => array(
  'validate_callback' => function ($value, $request) {
    // 他のパラメータを参照
    $orderby = $request->get_param('orderby');
    if ($orderby === 'count' && !in_array(strtolower($value), ['asc', 'desc'])) {
      return false;
    }
    return true;
  }
)

$param

第3引数の $param は現在検証中のパラメータの名前(文字列)になります。エラーメッセージの出し分けやログなどに利用できます。

'order' => array(
  'validate_callback' => function ($value, $request, $param) {
    if (!in_array(strtolower($value), ['asc', 'desc'])) {
      // この場合、$param には order が入ります
      error_log("Invalid value for {$param}: {$value}");
      return false;
    }
    return true;
  }
)

追加読み込みの実装(JavaScript)

作成したページネーション情報を提供するカスタムエンドポイントを使って、取得件数(per_page)より多い投稿がある場合は、Load More ボタンを表示してクリックすると追加で読み込むようにする例です。

fetch-posts クラスを指定した要素(ターゲット)に投稿リストを出力します。追加で load-more クラスが指定されていれば、Load More ボタンを表示し、show-no-image クラスが指定されていればアイキャッチ画像を出力しません。また、data-per-page 属性に投稿の取得件数を指定できます。

<div class="fetch-posts"></div>
<div class="fetch-posts load-more"></div><!-- Load More ボタンを表示 -->
<div class="fetch-posts load-more show-no-image"></div><!-- アイキャッチ画像非表示 -->
<div class="fetch-posts load-more" data-per-page="5"></div><!-- 取得件数を指定 -->

Load More ボタンを追加するので、構造を管理しやすいように JavaScript による出力を以下のように変更し、<div class="post-wrapper"> で投稿一覧の ul 要素と Load More ボタンの button 要素をラップして出力するようにします。

<div class="fetch-posts load-more"><!-- ターゲットの要素 -->
  <div class="post-wrapper">
    <ul class="post-items">
      <li class="post-item">...</li>
      <!-- 投稿アイテムが続く -->
    </ul>
    <div class="loading-indicator" style="display: none;">Loading...</div>
    <button class="fetch-load-more">Load More</button>
  </div>
</div>

JavaScript は以下のようになります。

この例の場合、ページ番号(page)と取得件数(per_page)がクエリごとに変わる設計なので、各 .fetch-posts 要素が個別に状態(現在のページ、表示件数など)を管理する必要があるため、先の例とは異なり、1回の fetch で複数の .fetch-posts 要素にデータを分配するのは難しいため、各 .fetch-posts 要素ごとに renderPosts() を独立して呼び出すようにします。

関数 renderPosts() はページ読み込み時と Load More ボタンをクリックする度に呼び出されるので、ラッパーの div 要素や投稿を出力する ul 要素、Load More ボタンの button 要素はページ読み込み時にのみ生成するようにします(すでに存在すればその要素を使用します)。

また、Load More ボタンへのクリックイベントのリスナーの登録が重複登録されないように、ボタン要素のカスタムデータ属性を使って登録済みかどうかを判定して登録します(183-199行目)。

その際、現在のページ(currentPage)の値は、クリックされる度に増加させ、カスタムデータ属を使って各ターゲット(投稿の出力先の要素)ごとに保存します(190-193行目)。

ローディングインジケーターは毎回 Load More ボタンの前に表示するように Load More ボタン作成後に wrapper.insertBefore() で Load More ボタンの前に挿入します(63行目)。そして fetch() 開始前に表示し、then() または catch() の完了後に非表示します。以下では .finally() を使って非表示にしています。

(() => {
  // HTML の文字列からプレーンテキストを抽出する関数
  const sanitizeHtml = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || "";
  };

  // 投稿を取得して表示する関数
  const renderPosts = (url, target, page = 1) => {
    if (!url) {
      console.error("無効な API URL");
      return;
    }

    // load-more クラスが含まれているかチェック
    let showLoadmore = target.classList.contains('load-more') ? true : false;
    // show-no-image クラスが含まれているかチェック
    const showNoImage = target.classList.contains("show-no-image");

    // post-wrapper(ul + ボタンのラッパー)を用意(すでに存在すればその要素を使用)
    let wrapper = target.querySelector(".post-wrapper");
    if (!wrapper) {
      wrapper = document.createElement("div");
      wrapper.className = "post-wrapper";
      target.appendChild(wrapper);
    }

    // ul 要素を作成または取得(すでに存在すればその要素を使用)
    let list = wrapper.querySelector("ul");
    if (!list) {
      list = document.createElement("ul");
      list.className = "post-items";
      wrapper.appendChild(list);
    }

    // Load More ボタンを取得
    let loadMoreButton = wrapper.querySelector(".fetch-load-more");
    if(showLoadmore && !loadMoreButton) {
      // showLoadmore が true で Load More ボタンがまだ作成されていなければ作成
      loadMoreButton = document.createElement("button");
      loadMoreButton.className = "fetch-load-more";
      loadMoreButton.textContent = "Load More";
      loadMoreButton.style.display = "none"; // 初期状態では非表示
      wrapper.appendChild(loadMoreButton);
    }

    // ローディングインジケーターを作成または取得(Load More ボタン作成後)
    let loadingIndicator = wrapper.querySelector(".loading-indicator");
    if (!loadingIndicator) {
      loadingIndicator = document.createElement("div");
      loadingIndicator.className = "loading-indicator";
      loadingIndicator.style.display = "none";
      const spinner = document.createElement("span");
      spinner.className = "spinner";
      const textNode = document.createTextNode("Loading...");
      loadingIndicator.appendChild(spinner);
      loadingIndicator.appendChild(textNode);
      if(showLoadmore) {
        // ここで Load More ボタンの前に挿入する(なければ最後に追加される)
        if (loadMoreButton) {
          wrapper.insertBefore(loadingIndicator, loadMoreButton);
        } else {
          wrapper.appendChild(loadingIndicator); // 念のため fallback
        }
      }
    }
    loadingIndicator.style.display = "block"; // ローディングインジケーターを表示

    // クエリパラメータ
    const queryParams = {
      // ターゲットの要素の data-per-page 属性が指定されていればその値を取得件数に(デフォルトは 10件。変更可能)
      per_page: target.dataset.perPage ? parseInt(target.dataset.perPage, 10) : 10,
      page: page, // ページ番号
    };

    // showNoImage が true(アイキャッチ画像非表示)のときのみ images に false を指定して追加
    if (showNoImage) {
      queryParams.images = false;
    }
    // オブジェクト形式のパラメータを URLSearchParams() でクエリ文字列に変換
    const params = new URLSearchParams(queryParams).toString();

    fetch(`${url}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(`リクエスト失敗: ${response.status} ${response.statusText}`);
        return response.json();
      })
      .then((data) => {
        // 総ページ数
        const totalPages = data.total_pages ? parseInt(data.total_pages, 10) : 1;
        // ターゲット要素のカスタムデータ属性に値を保存
        target.dataset.totalPages = totalPages;
        // 投稿データ
        const posts = data.posts;

        // 投稿が空( 0 件)だった場合の処理
        if (!Array.isArray(posts) || posts.length === 0) {
          list.innerHTML = "<li>投稿が見つかりませんでした。</li>";
          if (showLoadmore && loadMoreButton) {
            loadMoreButton.style.display = "none";
          }
          return;
        }

        // 初回はリストをクリア
        if (page === 1) list.innerHTML = "";

        // DOM フラグメントに一時的に要素を追加して、最後に一括して挿入することで DOM 操作を最小化
        const fragment = document.createDocumentFragment();

        posts.forEach((post) => {
          const link = post.link;
          const title = sanitizeHtml(post.title);
          const excerpt = sanitizeHtml(post.excerpt);
          // アイキャッチ画像のURL(存在すれば優先順で選択)。showNoImage が true(非表示)の場合は null に
          const imageUrl = showNoImage
          ? null
          : post.featured_image_medium ||
            post.featured_image_large ||
            post.featured_image_thumbnail ||
            post.featured_image_full ||
            undefined;

          // リンクが存在しない場合はスキップ
          if (!link) return;

          // 投稿アイテムの <li> 要素を作成
          const listItem = document.createElement("li");
          listItem.className = "post-item";

          // アイキャッチ画像部分の要素を作成
          if (imageUrl) {
            const imageWrapper = document.createElement("div");
            imageWrapper.className = "featured-image";
            const imageLink = document.createElement("a");
            imageLink.href = link;
            const img = document.createElement("img");
            img.src = imageUrl;
            img.alt = title;
            imageLink.appendChild(img);
            imageWrapper.appendChild(imageLink);
            listItem.appendChild(imageWrapper);
          }

          // 投稿タイトルを <h3> 要素として作成
          const postHeading = document.createElement("h3");
          postHeading.className = "post-title";
          const titleLink = document.createElement("a");
          titleLink.href = link;
          titleLink.textContent = title;
          postHeading.appendChild(titleLink);
          listItem.appendChild(postHeading);

          // 投稿の抜粋を <p> 要素として作成
          const excerptP = document.createElement("p");
          excerptP.className = "post-excerpt";
          excerptP.textContent = excerpt;
          listItem.appendChild(excerptP);
          // 作成した listItem を DocumentFragment に追加
          fragment.appendChild(listItem);
        });
        // 最後に一括して <ul> に追加(再描画を最小限に)
        list.appendChild(fragment);

        if(showLoadmore) {
          // Load More ボタンの表示切り替え(現在のページが総ページ数未満なら表示)
          loadMoreButton.style.display = (page < totalPages) ? "inline-block" : "none";
        }
      })
      .catch((error) => {
        console.warn("投稿の取得エラー:", error);
        list.innerHTML = `<li>投稿の取得に失敗しました:${error.message}</li>`;
      })
      .finally(() => {
        // 複数連打時の重複 fetch の防止フラグを解除
        target.dataset.isLoading = "false";
        loadingIndicator.style.display = "none"; // ローディングインジケーターをここで消す
      });

    if(showLoadmore) {
      // 重複イベント登録防止(既にイベントが登録済みかをチェック)
      if (!loadMoreButton.dataset.listenerAdded) {
        loadMoreButton.addEventListener("click", () => {
          // 複数連打時の重複 fetch の防止フラグ
          if (target.dataset.isLoading === "true") return;
          target.dataset.isLoading = "true"; // 防止フラグ
          let currentPage = parseInt(target.dataset.currentPage || "1", 10);
          currentPage++;
          // ページ番号(現在のページ)を更新し、各ターゲットごとに保存
          target.dataset.currentPage = currentPage;
          renderPosts(url, target, currentPage);
        });
        // 登録後に dataset.listenerAdded = "true" を設定(二重登録防止用)
        loadMoreButton.dataset.listenerAdded = "true";
      }
    }
  };

  // 初期化処理
  document.addEventListener("DOMContentLoaded", () => {
    // 新たなエンドポイント v2 を指定
    const url = "https://example.com/wp-json/custom/v2/posts";
    const targets = Array.from(document.getElementsByClassName("fetch-posts"));
    targets.forEach((target) => {
      target.dataset.currentPage = "1";
      renderPosts(url, target, 1);
    });
  });
})();

ローディングインジケーターの CSS

以下はローディングインジケーターとスピナーの CSS の例です。 border-top-color を変更すればスピナーの色をカスタマイズできます。

.load-more .loading-indicator {
  padding: 1em;
  font-size: 1rem;
  color: #555;
}

.load-more .spinner {
  display: inline-block;
  width: 1em;
  height: 1em;
  border: 2px solid #ccc;
  border-top-color: #333;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
  margin-right: 0.5em;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

例えば、以下のように表示されます(別途 CSS を適用しています)。

Load More ボタンをクリックして読込中ではローディングインジケーターが表示されます。

ページネーションの実装(JavaScript)

Load More ボタンを表示する代わりに、ページ番号のリンクを表示し、それをクリックすると該当ページの投稿を表示するシンプルなページネーション(ページナビゲーション)を実装する例です。

pagination クラスを指定した要素(ターゲット)に投稿リストを出力します。

<div class="fetch-posts"></div>
<div class="fetch-posts pagination"></div><!-- ページネーションを表示 -->
<div class="fetch-posts pagination show-no-image"></div><!-- アイキャッチ画像非表示 -->
<div class="fetch-posts pagination" data-per-page="5"></div><!-- 取得件数を指定 -->

JavaScript により出力される HTML 構造は以下のようになります。

Load More ボタンの代わりに ページナビゲーション(ul.pagination) を表示します。

ページを読み込む際にページナビゲーションがガクガクしないように、ローディングインジケーターはオーバーレイ表示するようにします(表示・非表示はクラスの着脱で切り替えます)。

<div class="fetch-posts-pagination"><!-- ターゲットの要素 -->
  <div class="post-wrapper">
    <ul class="post-items">
      <li class="post-item">...</li>
      <!-- 投稿アイテムが続く -->
    </ul>
    <div class="loading-indicator"><span class="spinner"></span>Loading...</div>
    <ul class="pagination">
      <li class="page-number current" aria-current="page"><span>1</span></li>
      <li class="page-number"><a href="#">2</a></li>
      <!-- ページ番号リンクが続く -->
    </ul>
  </div>
</div>

JavaScript は以下のようになります。

ページナビゲーションを表示・更新する関数 renderPagination() を追加して、ページ番号を表示しクリックでそのページの投稿を読み込みするようにします。

renderPagination() ではページ総数の数だけページ番号のリンクを表示し、クリックイベントを設定します。その際、現在のページを再読込する必要はないので、現在のページ番号は span 要素で作成し、current クラス と aria-current 属性を追加してクリックイベントは設定しません。

そして 投稿を取得して表示する関数 renderPosts() 内で(総ページ数が1より大きい場合は)ページネーション更新処理を呼び出します(170行目)。

renderPosts() では、ローディングインジケーターを .post-wrapper 内に絶対配置(position: absolute)でオーバーレイ表示するように .post-wrapper に position: relative を設定してオーバーレイの基準にしています(CSS 側で設定することもできます)。

ローディングインジケーターは display: block / none の代わりに classList.add/remove("active") で表示制御します

(() => {
  const sanitizeHtml = (html) => {
    const div = document.createElement("div");
    div.innerHTML = html;
    return div.textContent || "";
  };

  // ページナビゲーションを表示する関数を追加
  const renderPagination = (wrapper, totalPages, currentPage, url, target) => {
    // ページ数が1以下なら表示しない(呼び出し時に1より大きいことを確認しているので省略可能)
    if (totalPages <= 1) return;
    // ページナビゲーションの要素を作成または取得(すでに存在すればその要素を使用)
    let pagination = wrapper.querySelector(".pagination-wrapper");
    // 存在しなければ作成
    if (!pagination) {
      // ページナビゲーションの ul 要素を作成
      pagination = document.createElement("ul");
      pagination.className = "pagination-wrapper";
      // ラッパー要素にページナビゲーションを追加
      wrapper.appendChild(pagination);
    }
    pagination.innerHTML = "";

    // ページ総数の数だけページ番号のリンクを表示
    for (let i = 1; i <= totalPages; i++) {
      const li = document.createElement("li");
      li.className = "page-number";

      if (i === currentPage) {
        // 現在ページは <span> として表示、current クラスを追加
        li.classList.add("current");
        li.setAttribute("aria-current", "page")
        const span = document.createElement("span");
        span.textContent = i; // i はページ番号
        li.appendChild(span);
      } else {
        // 現在ページでない場合はリンクとして表示し、クリックイベントを設定
        const a = document.createElement("a");
        a.href = "#";
        a.textContent = i; // i はページ番号
        // クリックイベントを設定
        a.addEventListener("click", (e) => {
          e.preventDefault();
          renderPosts(url, target, i); // renderPosts() にページ番号を渡して呼び出す
        });
        li.appendChild(a);
      }
      // ページナビゲーションにページ番号のリンクを追加
      pagination.appendChild(li);
    }
  };

  // 投稿を取得して表示する関数
  const renderPosts = (url, target, page = 1) => {
    if (!url) {
      console.error("無効な API URL");
      return;
    }

    // pagination クラスが含まれているかチェック
    let showPagination = target.classList.contains('pagination') ? true : false;
    // show-no-image クラスが含まれているかチェック
    const showNoImage = target.classList.contains("show-no-image");

    let wrapper = target.querySelector(".post-wrapper");
    if (!wrapper) {
      wrapper = document.createElement("div");
      wrapper.className = "post-wrapper";
      wrapper.style.position = "relative"; // ローディングインジケーターのオーバーレイの基準に
      target.appendChild(wrapper);
    }

    let list = wrapper.querySelector("ul.post-items");
    if (!list) {
      list = document.createElement("ul");
      list.className = "post-items";
      wrapper.appendChild(list);
    }

    let loadingIndicator = wrapper.querySelector(".loading-indicator");
    if (!loadingIndicator) {
      loadingIndicator = document.createElement("div");
      loadingIndicator.className = "loading-indicator";
      const spinner = document.createElement("span");
      spinner.className = "spinner";
      const textNode = document.createTextNode("Loading...");
      loadingIndicator.appendChild(spinner);
      loadingIndicator.appendChild(textNode);
      wrapper.appendChild(loadingIndicator);
    }
    // ローディングインジケーターをオーバーレイ表示
    loadingIndicator.classList.add("active");

    const queryParams = {
      per_page: target.dataset.perPage ? parseInt(target.dataset.perPage, 10) : 10,
      page: page,
    };
    if (showNoImage) {
      queryParams.images = false;
    }
    const params = new URLSearchParams(queryParams).toString();

    fetch(`${url}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(`リクエスト失敗: ${response.status} ${response.statusText}`);
        return response.json();
      })
      .then((data) => {
        const posts = data.posts;
        const totalPages = data.total_pages ? parseInt(data.total_pages, 10) : 1;

        if (!Array.isArray(posts) || posts.length === 0) {
          list.innerHTML = "<li>投稿が見つかりませんでした。</li>";
          return;
        }

        list.innerHTML = "";

        const fragment = document.createDocumentFragment();

        posts.forEach((post) => {
          const link = post.link;
          const title = sanitizeHtml(post.title);
          const excerpt = sanitizeHtml(post.excerpt);
          const imageUrl = showNoImage
          ? null
          : post.featured_image_medium ||
            post.featured_image_large ||
            post.featured_image_thumbnail ||
            post.featured_image_full ||
            undefined;

          if (!link) return;

          const listItem = document.createElement("li");
          listItem.className = "post-item";

          if (imageUrl) {
            const imageWrapper = document.createElement("div");
            imageWrapper.className = "featured-image";
            const imageLink = document.createElement("a");
            imageLink.href = link;
            const img = document.createElement("img");
            img.src = imageUrl;
            img.alt = title;
            imageLink.appendChild(img);
            imageWrapper.appendChild(imageLink);
            listItem.appendChild(imageWrapper);
          }

          const postHeading = document.createElement("h3");
          postHeading.className = "post-title";
          const titleLink = document.createElement("a");
          titleLink.href = link;
          titleLink.textContent = title;
          postHeading.appendChild(titleLink);
          listItem.appendChild(postHeading);

          const excerptP = document.createElement("p");
          excerptP.className = "post-excerpt";
          excerptP.textContent = excerpt;
          listItem.appendChild(excerptP);
          fragment.appendChild(listItem);
        });
        list.appendChild(fragment);

        if(showPagination && totalPages > 1) {
          // ページナビゲーションを表示する関数を呼び出す
          renderPagination(wrapper, totalPages, page, url, target);
        }
      })
      .catch((err) => {
        list.innerHTML = `<li>投稿の取得に失敗しました: ${err.message}</li>`;
      })
      .finally(() => {
        // ローディングインジケーターを非表示に
        loadingIndicator.classList.remove("active");
      });
  };

  document.addEventListener("DOMContentLoaded", () => {
    const url = "https://example.com/wp-json/custom/v2/posts";
    const targets = Array.from(document.getElementsByClassName("fetch-posts"));
    targets.forEach((target) => {
      renderPosts(url, target, 1);
    });
  });
})();

CSS

以下はページナビゲーションやローディングインジケーター、スピナーの CSS の例です。

.pagination-wrapper {
  display: flex;
  gap: 0.5rem;
  list-style: none;
  padding: 0;
  margin-top: 1rem;
  flex-wrap: wrap;
}

.pagination-wrapper li a {
  padding: 0.4rem 0.8rem;
  text-decoration: none;
  border: 1px solid #ccc;
  border-radius: 4px;
  color: #333;
  transition: background-color 0.3s;
}

.pagination-wrapper li a:hover {
  background-color: #005f8d;
  color: #fff;
}

.pagination-wrapper li.current span {
  background-color: #76acc6;
  color: #fff;
  padding: 0.4rem 0.8rem;
  text-decoration: none;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.pagination-wrapper li.disabled a {
  color: #aaa;
  pointer-events: none;
}

.pagination-wrapper li.page-dots {
  padding: 0.4rem 0.8rem;
  color: #999;
}

.fetch-posts.pagination .loading-indicator {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.7);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 10;
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.fetch-posts.pagination .loading-indicator.active {
  opacity: 1;
  pointer-events: auto;
}

/* スピナーのスタイル */
.fetch-posts.pagination .spinner {
  width: 24px;
  height: 24px;
  border: 3px solid #ccc;
  border-top-color: #3498db;
  border-radius: 50%;
  animation: spin2 0.8s linear infinite;
}

/* スピンアニメーション(Load More ボタンの場合と同じ) */
@keyframes spin2 {
  to {
    transform: rotate(360deg);
  }
}

例えば、以下のように表示されます(別途 CSS を適用しています)。

読込中では以下のようにローディングインジケーターをオーバーレイ表示します。

Load More & ページネーション

load-more クラスを指定すると Load More ボタンを表示し、pagination クラスを指定するとページナビゲーションを表示する例です。これまで同様、show-no-image クラスを指定してアイキャッチ画像を非表示にすることも、data-per-page 属性に投稿の取得件数を指定することもできます。

<div class="fetch-posts load-more"></div><!-- Load More ボタンを表示 -->
<div class="fetch-posts pagination"></div><!-- ページネーションを表示 -->

以下が JavaScript です。

ページナビゲーションを表示する関数 renderPagination() は同じです。

投稿を取得して表示する関数 renderPosts() では、load-more クラスや pagination クラスが指定されているかをチェックし、モード(どちらを表示するか)を切り替えています。load-more クラスと pagination クラスが同時に指定されている場合は、無効とみなすようにしています。

currentPage の値は Load More のときは使用するので更新していますが、ページネーション時も(currentPage を使用していませんが)合わせて管理して一貫性が取れるようにしています。

また、ローディングインジケーターはオーバーレイ表示するように CSS で調整します。

(() => {
  const sanitizeHtml = (html) => {
    const div = document.createElement("div");
    div.innerHTML = html;
    return div.textContent || "";
  };

  // ページナビゲーションを表示する関数
  const renderPagination = (wrapper, totalPages, currentPage, url, target) => {
    if (totalPages <= 1) return;

    let pagination = wrapper.querySelector(".pagination-wrapper");
    if (!pagination) {
      pagination = document.createElement("ul");
      pagination.className = "pagination-wrapper";
      wrapper.appendChild(pagination);
    }
    pagination.innerHTML = "";

    for (let i = 1; i <= totalPages; i++) {
      const li = document.createElement("li");
      li.className = "page-number";

      if (i === currentPage) {
        li.classList.add("current");
        li.setAttribute("aria-current", "page")
        const span = document.createElement("span");
        span.textContent = i;
        li.appendChild(span);
      } else {
        const a = document.createElement("a");
        a.href = "#";
        a.textContent = i;
        a.addEventListener("click", (e) => {
          e.preventDefault();
          // Load More では currentPage を更新しているので、ページネーション時でも(使用していないが)合わせて管理
          target.dataset.currentPage = i;
          renderPosts(url, target, i);
        });
        li.appendChild(a);
      }
      pagination.appendChild(li);
    }
  };

  // 投稿を取得して表示する関数
  const renderPosts = (url, target, page = 1) => {
    if (!url) {
      console.error("無効な API URL");
      return;
    }
    let showLoadmore = target.classList.contains('load-more') ? true : false;
    let showPagination = target.classList.contains('pagination') ? true : false;
    const showNoImage = target.classList.contains("show-no-image");

    // ターゲットの要素に load-more と pagination クラスが同時に指定されていれば無効
    if(showLoadmore && showPagination) {
      showLoadmore = false;
      showPagination = false;
      console.warn('load-more クラスと pagination クラスは同時に指定できません。現在の指定は無効です。')
    }

    let wrapper = target.querySelector(".post-wrapper");
    if (!wrapper) {
      wrapper = document.createElement("div");
      wrapper.className = "post-wrapper";
      wrapper.style.position = "relative";
      target.appendChild(wrapper);
    }

    let list = wrapper.querySelector("ul.post-items");
    if (!list) {
      list = document.createElement("ul");
      list.className = "post-items";
      wrapper.appendChild(list);
    }

    let loadMoreButton = wrapper.querySelector(".fetch-load-more");
    if(showLoadmore && !loadMoreButton) {
      loadMoreButton = document.createElement("button");
      loadMoreButton.className = "fetch-load-more";
      loadMoreButton.textContent = "Load More";
      loadMoreButton.style.display = "none";
      wrapper.appendChild(loadMoreButton);
    }

    let loadingIndicator = wrapper.querySelector(".loading-indicator");
    if (!loadingIndicator) {
      loadingIndicator = document.createElement("div");
      loadingIndicator.className = "loading-indicator";
      const spinner = document.createElement("span");
      spinner.className = "spinner";
      loadingIndicator.appendChild(spinner);
      loadingIndicator.appendChild(document.createTextNode("Loading..."));
      wrapper.appendChild(loadingIndicator);
    }
    loadingIndicator.classList.add("active");

    const queryParams = {
      per_page: target.dataset.perPage ? parseInt(target.dataset.perPage, 10) : 10,
      page: page,
    };
    if (showNoImage) {
      queryParams.images = false;
    }
    const params = new URLSearchParams(queryParams).toString();

    fetch(`${url}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(`リクエスト失敗: ${response.status} ${response.statusText}`);
        return response.json();
      })
      .then((data) => {
        const posts = data.posts;
        const totalPages = data.total_pages ? parseInt(data.total_pages, 10) : 1;

        if (!Array.isArray(posts) || posts.length === 0) {
          list.innerHTML = "<li>投稿が見つかりませんでした。</li>";
          return;
        }

        // list をクリア(初回またはページネーション時)
        if (showPagination || page === 1) {
          list.innerHTML = "";
        }

        const fragment = document.createDocumentFragment();

        posts.forEach((post) => {
          const link = post.link;
          const title = sanitizeHtml(post.title);
          const excerpt = sanitizeHtml(post.excerpt);
          const imageUrl = showNoImage
          ? null
          : post.featured_image_medium ||
            post.featured_image_large ||
            post.featured_image_thumbnail ||
            post.featured_image_full ||
            undefined;

          if (!link) return;

          const listItem = document.createElement("li");
          listItem.className = "post-item";

          if (imageUrl) {
            const imageWrapper = document.createElement("div");
            imageWrapper.className = "featured-image";
            const imageLink = document.createElement("a");
            imageLink.href = link;
            const img = document.createElement("img");
            img.src = imageUrl;
            img.alt = title;
            imageLink.appendChild(img);
            imageWrapper.appendChild(imageLink);
            listItem.appendChild(imageWrapper);
          }

          const postHeading = document.createElement("h3");
          postHeading.className = "post-title";
          const titleLink = document.createElement("a");
          titleLink.href = link;
          titleLink.textContent = title;
          postHeading.appendChild(titleLink);
          listItem.appendChild(postHeading);

          const excerptP = document.createElement("p");
          excerptP.className = "post-excerpt";
          excerptP.textContent = excerpt;
          listItem.appendChild(excerptP);
          fragment.appendChild(listItem);
        });
        list.appendChild(fragment);

        if(showLoadmore) {
          // Load More ボタンの表示切り替え(現在のページが総ページ数未満なら表示)
          loadMoreButton.style.display = (page < totalPages) ? "inline-block" : "none";
        }

        if(showPagination && totalPages > 1) {
          // ページナビゲーションを表示する関数を呼び出す
          renderPagination(wrapper, totalPages, page, url, target);
        }
      })
      .catch((err) => {
        list.innerHTML = `<li>投稿の取得に失敗しました: ${err.message}</li>`;
      })
      .finally(() => {
        target.dataset.isLoading = "false";
        loadingIndicator.classList.remove("active");
      });

      if(showLoadmore) {
        if (!loadMoreButton.dataset.listenerAdded) {
          loadMoreButton.addEventListener("click", () => {
            if (target.dataset.isLoading === "true") return;
            target.dataset.isLoading = "true";
            let currentPage = parseInt(target.dataset.currentPage || "1", 10);
            currentPage++;
            target.dataset.currentPage = currentPage;
            renderPosts(url, target, currentPage);
          });
          loadMoreButton.dataset.listenerAdded = "true";
        }
      }
      // 現在のページを dataset に反映
      target.dataset.currentPage = page;
  };

  document.addEventListener("DOMContentLoaded", () => {
    const url = "https://example.com/wp-json/custom/v2/posts";
    const targets = Array.from(document.getElementsByClassName("fetch-posts"));
    targets.forEach((target) => {
      renderPosts(url, target, 1);
    });
  });
})();

以下はページネーションとローディングインジケーターの CSS です。

.pagination-wrapper {
  display: flex;
  gap: 0.5rem;
  list-style: none;
  padding: 0;
  margin-top: 1rem;
  flex-wrap: wrap;
}

.pagination-wrapper li a {
  padding: 0.4rem 0.8rem;
  text-decoration: none;
  border: 1px solid #ccc;
  border-radius: 4px;
  color: #333;
  transition: background-color 0.3s;
}

.pagination-wrapper li a:hover {
  background-color: #005f8d;
  color: #fff;
}

.pagination-wrapper li.current span {
  background-color: #76acc6;
  color: #fff;
  padding: 0.4rem 0.8rem;
  text-decoration: none;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.pagination-wrapper li.disabled a {
  color: #aaa;
  pointer-events: none;
}

.pagination-wrapper li.page-dots {
  padding: 0.4rem 0.8rem;
  color: #999;
}

.loading-indicator {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.7);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 10;
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.loading-indicator.active {
  opacity: 1;
  pointer-events: auto;
}

/* スピナーのスタイル */
.spinner {
  width: 24px;
  height: 24px;
  border: 3px solid #ccc;
  border-top-color: #3498db;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

/* スピンアニメーション */
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

投稿のカテゴリーやタグを取得

以下は、先に作成したカスタム REST API エンドポイントを拡張し、各投稿に属するカテゴリーやタグの情報を提供するようにしたコードです。さらに、投稿の ID・公開日・著者名などの基本情報も追加で返すようにしています。

カテゴリー情報は get_the_category()、タグ情報は get_the_tags() によって取得し、それぞれのタームオブジェクトから name、slug、リンク(URL)を抽出しています。これらのデータは array_map() を使って整形し、配列としてレスポンスに含めています。

なお、get_the_tags() はタグが設定されていない投稿に対して false を返すため、?: [] を使って空配列へフォールバックさせています。また、esc_html() や sanitize_title()、esc_url() によるエスケープ処理も適用して、出力の安全性を確保しています。

// カスタム REST API エンドポイントのコールバック関数
function my_custom_get_posts_v3(WP_REST_Request $request) {

  $per_page = max(1, intval($request->get_param('per_page') ?: 10));
  $paged = max(1, intval($request->get_param('page') ?: 1));
  $show_images = $request->get_param('images');

  $args = array(
    'post_type'           => 'post',
    'posts_per_page'      => $per_page,
    'paged'               => $paged,
    'ignore_sticky_posts' => true,
  );

  $query = new WP_Query($args);
  $posts_data = array();

  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();

      // カテゴリーの配列
      $categories = array_map(function ($cat) {
        return array(
          'label' => esc_html($cat->name),
          'slug' => sanitize_title($cat->slug),
          'link'  => esc_url(get_category_link($cat->term_id)),
        );
      }, get_the_category());

      // タグの配列
      $tags = array_map(function ($tag) {
        return array(
          'label' => esc_html($tag->name),
          'slug' => sanitize_title($tag->slug),
          'link'  => esc_url(get_tag_link($tag->term_id)),
        );
      }, get_the_tags() ?: []);

      // 投稿ごとのデータを配列に追加
      $post_data = array(
        'id'                       => get_the_ID(),  // 投稿ID
        'date'                     => get_the_date('c'),  // 公開日
        'author'                   => esc_html(get_the_author()),  // 著者名
        'title'                    => esc_html(wp_strip_all_tags(get_the_title())),
        'excerpt'                  => esc_html(wp_strip_all_tags(get_the_excerpt())),
        'link'                     => esc_url(get_permalink()),
        'categories'               => $categories,  // カテゴリーのデータ
        'tags'                     => $tags,  // タグのデータ
      );
      if ($show_images) {
        $post_data['featured_image_full'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'full'));
        $post_data['featured_image_large'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'large'));
        $post_data['featured_image_medium'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'medium'));
        $post_data['featured_image_thumbnail'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'thumbnail'));
      }
      $posts_data[] = $post_data;
    }
    wp_reset_postdata();
  }

  // API レスポンス用の配列を作成
  $response = array(
    'posts'        => $posts_data,
    'total_pages'  => $query->max_num_pages,
    'current_page' => $paged,
    'per_page'     => $per_page,
  );

  return rest_ensure_response($response);
}

// カスタム REST API エンドポイントを登録
function register_my_custom_rest_route_v3() {
  // 'custom/v3/posts/' というエンドポイントを登録
  register_rest_route('custom/v3', '/posts/', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'my_custom_get_posts_v3',
    'permission_callback' => '__return_true',
    'args'                => array(
      'per_page' => array(
        'description' => '1ページあたりの取得件数',
        'type'        => 'integer',
        'required'    => false,
        'default'     => 10,
        'sanitize_callback' => 'absint',
        'validate_callback' => function ($value) {
          return $value > 0 && $value <= 100;
        },
      ),
      'page' => array(
        'description' => 'ページ番号',
        'type'        => 'integer',
        'required'    => false,
        'default'     => 1,
        'sanitize_callback' => 'absint',
        'validate_callback' => function ($value) {
          return $value > 0;
        },
      ),
      'images' => array(
        'description' => 'true でアイキャッチ画像 URL を含める(デフォルト)、false で除外',
        'type'        => 'boolean',
        'required'    => false,
        'default'     => true,
        'sanitize_callback' => 'rest_sanitize_boolean',
      ),
    ),
  ));
}
add_action('rest_api_init', 'register_my_custom_rest_route_v3');

上記のコードを記述して、以下の URL(example.com の部分は適宜変更)にアクセスすると、カテゴリーやタグの情報(ラベル・スラッグ・リンク)、投稿の ID・公開日・著者名などの値を JSON 形式で取得できます。

https://example.com/wp-json/custom/v3/posts/

JavaScript(フロントエンド)

作成したカスタムエンドポイントを使って投稿のリストを表示する際に、投稿日やカテゴリーとタグのリンクも表示する例です。

HTML の指定はこれまでと同じです。show-no-image クラスを指定してアイキャッチ画像を非表示にしたり、data-per-page 属性に投稿の取得件数を指定することができます。

<div class="fetch-posts load-more"></div><!-- Load More ボタンを表示 -->
<div class="fetch-posts pagination"></div><!-- ページネーションを表示 -->

投稿のリストを表示する処理の部分を以下のように書き換えます。

投稿日の表示(57-58行目)では、new Date() に9行目で取得した日付情報(date)を渡して Date のインスタンスを生成し、toLocaleDateString() メソッドを使って日付フォーマットを日本語で表示しています。

カテゴリーリンクの表示(61-83行目)では、post.categories が配列であることをチェックし、forEach() を使って各カテゴリーに対してループ処理を行っています。

sanitizeHtml() でラベルのサニタイズ(XSS 対策)をし、77-79行目のカンマ区切りの処理(, の追加)は最後の項目を除外しています。タグリンクの表示も同様です。

const fragment = document.createDocumentFragment();

posts.forEach((post) => {
  const link = post.link;

  if (!link) return;

  const title = sanitizeHtml(post.title);
  const date = sanitizeHtml(post.date);  // 投稿日
  const excerpt = sanitizeHtml(post.excerpt);
  const imageUrl = showNoImage
  ? null
  : post.featured_image_medium ||
    post.featured_image_large ||
    post.featured_image_thumbnail ||
    post.featured_image_full ||
    undefined;

  const listItem = document.createElement("li");
  listItem.className = "post-item";

  if (imageUrl) {
    const imageWrapper = document.createElement("div");
    imageWrapper.className = "featured-image";
    const imageLink = document.createElement("a");
    imageLink.href = link;
    const img = document.createElement("img");
    img.src = imageUrl;
    img.alt = title;
    imageLink.appendChild(img);
    imageWrapper.appendChild(imageLink);
    listItem.appendChild(imageWrapper);
  }

  const postHeading = document.createElement("h3");
  postHeading.className = "post-title";
  const titleLink = document.createElement("a");
  titleLink.href = link;
  titleLink.textContent = title;
  postHeading.appendChild(titleLink);
  listItem.appendChild(postHeading);

  // 投稿のメタ情報を格納する div 要素
  const postMetaDiv = document.createElement("div");
  postMetaDiv.className = "post-meta";

  // 投稿日の表示
  const dateP = document.createElement("p");
  dateP.className = "post-date";
  // 表示形式のオプション
  const options = {
    year: "numeric",
    month: "long",
    day: "numeric",
  };
  // 日付フォーマットを日本語で表示(例: 2025年4月6日)
  dateP.textContent = `投稿日: ${new Date(date).toLocaleDateString("ja-JP", options)}`;
  postMetaDiv.appendChild(dateP);

  // カテゴリーリンクの表示
  if (Array.isArray(post.categories) && post.categories.length > 0) {
    // カテゴリーリンクをまとめるための div 要素を作成
    const categoryDiv = document.createElement("div");
    categoryDiv.className = "post-categories";
    // 見出しテキストを追加(例: "カテゴリ: ")
    categoryDiv.textContent = "カテゴリ: ";
    // 各カテゴリーに対してループ処理を行う
    post.categories.forEach((cat, index) => {
      // カテゴリーのリンク要素(<a>)を作成
      const catLink = document.createElement("a");
      catLink.href = cat.link;  // カテゴリーページへのURL
      catLink.textContent = sanitizeHtml(cat.label);  // カテゴリー名(サニタイズ)
      catLink.className = "post-category"; // CSS用のクラスを追加
      // div にリンクを追加
      categoryDiv.appendChild(catLink);
      // 最後の要素以外にはカンマ区切りを追加
      if (index < post.categories.length - 1) {
        categoryDiv.appendChild(document.createTextNode(", "));
      }
    });
    // 作成したカテゴリー情報を投稿リストアイテムに追加
    postMetaDiv.appendChild(categoryDiv);
  }

  // タグリンクの表示(カテゴリーと同様の処理)
  if (Array.isArray(post.tags) && post.tags.length > 0) {
    const tagDiv = document.createElement("div");
    tagDiv.className = "post-tags";
    tagDiv.textContent = "タグ: ";
    post.tags.forEach((tag, index) => {
      const tagLink = document.createElement("a");
      tagLink.href = tag.link;
      tagLink.textContent = sanitizeHtml(tag.label);
      tagLink.className = "post-tag";
      tagDiv.appendChild(tagLink);
      if (index < post.tags.length - 1) {
        tagDiv.appendChild(document.createTextNode(", "));
      }
    });
    postMetaDiv.appendChild(tagDiv);
  }
  // 投稿日、カテゴリー、タグを格納した postMetaDiv を listItem に追加
  listItem.appendChild(postMetaDiv);

  const excerptP = document.createElement("p");
  excerptP.className = "post-excerpt";
  excerptP.textContent = excerpt;
  listItem.appendChild(excerptP);
  fragment.appendChild(listItem);
});
list.appendChild(fragment);
(() => {
  const sanitizeHtml = (html) => {
    const div = document.createElement("div");
    div.innerHTML = html;
    return div.textContent || "";
  };

  // ページナビゲーションを表示する関数
  const renderPagination = (wrapper, totalPages, currentPage, url, target) => {
    if (totalPages <= 1) return;

    let pagination = wrapper.querySelector(".pagination-wrapper");
    if (!pagination) {
      pagination = document.createElement("ul");
      pagination.className = "pagination-wrapper";
      wrapper.appendChild(pagination);
    }
    pagination.innerHTML = "";

    for (let i = 1; i <= totalPages; i++) {
      const li = document.createElement("li");
      li.className = "page-number";

      if (i === currentPage) {
        li.classList.add("current");
        li.setAttribute("aria-current", "page")
        const span = document.createElement("span");
        span.textContent = i;
        li.appendChild(span);
      } else {
        const a = document.createElement("a");
        a.href = "#";
        a.textContent = i;
        a.addEventListener("click", (e) => {
          e.preventDefault();
          // Load More では currentPage を更新しているので、ページネーション時でも(使用していないが)合わせて管理
          target.dataset.currentPage = i;
          renderPosts(url, target, i);
        });
        li.appendChild(a);
      }
      pagination.appendChild(li);
    }
  };

  // 投稿を取得して表示する関数
  const renderPosts = (url, target, page = 1) => {
    if (!url) {
      console.error("無効な API URL");
      return;
    }
    let showLoadmore = target.classList.contains('load-more') ? true : false;
    let showPagination = target.classList.contains('pagination') ? true : false;
    const showNoImage = target.classList.contains("show-no-image");

    // ターゲットの要素に load-more と pagination クラスが同時に指定されていれば無効
    if(showLoadmore && showPagination) {
      showLoadmore = false;
      showPagination = false;
      console.warn('load-more クラスと pagination クラスは同時に指定できません。現在の指定は無効です。')
    }

    let wrapper = target.querySelector(".post-wrapper");
    if (!wrapper) {
      wrapper = document.createElement("div");
      wrapper.className = "post-wrapper";
      wrapper.style.position = "relative";
      target.appendChild(wrapper);
    }

    let list = wrapper.querySelector("ul.post-items");
    if (!list) {
      list = document.createElement("ul");
      list.className = "post-items";
      wrapper.appendChild(list);
    }

    let loadMoreButton = wrapper.querySelector(".fetch-load-more");
    if(showLoadmore && !loadMoreButton) {
      loadMoreButton = document.createElement("button");
      loadMoreButton.className = "fetch-load-more";
      loadMoreButton.textContent = "Load More";
      loadMoreButton.style.display = "none";
      wrapper.appendChild(loadMoreButton);
    }

    let loadingIndicator = wrapper.querySelector(".loading-indicator");
    if (!loadingIndicator) {
      loadingIndicator = document.createElement("div");
      loadingIndicator.className = "loading-indicator";
      const spinner = document.createElement("span");
      spinner.className = "spinner";
      loadingIndicator.appendChild(spinner);
      loadingIndicator.appendChild(document.createTextNode("Loading..."));
      wrapper.appendChild(loadingIndicator);
    }
    loadingIndicator.classList.add("active");

    const queryParams = {
      per_page: target.dataset.perPage ? parseInt(target.dataset.perPage, 10) : 10,
      page: page,
    };
    if (showNoImage) {
      queryParams.images = false;
    }
    const params = new URLSearchParams(queryParams).toString();

    fetch(`${url}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(`リクエスト失敗: ${response.status} ${response.statusText}`);
        return response.json();
      })
      .then((data) => {
        const posts = data.posts;
        const totalPages = data.total_pages ? parseInt(data.total_pages, 10) : 1;

        if (!Array.isArray(posts) || posts.length === 0) {
          list.innerHTML = "<li>投稿が見つかりませんでした。</li>";
          return;
        }

        // list をクリア(初回またはページネーション時)
        if (showPagination || page === 1) {
          list.innerHTML = "";
        }

        const fragment = document.createDocumentFragment();

        posts.forEach((post) => {
          const link = post.link;

          if (!link) return;

          const title = sanitizeHtml(post.title);
          const date = sanitizeHtml(post.date);  // 投稿日
          const excerpt = sanitizeHtml(post.excerpt);
          const imageUrl = showNoImage
          ? null
          : post.featured_image_medium ||
            post.featured_image_large ||
            post.featured_image_thumbnail ||
            post.featured_image_full ||
            undefined;

          const listItem = document.createElement("li");
          listItem.className = "post-item";

          if (imageUrl) {
            const imageWrapper = document.createElement("div");
            imageWrapper.className = "featured-image";
            const imageLink = document.createElement("a");
            imageLink.href = link;
            const img = document.createElement("img");
            img.src = imageUrl;
            img.alt = title;
            imageLink.appendChild(img);
            imageWrapper.appendChild(imageLink);
            listItem.appendChild(imageWrapper);
          }

          const postHeading = document.createElement("h3");
          postHeading.className = "post-title";
          const titleLink = document.createElement("a");
          titleLink.href = link;
          titleLink.textContent = title;
          postHeading.appendChild(titleLink);
          listItem.appendChild(postHeading);

          // 投稿のメタ情報を格納する div 要素
          const postMetaDiv = document.createElement("div");
          postMetaDiv.className = "post-meta";

          // 投稿日の表示
          const dateP = document.createElement("p");
          dateP.className = "post-date";
          // 表示形式のオプション
          const options = {
            year: "numeric",
            month: "long",
            day: "numeric",
          };
          // 日付フォーマットを日本語で表示(例: 2025年4月6日)
          dateP.textContent = `投稿日: ${new Date(date).toLocaleDateString("ja-JP", options)}`;
          postMetaDiv.appendChild(dateP);

          // カテゴリーリンクの表示
          if (Array.isArray(post.categories) && post.categories.length > 0) {
            // カテゴリーリンクをまとめるための div 要素を作成
            const categoryDiv = document.createElement("div");
            categoryDiv.className = "post-categories";
            // 見出しテキストを追加(例: "カテゴリ: ")
            categoryDiv.textContent = "カテゴリ: ";
            // 各カテゴリーに対してループ処理を行う
            post.categories.forEach((cat, index) => {
              // カテゴリーのリンク要素(<a>)を作成
              const catLink = document.createElement("a");
              catLink.href = cat.link;  // カテゴリーページへのURL
              catLink.textContent = sanitizeHtml(cat.label);  // カテゴリー名(サニタイズ)
              catLink.className = "post-category"; // CSS用のクラスを追加
              // div にリンクを追加
              categoryDiv.appendChild(catLink);
              // 最後の要素以外にはカンマ区切りを追加
              if (index < post.categories.length - 1) {
                categoryDiv.appendChild(document.createTextNode(", "));
              }
            });
            // 作成したカテゴリー情報を投稿リストアイテムに追加
            postMetaDiv.appendChild(categoryDiv);
          }

          // タグリンクの表示(カテゴリーと同様の処理)
          if (Array.isArray(post.tags) && post.tags.length > 0) {
            const tagDiv = document.createElement("div");
            tagDiv.className = "post-tags";
            tagDiv.textContent = "タグ: ";
            post.tags.forEach((tag, index) => {
              const tagLink = document.createElement("a");
              tagLink.href = tag.link;
              tagLink.textContent = sanitizeHtml(tag.label);
              tagLink.className = "post-tag";
              tagDiv.appendChild(tagLink);
              if (index < post.tags.length - 1) {
                tagDiv.appendChild(document.createTextNode(", "));
              }
            });
            postMetaDiv.appendChild(tagDiv);
          }
          // 投稿日、カテゴリー、タグを格納した postMetaDiv を listItem に追加
          listItem.appendChild(postMetaDiv);

          const excerptP = document.createElement("p");
          excerptP.className = "post-excerpt";
          excerptP.textContent = excerpt;
          listItem.appendChild(excerptP);
          fragment.appendChild(listItem);
        });
        list.appendChild(fragment);

        if(showLoadmore) {
          // Load More ボタンの表示切り替え(現在のページが総ページ数未満なら表示)
          loadMoreButton.style.display = (page < totalPages) ? "inline-block" : "none";
        }

        if(showPagination && totalPages > 1) {
          // ページナビゲーションを表示する関数を呼び出す
          renderPagination(wrapper, totalPages, page, url, target);
        }
      })
      .catch((err) => {
        list.innerHTML = `<li>投稿の取得に失敗しました: ${err.message}</li>`;
      })
      .finally(() => {
        target.dataset.isLoading = "false";
        loadingIndicator.classList.remove("active");
      });

      if(showLoadmore) {
        if (!loadMoreButton.dataset.listenerAdded) {
          loadMoreButton.addEventListener("click", () => {
            if (target.dataset.isLoading === "true") return;
            target.dataset.isLoading = "true";
            let currentPage = parseInt(target.dataset.currentPage || "1", 10);
            currentPage++;
            target.dataset.currentPage = currentPage;
            renderPosts(url, target, currentPage);
          });
          loadMoreButton.dataset.listenerAdded = "true";
        }
      }
      // 現在のページを dataset に反映
      target.dataset.currentPage = page;
  };

  document.addEventListener("DOMContentLoaded", () => {
    const url = "https://example.com/wp-json/custom/v3/posts";
    const targets = Array.from(document.getElementsByClassName("fetch-posts"));
    targets.forEach((target) => {
      renderPosts(url, target, 1);
    });
  });
})();

例えば、以下のように表示されます。投稿日、カテゴリー、タグ(.post-date, .post-categories, .post-tags)を inline-block にするなど、別途 CSS を適用しています。

カテゴリーやタグを指定

カテゴリーやタグのスラッグを指定して投稿データを取得できるカスタム REST API エンドポイントを定義します。例えば、以下のようなリクエストでカテゴリーが music、タグが jazz の投稿を取得できます。

GET /wp-json/custom/v4/posts/?category=music&tag=jazz

  • カテゴリーとタグは スラッグで指定し、カンマ区切りで複数の値を指定できます。
  • どちらか一方のみの指定や、両方の省略も可能で、省略時はすべての投稿が対象になります。

コールバック関数(my_custom_get_posts_v4)では以下の処理を行います。

  • リクエストから category と tag のスラッグを取得。
  • 指定されたスラッグが実在するかどうかを検証し、存在しないスラッグが含まれている場合、HTTP 400 エラーを返します。
  • 有効なスラッグのみで tax_query を構築し、WP_Query の引数に追加。
  • クエリ結果から投稿情報を整形し、カテゴリーやタグ情報、アイキャッチ画像、ページネーション情報などを含んだ JSON レスポンスを返します。

指定されたスラッグが実在するかどうかの検証では、get_terms() を使って指定されたスラッグに対応する既存のカテゴリーを取得し、array_diff() を使って不正なスラッグを検出(第1引数で指定した配列にはあって、第2引数にはない値を検出)しています。エラーメッセージには 不正なスラッグ名を表示するので、フロントエンド側で原因の特定がしやすくなっています。

投稿の取得では、WP_Query パラメータに tax_query を使うことで、カテゴリーやタグなどのタクソノミー条件を柔軟かつ安全に絞り込むことができます。複数条件の組み合わせや AND / OR ロジックも扱えるため、カスタマイズ性の高いクエリが実現できます。

※ tax_query は、1つ以上のタクソノミー条件(例:カテゴリー、タグなど)を渡すための 多次元配列(配列の中に複数の条件配列)です(Developer Resources: tax_query)。

また、register_rest_route() の args パラメータで、category、tag のクエリパラメータの説明や型を定義しています。

// カスタム REST API エンドポイントのコールバック関数
function my_custom_get_posts_v4(WP_REST_Request $request) {

  // カテゴリースラッグの取得
  $category_param = $request->get_param('category');
  // タグスラッグの取得
  $tag_param = $request->get_param('tag');

  $per_page = max(1, intval($request->get_param('per_page') ?: 10));
  $paged = max(1, intval($request->get_param('page') ?: 1));
  $show_images = $request->get_param('images');

  // WP_Query 用の引数を定義
  $args = array(
    'post_type'           => 'post',
    'posts_per_page'      => $per_page,
    'paged'               => $paged,
    'ignore_sticky_posts' => true,
  );

  // タクソノミークエリ(tax_query の準備)
  $tax_query = array();

  // カテゴリー
  if (!empty($category_param)) {
    // カンマで区切ってスラッグの配列に変換し、前後の空白を除去
    $category_slugs = array_map('trim', explode(',', $category_param));
    // 指定されたスラッグに対応する既存のカテゴリーを取得(存在するものだけ)
    $existing_categories = get_terms(array(
      'taxonomy'   => 'category', // カテゴリータクソノミーを指定
      'slug'       => $category_slugs, // スラッグで絞り込み
      'fields'     => 'slugs', // 結果はスラッグのみ取得(スラッグの配列)
      'hide_empty' => false, // 投稿数0でも取得する
    ));
    // 存在しないカテゴリーを抽出(送信されたスラッグと、取得されたスラッグの差分)
    $invalid_categories = array_diff($category_slugs, $existing_categories);
    // 存在しないカテゴリーがあれば、エラーとして返す(HTTP 400)
    if (!empty($invalid_categories)) {
      return new WP_Error('invalid_category', '存在しないカテゴリー: ' . implode(', ', $invalid_categories), array('status' => 400));
    }

    // tax_query にカテゴリー条件を追加(複数指定に対応)
    $tax_query[] = array(
      'taxonomy' => 'category', // 対象のタクソノミー
      'field'    => 'slug',   // スラッグで絞り込む
      'terms'    => $category_slugs,  // 指定されたカテゴリーすべてを対象
      'operator' => 'IN',    // いずれかに一致すればOK
    );
  }

  // タグ (カテゴリーと同様の処理)
  if (!empty($tag_param)) {
    $tag_slugs = array_map('trim', explode(',', $tag_param));
    $existing_tags = get_terms(array(
      'taxonomy'   => 'post_tag', // タグタクソノミー(post_tag)を指定
      'slug'       => $tag_slugs,
      'fields'     => 'slugs',
      'hide_empty' => false,
    ));
    $invalid_tags = array_diff($tag_slugs, $existing_tags);
    // 存在しないスラッグが含まれていたらエラー
    if (!empty($invalid_tags)) {
      return new WP_Error('invalid_tag', '存在しないタグ: ' . implode(', ', $invalid_tags), array('status' => 400));
    }

    // tax_query にタグ条件を追加(複数指定に対応)
    $tax_query[] = array(
      'taxonomy' => 'post_tag',
      'field'    => 'slug',
      'terms'    => $tag_slugs,
      'operator' => 'IN',
    );
  }

  // $tax_query が空でなければタクソノミークエリを追加
  if (!empty($tax_query)) {
    $args['tax_query'] = $tax_query;
  }

  $query = new WP_Query($args);
  $posts_data = array();

  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();

      $categories = array_map(function ($cat) {
        return array(
          'label' => esc_html($cat->name),
          'slug' => sanitize_title($cat->slug),
          'link'  => esc_url(get_category_link($cat->term_id)),
        );
      }, get_the_category());

      $tags = array_map(function ($tag) {
        return array(
          'label' => esc_html($tag->name),
          'slug' => sanitize_title($tag->slug),
          'link'  => esc_url(get_tag_link($tag->term_id)),
        );
      }, get_the_tags() ?: []);

      $post_data = array(
        'id'                       => get_the_ID(),
        'date'                     => get_the_date('c'),
        'author'                   => esc_html(get_the_author()),
        'title'                    => esc_html(wp_strip_all_tags(get_the_title())),
        'excerpt'                  => esc_html(wp_strip_all_tags(get_the_excerpt())),
        'link'                     => esc_url(get_permalink()),
        'categories'               => $categories,
        'tags'                     => $tags,
      );
      if ($show_images) {
        $post_data['featured_image_full'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'full'));
        $post_data['featured_image_large'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'large'));
        $post_data['featured_image_medium'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'medium'));
        $post_data['featured_image_thumbnail'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'thumbnail'));
      }
      $posts_data[] = $post_data;
    }
    wp_reset_postdata();
  }
  $response = array(
    'posts'        => $posts_data,
    'total_pages'  => $query->max_num_pages,
    'current_page' => $paged,
    'per_page'     => $per_page,
  );
  return rest_ensure_response($response);
}

// カスタム REST API エンドポイントを登録
function register_my_custom_rest_route_v4() {
  // 'custom/v4/posts/' というエンドポイントを登録
  register_rest_route('custom/v4', '/posts/', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'my_custom_get_posts_v4',
    'permission_callback' => '__return_true',
    'args'                => array(
      // カテゴリーを追加
      'category' => array(
        'description' => 'カテゴリースラッグ(カンマ区切り対応)',
        'type'        => 'string',
        'required'    => false,
      ),
      // タグを追加
      'tag' => array(
        'description' => 'タグスラッグ(カンマ区切り対応)',
        'type'        => 'string',
        'required'    => false,
      ),
      'per_page' => array(
        'description' => '1ページあたりの取得件数',
        'type'        => 'integer',
        'required'    => false,
        'default'     => 10,
        'sanitize_callback' => 'absint',
        'validate_callback' => function ($value) {
          return $value > 0 && $value <= 100;
        },
      ),
      'page' => array(
        'description' => 'ページ番号',
        'type'        => 'integer',
        'required'    => false,
        'default'     => 1,
        'sanitize_callback' => 'absint',
        'validate_callback' => function ($value) {
          return $value > 0;
        },
      ),
      'images' => array(
        'description' => 'true でアイキャッチ画像 URL を含める(デフォルト)、false で除外',
        'type'        => 'boolean',
        'required'    => false,
        'default'     => true,
        'sanitize_callback' => 'rest_sanitize_boolean',
      ),
    ),
  ));
}
add_action('rest_api_init', 'register_my_custom_rest_route_v4');

上記のコードを記述して、以下の URL(example.com の部分は適宜変更)にアクセスすると、カテゴリーが music でタグが jazz の投稿データを取得できます。

https://example.com/wp-json/custom/v4/posts/?category=music&tag=jazz

存在しないカテゴリーやタグを指定するとエラーが返ります。

JavaScript(フロントエンド)

fetch-posts クラスを指定した要素に data-category や data-tag 属性にスラッグを指定して(カンマ区切りで複数指定可能)、カテゴリーやタグで絞り込むことができるようにします。

例えば、以下のように HTML を指定すると、カテゴリーが music で タグが contemporary の投稿が出力されます。これまで同様、show-no-image クラスや data-per-page 属性を指定できます。

<div class="fetch-posts load-more" data-category="music" data-tag="contemporary">
  <h3>Music/Contemporary</h3>
</div>

ターゲットの要素の data-category 属性と data-tag 属性の値を取得して、値が指定されていれば、クエリのパラメータ category、tag に設定して fetch() に渡します。

const queryParams = {
  per_page: target.dataset.perPage ? parseInt(target.dataset.perPage, 10) : 10,
  page: page,
};
if (showNoImage)  queryParams.images = false;

// ターゲットの要素の data-category 属性が指定されていればパラメータに追加
const category = target.dataset.category;
if (category) queryParams.category = category;

// ターゲットの要素の data-tag 属性が指定されていればパラメータに追加
const tag = target.dataset.tag;
if (tag) queryParams.tag = tag;

const params = new URLSearchParams(queryParams).toString();

以下がコード全体です。エンドポイントの URL と上記の部分以外は先述の JavaScript と同じです。

(() => {
  const sanitizeHtml = (html) => {
    const div = document.createElement("div");
    div.innerHTML = html;
    return div.textContent || "";
  };

  // ページナビゲーションを表示する関数
  const renderPagination = (wrapper, totalPages, currentPage, url, target) => {
    if (totalPages <= 1) return;

    let pagination = wrapper.querySelector(".pagination-wrapper");
    if (!pagination) {
      pagination = document.createElement("ul");
      pagination.className = "pagination-wrapper";
      wrapper.appendChild(pagination);
    }
    pagination.innerHTML = "";

    for (let i = 1; i <= totalPages; i++) {
      const li = document.createElement("li");
      li.className = "page-number";

      if (i === currentPage) {
        li.classList.add("current");
        li.setAttribute("aria-current", "page")
        const span = document.createElement("span");
        span.textContent = i;
        li.appendChild(span);
      } else {
        const a = document.createElement("a");
        a.href = "#";
        a.textContent = i;
        a.addEventListener("click", (e) => {
          e.preventDefault();
          target.dataset.currentPage = i;
          renderPosts(url, target, i);
        });
        li.appendChild(a);
      }
      pagination.appendChild(li);
    }
  };

  // 投稿を取得して表示する関数
  const renderPosts = (url, target, page = 1) => {
    if (!url) {
      console.error("無効な API URL");
      return;
    }
    let showLoadmore = target.classList.contains('load-more') ? true : false;
    let showPagination = target.classList.contains('pagination') ? true : false;
    const showNoImage = target.classList.contains("show-no-image");

    if(showLoadmore && showPagination) {
      showLoadmore = false;
      showPagination = false;
      console.warn('load-more クラスと pagination クラスは同時に指定できません。現在の指定は無効です。')
    }

    let wrapper = target.querySelector(".post-wrapper");
    if (!wrapper) {
      wrapper = document.createElement("div");
      wrapper.className = "post-wrapper";
      wrapper.style.position = "relative";
      target.appendChild(wrapper);
    }

    let list = wrapper.querySelector("ul.post-items");
    if (!list) {
      list = document.createElement("ul");
      list.className = "post-items";
      wrapper.appendChild(list);
    }

    let loadMoreButton = wrapper.querySelector(".fetch-load-more");
    if(showLoadmore && !loadMoreButton) {
      loadMoreButton = document.createElement("button");
      loadMoreButton.className = "fetch-load-more";
      loadMoreButton.textContent = "Load More";
      loadMoreButton.style.display = "none";
      wrapper.appendChild(loadMoreButton);
    }

    let loadingIndicator = wrapper.querySelector(".loading-indicator");
    if (!loadingIndicator) {
      loadingIndicator = document.createElement("div");
      loadingIndicator.className = "loading-indicator";
      const spinner = document.createElement("span");
      spinner.className = "spinner";
      loadingIndicator.appendChild(spinner);
      loadingIndicator.appendChild(document.createTextNode("Loading..."));
      wrapper.appendChild(loadingIndicator);
    }
    loadingIndicator.classList.add("active");

    const queryParams = {
      per_page: target.dataset.perPage ? parseInt(target.dataset.perPage, 10) : 10,
      page: page,
    };
    if (showNoImage)  queryParams.images = false;

    // ターゲットの要素の data-category 属性が指定されていればパラメータに追加
    const category = target.dataset.category;
    if (category) queryParams.category = category;

    // ターゲットの要素の data-tag 属性が指定されていればパラメータに追加
    const tag = target.dataset.tag;
    if (tag) queryParams.tag = tag;

    const params = new URLSearchParams(queryParams).toString();

    fetch(`${url}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(`リクエスト失敗: ${response.status} ${response.statusText}`);
        return response.json();
      })
      .then((data) => {
        const posts = data.posts;
        const totalPages = data.total_pages ? parseInt(data.total_pages, 10) : 1;

        if (!Array.isArray(posts) || posts.length === 0) {
          list.innerHTML = "<li>投稿が見つかりませんでした。</li>";
          return;
        }

        if (showPagination || page === 1) {
          list.innerHTML = "";
        }

        const fragment = document.createDocumentFragment();

        posts.forEach((post) => {
          const link = post.link;
          if (!link) return;
          const title = sanitizeHtml(post.title);
          const date = sanitizeHtml(post.date);
          const excerpt = sanitizeHtml(post.excerpt);
          const imageUrl = showNoImage
          ? null
          : post.featured_image_medium ||
            post.featured_image_large ||
            post.featured_image_thumbnail ||
            post.featured_image_full ||
            undefined;

          const listItem = document.createElement("li");
          listItem.className = "post-item";

          if (imageUrl) {
            const imageWrapper = document.createElement("div");
            imageWrapper.className = "featured-image";
            const imageLink = document.createElement("a");
            imageLink.href = link;
            const img = document.createElement("img");
            img.src = imageUrl;
            img.alt = title;
            imageLink.appendChild(img);
            imageWrapper.appendChild(imageLink);
            listItem.appendChild(imageWrapper);
          }

          const postHeading = document.createElement("h3");
          postHeading.className = "post-title";
          const titleLink = document.createElement("a");
          titleLink.href = link;
          titleLink.textContent = title;
          postHeading.appendChild(titleLink);
          listItem.appendChild(postHeading);

          const postMetaDiv = document.createElement("div");
          postMetaDiv.className = "post-meta";

          const dateP = document.createElement("p");
          dateP.className = "post-date";
          const options = {
            year: "numeric",
            month: "long",
            day: "numeric",
          };
          dateP.textContent = `投稿日: ${new Date(date).toLocaleDateString("ja-JP", options)}`;
          postMetaDiv.appendChild(dateP);

          if (Array.isArray(post.categories) && post.categories.length > 0) {
            const categoryDiv = document.createElement("div");
            categoryDiv.className = "post-categories";
            categoryDiv.textContent = "カテゴリ: ";
            post.categories.forEach((cat, index) => {
              const catLink = document.createElement("a");
              catLink.href = cat.link;
              catLink.textContent = sanitizeHtml(cat.label);
              catLink.className = "post-category";
              categoryDiv.appendChild(catLink);
              if (index < post.categories.length - 1) {
                categoryDiv.appendChild(document.createTextNode(", "));
              }
            });
            postMetaDiv.appendChild(categoryDiv);
          }

          if (Array.isArray(post.tags) && post.tags.length > 0) {
            const tagDiv = document.createElement("div");
            tagDiv.className = "post-tags";
            tagDiv.textContent = "タグ: ";
            post.tags.forEach((tag, index) => {
              const tagLink = document.createElement("a");
              tagLink.href = tag.link;
              tagLink.textContent = sanitizeHtml(tag.label);
              tagLink.className = "post-tag";
              tagDiv.appendChild(tagLink);
              if (index < post.tags.length - 1) {
                tagDiv.appendChild(document.createTextNode(", "));
              }
            });
            postMetaDiv.appendChild(tagDiv);
          }
          listItem.appendChild(postMetaDiv);

          const excerptP = document.createElement("p");
          excerptP.className = "post-excerpt";
          excerptP.textContent = excerpt;
          listItem.appendChild(excerptP);
          fragment.appendChild(listItem);
        });
        list.appendChild(fragment);

        if(showLoadmore) {
          loadMoreButton.style.display = (page < totalPages) ? "inline-block" : "none";
        }

        if(showPagination && totalPages > 1) {
          renderPagination(wrapper, totalPages, page, url, target);
        }
      })
      .catch((err) => {
        list.innerHTML = `<li>投稿の取得に失敗しました: ${err.message}</li>`;
      })
      .finally(() => {
        target.dataset.isLoading = "false";
        loadingIndicator.classList.remove("active");
      });

      if(showLoadmore) {
        if (!loadMoreButton.dataset.listenerAdded) {
          loadMoreButton.addEventListener("click", () => {
            if (target.dataset.isLoading === "true") return;
            target.dataset.isLoading = "true";
            let currentPage = parseInt(target.dataset.currentPage || "1", 10);
            currentPage++;
            target.dataset.currentPage = currentPage;
            renderPosts(url, target, currentPage);
          });
          loadMoreButton.dataset.listenerAdded = "true";
        }
      }
      target.dataset.currentPage = page;
  };

  document.addEventListener("DOMContentLoaded", () => {
    const url = "https://example.com/wp-json/custom/v4/posts";
    const targets = Array.from(document.getElementsByClassName("fetch-posts"));
    targets.forEach((target) => {
      renderPosts(url, target, 1);
    });
  });
})();

以下は data-category="music" data-tag="contemporary" を指定してカテゴリーが music、タグが contemporary を含む投稿を表示した例です。

カテゴリーとタグの情報を取得

全てのカテゴリーとタグの情報(id, name など)を返すカスタムエンドポイントを作成する例です。

これまでに作成した /custom/v4/posts/ のレスポンスにカテゴリーとタグの情報を含めることもできますが、API レスポンスが肥大化しがちで、将来的に「カテゴリーだけ取得したい」「タグだけ更新したい」といった要件が出たとき、柔軟性に欠けるデメリットがあります。

そのため、別途 /custom/v4/categories/ と /custom/v4/tags/ のエンドポイントを作成します。

構成

  • /custom/v4/posts/ (作成済み)
    • 投稿情報と「その投稿に含まれるカテゴリー・タグ」を提供
  • /custom/v4/categories/
    • サイト全体のカテゴリを提供(id, name, slug, count など)
  • /custom/v4/tags/
    • サイト全体のタグを提供(id, name, slug, count など)

ネームスペース(custom/v4)やルート(posts や categories)部分は必要に応じて適宜変更します。

カテゴリーやタグの情報(id, name, slug など)を返すには、get_terms() 関数を使って各タクソノミーのタームを取得し、それらを REST API のレスポンスとして返すことができます。

以下は、カテゴリー(category)とタグ(post_tag)のターム情報を個別のエンドポイントで返すシンプルなカスタム REST API の例です。

このコードでは、get_terms() を使ってタームを取得し、array_map() で各タームから必要な情報(ID, 名前, スラッグ)を抽出。esc_html() や sanitize_title() を使ってエスケープ処理を施し、整形した配列をレスポンスとして返しています。

// カテゴリー一覧を取得して JSON レスポンスとして返す関数
function custom_get_categories() {
  // カテゴリータクソノミーの全タームを取得(投稿数が0のものも含む)
  $terms = get_terms(array(
    'taxonomy'   => 'category',
    'hide_empty' => false,
  ));

  // タームの配列から必要な情報(ID、名前、スラッグ)のみを抽出
  $data = array_map(function($term) {
    return array(
      'id'   => intval($term->term_id), // ID は整数化
      'name' => esc_html($term->name), // 名前をHTMLエスケープ
      'slug' => sanitize_title($term->slug), // スラッグはサニタイズ(スラッグに適した形式に整える)
    );
  }, $terms);

  // REST API 用のレスポンスとして返す
  return rest_ensure_response($data);
}

// タグ一覧を取得して JSON レスポンスとして返す関数
function custom_get_tags() {
  // タグタクソノミーの全タームを取得(投稿数が0のものも含む)
  $terms = get_terms(array(
    'taxonomy'   => 'post_tag',
    'hide_empty' => false,
  ));

  // タームの配列から必要な情報(ID、名前、スラッグ)のみを抽出
  $data = array_map(function($term) {
    return array(
      'id'   => intval($term->term_id),
      'name' => esc_html($term->name),
      'slug' => sanitize_title($term->slug),
    );
  }, $terms);

  // REST API 用のレスポンスとして返す
  return rest_ensure_response($data);
}

// カスタム REST API エンドポイントを登録する
add_action('rest_api_init', function () {
  // /wp-json/custom/v1/categories/ エンドポイントを登録(GET リクエストに対応)
  register_rest_route('custom/v1', '/categories/', array(
    'methods' => WP_REST_Server::READABLE, // GET メソッド
    'callback' => 'custom_get_categories', // コールバック関数
    'permission_callback' => '__return_true', // 認証なしでアクセス可能
  ));

  // /wp-json/custom/v1/tags/ エンドポイントを登録(GET リクエストに対応)
  register_rest_route('custom/v1', '/tags/', array(
    'methods' => WP_REST_Server::READABLE, // GET メソッド
    'callback' => 'custom_get_tags', // コールバック関数
    'permission_callback' => '__return_true', // 認証なしでアクセス可能
  ));
});

上記のコールバック関数の処理は同じなので、以下のように共通関数として定義して、引数でタクソノミーを受け取って切り替えることもできます。

// 引数で指定されたタクソノミー一覧を取得して JSON レスポンスとして返す関数
function custom_get_terms($taxonomy) {
  $terms = get_terms(array(
    'taxonomy'   => $taxonomy, //引数で受け取るタクソノミー(category か post_tag)
    'hide_empty' => false,
  ));
  $data = array_map(function ($term) {
    return array(
      'id'   => intval($term->term_id),
      'name' => esc_html($term->name),
      'slug' => sanitize_title($term->slug),
    );
  }, $terms);
  return rest_ensure_response($data);
}

// カテゴリー用のエンドポイントコールバック関数
function custom_get_categories() {
  return custom_get_terms('category');
}

// タグ用のエンドポイントコールバック関数
function custom_get_tags() {
  return custom_get_terms('post_tag');
}

// カスタム REST API エンドポイントを登録する
add_action('rest_api_init', function () {
  register_rest_route('custom/v1', '/categories/', array(
    'methods' => WP_REST_Server::READABLE,
    'callback' => 'custom_get_categories',
    'permission_callback' => '__return_true',
  ));
  register_rest_route('custom/v1', '/tags/', array(
    'methods' => WP_REST_Server::READABLE,
    'callback' => 'custom_get_tags',
    'permission_callback' => '__return_true',
  ));
});

並び順などのパラメータを受け取る

カテゴリーとタグのエンドポイントに共通で以下のパラメータを受け付けるようにします。

パラメータ 内容
search 名前に部分一致するキーワードで絞り込み search=ニュース
orderby 並び替え基準(name, slug, count に限定) orderby=count
order 昇順 or 降順(asc, desc) order=desc
hide_empty 投稿がゼロ(0件)のタームを除外するか hide_empty=true(デフォルト)
number 取得するターム数(上限) number=5
include 特定のターム ID のみ取得 include[]=2&include[]=7 または include=2,7
exclude 特定のターム ID を除外 exclude[]=2&exclude[]=7 または exclude=2,7

これらのパラメータは get_terms() を使ってタームを取得する際に指定します。

以下が拡張したカスタムエンドポイントを作成するコードです。

custom_get_terms_with_filter() は、WP_REST_Request オブジェクトのクエリパラメータに基づいて get_terms() の引数を構築し、指定されたタクソノミー(カテゴリーまたはタグ)に属するタームを取得する共通関数です。

include および exclude パラメータについては、空配列を渡すと「すべて除外」や「何も取得できない」といった意図しない結果になる可能性があるため、値が明示的に指定された場合にのみ引数として追加しています。

両パラメータはエンドポイント定義時に sanitize_callback によって文字列から配列に変換する処理を定義していますが、GET リクエストではサニタイズ処理が確実に適用されないケースもあるため、関数内でも再度形式をチェック・変換するようにしています。これにより、文字列・配列いずれの形式で渡されても安全に処理されます。

get_terms() の実行後に、array_values() を適用してインデックスを振り直しているのは、hide_empty が true の場合、投稿が0のタームが除外され、配列のインデックスが飛んでしまうためです。

また、結果が WP_Error オブジェクトであるかを is_wp_error() によってチェックし、エラーが発生していた場合には適切なエラーレスポンスを返します。

最終的に取得したタームの配列から、必要な情報(id, name, slug, count)のみを array_map() で抽出し、name には HTML エスケープ、slug にはサニタイズ処理を加えて整形します。返却時には rest_ensure_response() を通すことで、レスポンスが WP_REST_Response としてラップされ、適切な HTTP ヘッダーや JSON エンコードが保証されます。

// ターム(カテゴリーやタグ)を取得する共通関数。taxonomy を引数で切り替え可能。
function custom_get_terms_with_filter(WP_REST_Request $request, $taxonomy) {
  // クエリパラメータに基づいて get_terms() の引数を構築
  $args = array(
    'taxonomy'   => $taxonomy, // タクソノミー(category か post_tag)
    'hide_empty' => $request->get_param('hide_empty') !== false, // デフォルトで空のタームを除外(sanitize_callback で真偽値に変換)
    'orderby'    => sanitize_text_field($request->get_param('orderby') ?: 'name'), // 並び替え
    'order'      => strtoupper(sanitize_text_field($request->get_param('order') ?: 'ASC')), // 昇順 / 降順
    'search'     => sanitize_text_field($request->get_param('search') ?: ''), // 名前検索
    'number'     => absint($request->get_param('number')) ?: 100, // 取得するターム数(上限)
  );

  // include 指定があれば、指定された ID のタームのみ取得
  if ($include = $request->get_param('include')) {
    // sanitize_callback で配列化されている想定だが、念のため文字列で渡された場合にも対応(GET パラメータでは sanitize_callback が通らない可能性もあるため、明示的に整形)
    $ids = is_array($include) ? $include : preg_split('/[\s,]+/', trim($include));
    // そのうえで absint を適用して整数 ID の配列に整形して $args に追加
    $args['include'] = array_map('absint', array_filter($ids, 'is_numeric'));
  }

  // exclude 指定があれば、指定された ID のタームを除外(処理は include と同様)
  if ($exclude = $request->get_param('exclude')) {
    $ids = is_array($exclude) ? $exclude : preg_split('/[\s,]+/', trim($exclude));
    $args['exclude'] = array_map('absint', array_filter($ids, 'is_numeric'));
  }

  // タームを取得
  $terms = get_terms($args);
  // 取得直後にインデックスを振り直しておく(hide_empty => true により連番が飛ぶのを防ぐ)
  $terms =  array_values($terms);

  // エラーチェック
  if (is_wp_error($terms)) {
    return new WP_Error('term_fetch_error', 'タームの取得に失敗しました。', array('status' => 500));
  }

  // 必要な情報だけ抽出し、エスケープ処理を加えて整形
  $data = array_map(function ($term) {
    return array(
      'id'    => (int) $term->term_id,
      'name'  => esc_html($term->name),       // ターム名を HTML エスケープ
      'slug'  => sanitize_title($term->slug), // スラッグをサニタイズ
      'count' => (int) $term->count,          // 投稿数
    );
  }, $terms);

  return rest_ensure_response($data);
}

// カテゴリー用のエンドポイントコールバック関数
function custom_get_categories_v4(WP_REST_Request $request) {
  return custom_get_terms_with_filter($request, 'category');
}

// タグ用のエンドポイントコールバック関数
function custom_get_tags_v4(WP_REST_Request $request) {
  return custom_get_terms_with_filter($request, 'post_tag');
}

// REST API のエンドポイント登録
add_action('rest_api_init', function () {

  // 共通の引数定義(categories, tags 両方に使う)
  $args = array(
    'search' => array(
      'description'       => '名前による検索(部分一致)',
      'type'              => 'string',
      'required'          => false,
      'sanitize_callback' => 'sanitize_text_field',
    ),
    'orderby' => array(
      'description'       => '並び替えの基準(name, slug, count)',
      'type'              => 'string',
      'required'          => false,
      'sanitize_callback' => 'sanitize_text_field',
      'validate_callback' => function ($value) {
        // 許可された orderby 値(name, slug, count)のみ通す
        return in_array($value, ['name', 'slug', 'count']);
      },
    ),
    'order' => array(
      'description'       => '昇順/降順(asc または desc)',
      'type'              => 'string',
      'required'          => false,
      'default'           => 'asc',
      'sanitize_callback' => function ($value) {
        return strtolower($value);
      },
      'validate_callback' => function ($value) {
        return in_array($value, ['asc', 'desc']);
      },
    ),
    'hide_empty' => array(
      'description'       => '投稿がないものを除外する(true または false)',
      'type'              => 'boolean',
      'required'          => false,
      'default'           => true,
      'sanitize_callback' => 'rest_sanitize_boolean',
    ),
    'number' => array(
      'description'       => '取得するターム数(上限)',
      'type'              => 'integer',
      'required'          => false,
      'default'           => 100,
      'sanitize_callback' => 'absint',
      'validate_callback' => function ($value) {
        return is_numeric($value) && $value >= 1;
      },
    ),
    'include' => array(
      'description'       => '取得するターム ID(配列またはカンマ/スペース区切りの文字列)',
      'type'              => 'array',
      'required'          => false,
      'sanitize_callback' => function ($value) {
        // 文字列の場合はカンマまたはスペースで分割して配列化
        $ids = is_array($value) ? $value : preg_split('/[\s,]+/', trim($value));
        // 数値のみに絞って absint(負数→正数)を適用
        return array_map('absint', array_filter($ids, 'is_numeric'));
      },
      'validate_callback' => function ($value) {
        // 文字列の場合も対応(sanitize が効かない可能性に備える)
        $ids = is_array($value) ? $value : preg_split('/[\s,]+/', trim($value));
        foreach ($ids as $id) {
          // 各要素が数値でなければバリデーション失敗(処理を中断)
          if (!is_numeric($id)) return false;
        }
        return true;
      },
    ),
    'exclude' => array(
      'description'       => '除外するターム ID(配列またはカンマ/スペース区切りの文字列)',
      'type'              => 'array',
      'required'          => false,
      'sanitize_callback' => function ($value) {
        $ids = is_array($value) ? $value : preg_split('/[\s,]+/', trim($value));
        return array_map('absint', array_filter($ids, 'is_numeric'));
      },
      'validate_callback' => function ($value) {
        $ids = is_array($value) ? $value : preg_split('/[\s,]+/', trim($value));
        foreach ($ids as $id) {
          if (!is_numeric($id)) return false;
        }
        return true;
      },
    ),
  );

  // カテゴリーのエンドポイントを登録(エンドポイントの名前空間は投稿のエンドポイントと合わせるように v4 としています)
  register_rest_route('custom/v4', '/categories/', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'custom_get_categories_v4',
    'permission_callback' => '__return_true',
    'args'                => $args,
  ));

  // タグのエンドポイントを登録
  register_rest_route('custom/v4', '/tags/', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'custom_get_tags_v4',
    'permission_callback' => '__return_true',
    'args'                => $args,
  ));
});

上記のコードを記述して、以下の URL(example.com の部分は適宜変更)にアクセスすると、id が 209, 210, 211 のカテゴリーの情報(id, name, slug, count)が降順で取得できます。

https://example.com/wp-json/custom/v4/categories?include=209,210,211&order=desc

include と exclude にはカンマ区切りの id の文字列または配列を指定できます(以下は配列で指定)。

https://example.com/wp-json/custom/v4/categories?include[]=209&include[]=210

以下はタグの情報を3件降順で取得する例です。

https://example.com/wp-json/custom/v4/tags?number=3&order=desc

hide_empty 対策(オブジェクトが返される問題)

作成したカスタムエンドポイントでは、以下のようにタームの情報を配列で返しています。

但し、hide_empty => true によって返されるターム配列の内容が変わるため、インデックスが飛ぶような配列になってしまうことがあります。

// タームを取得
$terms = get_terms($args);

// 必要な情報だけ抽出し、エスケープ処理を加えて整形
$data = array_map(function ($term) {
  return array(
    'id'    => (int) $term->term_id,
    'name'  => esc_html($term->name),
    'slug'  => sanitize_title($term->slug),
    'count' => (int) $term->count,
  );
}, $terms);

return rest_ensure_response($data);

背景の挙動としては、get_terms() は戻り値として配列を返しますが、

  • hide_empty => false
    • → すべてのタームが返される
    • → インデックスは基本的に連番
  • hide_empty => true
    • → 投稿に使われていない(カウント0の)タームは除外される
    • → 配列の要素数が少なくなり、もとのキー(タームID)がそのまま使われるため、連番でなくなる

そしてその結果、hide_empty => true で、カウント0のタームが除外された場合、json_encode() されると、PHP では配列でも、JavaScript 側では連番でない場合オブジェクトとして解釈されてしまいます(結果、配列メソッドが使えない)。

PHP の json_encode() は配列のキーを見て以下のように動作します。

  • キーが連番の整数(0から始まって連続している)→ JSON の 配列([ ])
  • キーが連番でない or 文字列 → JSON の オブジェクト({ })

このため、JavaScript 側で取得した JSON データを操作する際に、配列として想定して .forEach() や .map() などの配列のメソッドを使うとエラーになってしまいます。

解決策

取得したタームの配列に array_values() を適用して、インデックスを振り直します。

$terms = get_terms($args);
// 取得直後にインデックスを振り直す
$terms =  array_values($terms);

$data = array_map(function ($term) {
  return array(
    'id'    => (int) $term->term_id,
    'name'  => esc_html($term->name),
    'slug'  => sanitize_title($term->slug),
    'count' => (int) $term->count,
  );
}, $terms);

return rest_ensure_response($data);

但し、例えば以下のようにデータの操作で「欠番」が出る可能性がある場合は、最後に array_values() を適用する必要があります。

// タームを取得
$terms = get_terms($args);

$data = array_map(function ($term) {
  if ($term->term_id == 15) {
    return null;  // 条件により null を返させる
  }
  return array(
    'id'    => (int) $term->term_id,
    'name'  => esc_html($term->name),
    'slug'  => sanitize_title($term->slug),
    'count' => (int) $term->count,
  );
}, $terms);

// null の要素を除去(欠番発生)
// array_filter() はキーを維持するため、連番にならない
$data = array_filter($data);
// ここで振り直す必要がある(JSON が配列として正しく扱われるように)
$data =  array_values($data);

return rest_ensure_response($data);

array_map() はキーを保持する仕様なので、元の配列のキーが飛んでいるとそのまま引き継ぎます。このため「処理後に array_values() をかける」のがベストプラクティスになります。

対策まとめ

  • get_terms() の戻り値は、場合によって連番の配列でなくなることがある
  • そのまま JSON にすると JavaScript 側で配列として扱えず不具合が起きる可能性がある
  • よって、array_values() を使って **インデックスを振り直す** のが安全
  • 特に array_filter() や unset() などで中身を間引いたあとも、インデックスが不連続になるので注意

JavaScript(フロントエンド)

作成した以下のエンドポイントを使って、カテゴリやタグを取得してセレクトボックスで表示し、選択されたタームに紐づく投稿を一覧表示します。

  • /custom/v4/posts/
    • 投稿情報と「その投稿に含まれるカテゴリー・タグ」を提供
  • /custom/v4/categories/
    • サイト全体のカテゴリを提供(id, name, slug, count など)
  • /custom/v4/tags/
    • サイト全体のタグを提供(id, name, slug, count など)

最初の例は HTML で term-select クラスの要素を配置すると、JavaScript でそこへ全てのカテゴリーを取得してセレクトボックスを表示し、選択したタームに属する投稿を10件表示するシンプルなものです。

<div class="term-select"></div>

例えば、以下のような HTML が出力されます。select 要素の option にはカテゴリー(ターム)の名前とそのタームに属する投稿数を表示し、value 属性にはそのスラッグを出力します。

セレクトボックスでタームを選択すると change イベントでそのタームのスラッグの投稿を取得して post-wrapper クラスの div 要素に投稿のタイトルと抜粋を表示します。

<div class="term-select">
  <select class="categories-select">
    <option value="art">Art (2)</option>
    <option value="music">Music (6)</option>
    <option value="nature">Nature (2)</option>
    ・・・
  </select>
  <div class="post-wrapper">
    <div class="post">
      <h3>Beatae dolorum</h3>
      <div>Beatae dolorum, sint at quos animi quia culpa rem dolores, ipsam dolor, alias offici […]</div>
    </div>
    <div class="post">
      <h3>Ratione harum</h3>
      <div>Ratione harum quisquam quasi voluptatibus illo odio aperiam aliquid, voluptatem pariatur ipsam voluptates quos […]</div>
    </div>
    ・・・
  </div>
</div>

デフォルトではカテゴリーを取得して表示しますが、以下のように追加で tag クラスを指定すると、タグを取得してセレクトボックスに表示します。

<div class="term-select tag"></div>

以下が JavaScript です。

HTML内の term-select クラスを持つ要素を取得し、その要素に tags クラスが含まれているかどうかで、「タグ」か「カテゴリ」かを判定します。そして、REST API のエンドポイントURLを構築するための taxonomy(複数形)変数を作成します。

投稿を取得する際は、パラメータに tag または category という単数形の名前を指定する必要があるので、別途 taxonomySingular という変数も作成します。

次に、ターム(タグまたはカテゴリ)を選ぶための select 要素と、投稿を表示するための div 要素(ラッパー)を作成し、HTMLに追加します。その後、API エンドポイントからターム情報を取得し、取得したデータをもとに option 要素を生成して select 要素に追加します。

続いて、セレクトボックスの選択が変更されたときに実行されるイベントハンドラーを設定します。この処理では、選択されたタームのスラッグ(識別子)を e.target.value から取得し、それをクエリパラメータに追加して投稿データを取得します。取得した投稿は postsWrapper 内に表示されます。

(() => {
  // HTMLをサニタイズして安全なテキストを返す関数(これまでと同じもの)
  const sanitizeHtml = (html) => {
    const div = document.createElement("div");
    div.innerHTML = html;
    return div.textContent || "";
  };

  // 最初にHTML上の「.term-select」クラスを持つ要素を取得(1つ目のみ使用)
  const termSelect = document.getElementsByClassName("term-select")[0];

  // .term-select クラスを持つ要素が存在すれば
  if(termSelect) {
    // 選択対象が「タグ」か「カテゴリ」かをクラス名から判定
    const taxonomy = termSelect.classList.contains("tag") ? "tags" : "categories"; // 複数形(APIエンドポイント用)
    const taxonomySingular = termSelect.classList.contains("tag") ? "tag" : "category"; // 単数形(クエリパラメータ用)

    // <select>要素を作成してクラスを設定し、termSelect内に追加
    const select = document.createElement("select");
    select.className = taxonomy + "-select"; // 例: categories-select
    termSelect.appendChild(select);

    // 投稿を表示するラッパー <div> を作成し、termSelect内に追加
    const postsWrapper = document.createElement("div");
    postsWrapper.className = "post-wrapper";
    termSelect.appendChild(postsWrapper);

    // WordPress REST API のベースURL(自分の環境に合わせて変更)
    const baseUrl = "https://example.com/wp-json/custom/v4/";

    // カテゴリ or タグの一覧を取得して、セレクトボックスに追加する
    fetch(`${baseUrl}${taxonomy}`) // 例: /custom/v4/categories
      .then((response) => {
        if (!response.ok)
          throw new Error(
            `リクエスト失敗: ${response.status} ${response.statusText}`
          );
        return response.json(); // JSON形式でデータを取得
      })
      .then((data) => {
        // 取得したデータ(カテゴリ or タグ)をセレクトボックスに追加
        data.forEach((term) => {
          const option = document.createElement("option");
          option.value = term.slug; // 例: "news", "design"
          option.textContent = `${term.name} (${term.count})`; // 表示名 + 投稿数
          select.appendChild(option);
        });
      })
      .catch((err) => {
        console.error("カテゴリの取得に失敗しました", err);
      });

    // セレクトボックスが変更されたときの処理(=投稿の取得)
    select.addEventListener("change", (e) => {
      const termId = e.target.value; // 選択されたタームのスラッグ

      const url = `${baseUrl}posts`; // 投稿のカスタムAPIエンドポイント

      // 投稿取得のクエリパラメータ(初期では最大10件)
      const queryParams = {
        per_page: 10,
      };

      // ターム(カテゴリ or タグ)指定がある場合はクエリに追加
      if (termId) {
        queryParams[taxonomySingular] = termId; // 例: { category: "news" }
      }

      // クエリパラメータをURLに変換
      const params = new URLSearchParams(queryParams).toString();

      // 投稿データを取得
      fetch(`${url}?${params}`)
        .then((response) => {
          if (!response.ok)
            throw new Error(
              `リクエスト失敗: ${response.status} ${response.statusText}`
            );
          return response.json();
        })
        .then((data) => {
          const posts = data.posts;
          console.log(posts); // デバッグ用:取得した投稿をコンソールに表示

          postsWrapper.innerHTML = ""; // 前の投稿を一旦クリア

          // 投稿がなかった場合の表示
          if (!posts.length) {
            postsWrapper.innerHTML = "<p>該当する投稿はありません。</p>";
            return;
          }

          // 投稿データをHTMLとして表示
          posts.forEach((post) => {
            const postEl = document.createElement("div");
            postEl.classList.add("post");

            // 投稿タイトルと抜粋をHTMLで組み立て
            postEl.innerHTML = `
                <h3>${sanitizeHtml(post.title)}</h3>
                <div>${sanitizeHtml(post.excerpt)}</div>
              `;

            // ラッパーに追加
            postsWrapper.appendChild(postEl);
          });
        })
        .catch((err) => {
          // エラー時の表示
          postsWrapper.innerHTML = `<li>投稿の取得に失敗しました: ${err.message}</li>`;
        })
    });
  }
})();

カスタムデータ属性でパラメータを指定

カスタムデータ属性を使って各種パラメータを指定する例です。

先の例同様、term-select クラスを指定した要素にセレクトボックスを表示し、選択したタームの投稿を表示します(複数の term-select クラスを指定した要素を配置可能)。

カスタムデータ属性には、エンドポイントで設定したパラメータを指定することができます。また、投稿数を表示するには show-count クラスを追加します。

term-select クラスのみを指定した場合、デフォルトが適用されます。デフォルトでは、初期状態で全ての投稿を10件表示しますが、data-initial-term 属性にタームのスラッグを指定すると、そのタームの投稿を表示します。

例えば、以下のような指定が可能です。data-number はセレクトボックスに表示するタームの数で、data-per-page は取得して表示する投稿の件数です。

<div class="term-select show-count"
  data-number="5"
  data-order="asc"
  data-orderby="count"
  data-include="209,210,211"
  data-initial-term="music"
  data-per-page="3">
</div>

<div class="term-select tag"
  data-placeholder="タグを選択"
  data-per-page="10">
</div>

以下が JavaScript です。

投稿リストを取得して表示する関数 renderPostLinks() を定義して、セレクトボックス変更時に投稿リストを更新するようにしています。また、初期状態の投稿リストを表示する際には、data-initial-term が指定されている場合はそのタームの投稿を表示し、指定がない場合は全ての投稿を表示します。

(() => {
  // HTMLをサニタイズして安全なテキストを返す関数(これまでと同じもの)
  const sanitizeHtml = (html) => {
    const div = document.createElement("div");
    div.innerHTML = html;
    return div.textContent || "";
  };

  // タクソノミー選択セレクトボックスと投稿リストを生成・表示する関数
  const renderTermSelect = (target) => {
    // APIのベースURL
    const baseUrl = "https://example.com/wp-json/custom/v4/";

    // タグかカテゴリかを判定してAPIエンドポイントとパラメータ名を決定
    const taxonomy = target.classList.contains("tag") ? "tags" : "categories";
    const taxonomySingular = target.classList.contains("tag") ? "tag" : "category";

    // 投稿件数を表示するかどうかのフラグ
    const showCount = target.classList.contains("show-count");

    // データ属性から各種パラメータを取得(未指定時はデフォルト値)
    const number = target.dataset.number ? parseInt(target.dataset.number, 10) : 100;
    const search = target.dataset.search;
    const orderby = target.dataset.orderby;
    const order = target.dataset.order;
    const hide_empty = target.dataset.hideEmpty !== undefined ? target.dataset.hideEmpty : "true";
    const include = target.dataset.include;
    const exclude = target.dataset.exclude;
    const initialTerm = target.dataset.initialTerm;
    const per_page = target.dataset.perPage ? parseInt(target.dataset.perPage, 10) : 10;
    const placeholder = target.dataset.placeholder ? target.dataset.placeholder : "選択してください";

    // APIに渡すクエリパラメータを構築
    const queryParams = { number };
    if (search) queryParams.search = search;
    if (orderby) queryParams.orderby = orderby;
    if (order) queryParams.order = order;
    if (hide_empty) queryParams.hide_empty = hide_empty;
    if (include) queryParams.include = include.toString();
    if (exclude) queryParams.exclude = exclude.toString();
    const params = new URLSearchParams(queryParams).toString();

    // 投稿リストを表示するラッパー要素を取得または生成
    let postsWrapper = target.querySelector(".post-wrapper");
    if (!postsWrapper) {
      postsWrapper = document.createElement("div");
      postsWrapper.className = "post-wrapper";
      target.appendChild(postsWrapper);
    }

    // 投稿リストのul要素を取得または生成
    let list = postsWrapper.querySelector("ul.post-links");
    if (!list) {
      list = document.createElement("ul");
      list.className = "post-links";
      postsWrapper.appendChild(list);
    }

    // ローディングインジケーターの生成
    const loadingIndicator = document.createElement("div");
    loadingIndicator.className = "loading-indicator";
    const spinner = document.createElement("span");
    spinner.className = "spinner";
    loadingIndicator.appendChild(spinner);
    loadingIndicator.appendChild(document.createTextNode("Loading..."));
    postsWrapper.appendChild(loadingIndicator);

    // タクソノミー選択用セレクトボックスを生成
    const select = document.createElement("select");
    select.className = taxonomy + "-select";
    target.insertBefore(select, postsWrapper);

    // タクソノミー(カテゴリまたはタグ)の取得とセレクトボックスへの追加
    fetch(`${baseUrl}${taxonomy}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(
            `リクエスト失敗: ${response.status} ${response.statusText}`
          );
        return response.json();
      })
      .then((data) => {
        const fragment = document.createDocumentFragment();

        // プレースホルダー用の空optionを追加
        const defaultOption = document.createElement("option");
        defaultOption.value = "";
        defaultOption.textContent = placeholder;
        fragment.appendChild(defaultOption);

        // タクソノミーデータからoptionを作成
        data.forEach((term) => {
          const option = document.createElement("option");
          option.value = term.slug;
          if (term.slug === initialTerm) option.selected = true;
          option.textContent = showCount
            ? `${term.name} (${term.count})`
            : term.name;
          fragment.appendChild(option);
        });

        select.appendChild(fragment);

        // 初期選択値を再設定(option.selectedもあるが、念のため)
        if (initialTerm) {
          select.value = initialTerm;
        }
      })
      .catch((err) => {
        console.error("カテゴリの取得に失敗しました", err);
      });

    // 投稿リストを取得して表示する関数
    const renderPostLinks = (url, slug) => {
      const queryParams = {
        images: false, // アイキャッチ画像は使用しないので取得しない
        per_page: per_page,
      };

      // タクソノミーのslugをクエリに追加(未選択なら追加しない)
      if (slug) {
        queryParams[taxonomySingular] = slug;
      }
      const params = new URLSearchParams(queryParams).toString();

      // ローディング表示開始
      loadingIndicator.classList.add("active");

      // 投稿データを取得
      fetch(`${url}?${params}`)
        .then((response) => {
          if (!response.ok)
            throw new Error(
              `リクエスト失敗: ${response.status} ${response.statusText}`
            );
          return response.json();
        })
        .then((data) => {
          const posts = data.posts;

          // 既存のリストをクリア
          list.innerHTML = "";

          // 投稿がなかった場合の表示
          if (!posts.length) {
            list.innerHTML = "<li>該当する投稿はありません。</li>";
            return;
          }

          // 投稿リストをHTMLとして構築
          const fragment = document.createDocumentFragment();

          posts.forEach((post) => {
            const link = post.link;
            if (!link) return;
            const title = sanitizeHtml(post.title);
            const listItem = document.createElement("li");
            listItem.className = "post-link";
            const postHeading = document.createElement("h3");
            postHeading.className = "post-title";
            const titleLink = document.createElement("a");
            titleLink.href = link;
            titleLink.textContent = title;
            postHeading.appendChild(titleLink);
            listItem.appendChild(postHeading);
            fragment.appendChild(listItem);
          });

          list.appendChild(fragment);
        })
        .catch((err) => {
          // エラー時の表示
          list.innerHTML = `<li>投稿の取得に失敗しました: ${err.message}</li>`;
        })
        .finally(() => {
          // ローディング終了
          target.dataset.isLoading = "false";
          loadingIndicator.classList.remove("active");
        });
    };

    // 初期状態の投稿リストを表示(初期タームがある場合はそれに応じて)
    renderPostLinks(`${baseUrl}posts`, initialTerm || null);

    // セレクト変更時に投稿リストを更新
    select.addEventListener("change", (e) => {
      renderPostLinks(`${baseUrl}posts`, e.target.value);
    });
  };

  // DOM読み込み完了時にすべての.term-select要素に処理を適用
  document.addEventListener("DOMContentLoaded", () => {
    const termSelects = Array.from(
      document.getElementsByClassName("term-select")
    );
    termSelects.forEach((termSelect) => {
      renderTermSelect(termSelect);
    });
  });
})();
Load More を追加

ターゲットの要素に load-more クラスが追加されていれば、表示する投稿件数が data-per-page 属性で指定された件数(またはデフォルトの10件)より多い場合は、追加読み込み用のボタンを表示します。

<div class="term-select show-count load-more"
  data-placeholder="カテゴリーを選択"
  data-per-page="5">
</div>

以下が JavaScript です。

load-more クラスが指定されていれば、Load More を作成します。

投稿一覧を取得・表示する関数 renderPostLinks() では追加で現在のページ番号を受取り、エンドポイントから取得した総ページ数と現在のページ番号を比較して Load More ボタンの表示を制御します。

ページ番号(現在のページ)の値 currentPage は各ターゲットごとに保存し、初回表示とセレクトボックス変更時にはページ番号をリセットします(225、230行目)。

Load More ボタンがクリックされた際の処理では、現在のセレクトボックスの値(slug)を取得して renderPostLinks() を呼び出し、投稿リストを再描画します(215-216行目)。

(() => {
  const sanitizeHtml = (html) => {
    const div = document.createElement("div");
    div.innerHTML = html;
    return div.textContent || "";
  };

  const renderTermSelect = (target) => {
    // APIのベースURL
    const baseUrl = "https://example.com/wp-json/custom/v4/";

    // タクソノミー(categories または tags)を判定
    const taxonomy = target.classList.contains("tag") ? "tags" : "categories";
    const taxonomySingular = target.classList.contains("tag") ? "tag" : "category";

    // カウント表示の有無を判定
    const showCount = target.classList.contains("show-count");

    // Load More ボタンの表示有無を判定
    let showLoadmore = target.classList.contains("load-more");

    // データ属性から各種設定値を取得
    const number = target.dataset.number ? parseInt(target.dataset.number, 10) : 100;
    const search = target.dataset.search;
    const orderby = target.dataset.orderby;
    const order = target.dataset.order;
    const hide_empty = target.dataset.hideEmpty !== undefined ? target.dataset.hideEmpty : "true";
    const include = target.dataset.include;
    const exclude = target.dataset.exclude;
    const initialTerm = target.dataset.initialTerm;
    const placeholder = target.dataset.placeholder ? target.dataset.placeholder : "選択してください";

    // APIクエリ用パラメータを構築
    const queryParams = { number };
    if (search) queryParams.search = search;
    if (orderby) queryParams.orderby = orderby;
    if (order) queryParams.order = order;
    if (hide_empty) queryParams.hide_empty = hide_empty;
    if (include) queryParams.include = include.toString();
    if (exclude) queryParams.exclude = exclude.toString();

    const params = new URLSearchParams(queryParams).toString();

    // 投稿リストと関連要素を準備
    let postsWrapper = target.querySelector(".post-wrapper");
    if (!postsWrapper) {
      postsWrapper = document.createElement("div");
      postsWrapper.className = "post-wrapper";
      target.appendChild(postsWrapper);
    }

    let list = postsWrapper.querySelector("ul.post-links");
    if (!list) {
      list = document.createElement("ul");
      list.className = "post-links";
      postsWrapper.appendChild(list);
    }

    // Load More ボタンを生成(必要な場合のみ)
    let loadMoreButton = postsWrapper.querySelector(".fetch-load-more");
    if (showLoadmore && !loadMoreButton) {
      loadMoreButton = document.createElement("button");
      loadMoreButton.className = "fetch-load-more";
      loadMoreButton.textContent = "Load More";
      loadMoreButton.style.display = "none";
      postsWrapper.appendChild(loadMoreButton);
    }

    // ローディングインジケーターを生成(まだない場合のみ)
    let loadingIndicator = postsWrapper.querySelector(".loading-indicator");
    if (!loadingIndicator) {
      loadingIndicator = document.createElement("div");
      loadingIndicator.className = "loading-indicator";
      const spinner = document.createElement("span");
      spinner.className = "spinner";
      loadingIndicator.appendChild(spinner);
      loadingIndicator.appendChild(document.createTextNode("Loading..."));
      postsWrapper.appendChild(loadingIndicator);
    }

    // セレクトボックスを生成し投稿リストの前に挿入
    const select = document.createElement("select");
    select.className = taxonomy + "-select";
    target.insertBefore(select, postsWrapper);

    // タームのセレクトボックスを取得・生成
    fetch(`${baseUrl}${taxonomy}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(`リクエスト失敗: ${response.status} ${response.statusText}`);
        return response.json();
      })
      .then((data) => {
        const fragment = document.createDocumentFragment();

        // デフォルトの「選択してください」オプション
        const defaultOption = document.createElement("option");
        defaultOption.value = "";
        defaultOption.textContent = placeholder;
        fragment.appendChild(defaultOption);

        // 各タームを option 要素として追加
        data.forEach((term) => {
          const option = document.createElement("option");
          option.value = term.slug;
          if (term.slug === initialTerm) option.selected = true;
          option.textContent = showCount ? `${term.name} (${term.count})` : term.name;
          fragment.appendChild(option);
        });

        select.appendChild(fragment);

        // 初期タームが指定されていれば反映
        if (initialTerm) {
          select.value = initialTerm;
        }
      })
      .catch((err) => {
        console.error("カテゴリの取得に失敗しました", err);
      });

    // 投稿取得数
    const per_page = target.dataset.perPage ? parseInt(target.dataset.perPage, 10) : 10;

    // 投稿一覧を取得・表示する関数
    const renderPostLinks = (url, slug, page = 1) => {
      const queryParams = {
        images: false,
        per_page: per_page,
        page: page, // 現在のページ
      };

      // ターム slug をパラメータに追加
      if (slug) {
        queryParams[taxonomySingular] = slug;
      }

      const params = new URLSearchParams(queryParams).toString();

      // ローディング表示を開始
      loadingIndicator.classList.add("active");

      // 投稿データ取得
      fetch(`${url}?${params}`)
        .then((response) => {
          if (!response.ok) {
            throw new Error(`リクエスト失敗: ${response.status} ${response.statusText}`);
          }
          return response.json();
        })
        .then((data) => {
          // 総ページ数を保存
          const totalPages = data.total_pages ? parseInt(data.total_pages, 10) : 1;
          target.dataset.totalPages = totalPages;
          // 投稿データ
          const posts = data.posts;
          // 投稿が空だった場合の処理
          if (!Array.isArray(posts) || posts.length === 0) {
            list.innerHTML = "<li>投稿が見つかりませんでした。</li>";
            if (showLoadmore && loadMoreButton) {
              loadMoreButton.style.display = "none";
            }
            return;
          }

          // 最初のページではリストをリセット
          if (page === 1) list.innerHTML = "";

          const fragment = document.createDocumentFragment();

          // 投稿ごとにリスト項目を生成
          posts.forEach((post) => {
            const link = post.link;
            if (!link) return;
            const title = sanitizeHtml(post.title);
            const listItem = document.createElement("li");
            listItem.className = "post-link";
            const postHeading = document.createElement("h3");
            postHeading.className = "post-title";
            const titleLink = document.createElement("a");
            titleLink.href = link;
            titleLink.textContent = title;
            postHeading.appendChild(titleLink);
            listItem.appendChild(postHeading);
            fragment.appendChild(listItem);
          });

          list.appendChild(fragment);

          // Load More ボタンの表示制御(次のページがある場合に表示)
          if (showLoadmore) {
            loadMoreButton.style.display = page < totalPages ? "inline-block" : "none";
          }
        })
        .catch((err) => {
          list.innerHTML = `<li>投稿の取得に失敗しました: ${err.message}</li>`;
        })
        .finally(() => {
          target.dataset.isLoading = "false";
          loadingIndicator.classList.remove("active");
        });

      // Load More イベントが未登録なら追加
      if (showLoadmore) {
        if (!loadMoreButton.dataset.listenerAdded) {
          loadMoreButton.addEventListener("click", () => {
            // 多重クリック防止
            if (target.dataset.isLoading === "true") return;
            target.dataset.isLoading = "true";
            let currentPage = parseInt(target.dataset.currentPage || "1", 10);
            currentPage++;
            // ページ番号(現在のページ)を更新し、各ターゲットごとに保存
            target.dataset.currentPage = currentPage;
            // 現在のセレクトボックスの値(slug)を取得して再描画
            const selectedSlug = select.value || null;
            renderPostLinks(`${baseUrl}posts`, selectedSlug, currentPage);
          });
          // リスナーが既に登録されていることを記録(二重登録防止用)
          loadMoreButton.dataset.listenerAdded = "true";
        }
      }
    };

    // 初期ページ番号をセットして初回表示
    target.dataset.currentPage = "1";
    renderPostLinks(`${baseUrl}posts`, initialTerm || null);

    // セレクトボックス変更時に投稿を再取得
    select.addEventListener("change", (e) => {
      target.dataset.currentPage = "1"; // ページ番号リセット
      renderPostLinks(`${baseUrl}posts`, e.target.value);
    });
  };

  // DOM構築完了後に各 term-select に対して初期化処理を実行
  document.addEventListener("DOMContentLoaded", () => {
    const termSelects = Array.from(document.getElementsByClassName("term-select"));
    termSelects.forEach((termSelect) => {
      renderTermSelect(termSelect);
    });
  });
})();

カスタムエンドポイントサンプル

以下はカスタムエンドポイントを作成する PHP とそれを利用する JavaScript のサンプルで(例)す。

内容はこれまでに作成したものと同じものですが、名前空間を変更しています。

推奨される $namespace(名前空間)の指定方法

{your-plugin-or-theme-slug}/{version}

例(my-plugin や mytheme などの部分は固有の名前で置き換えます)

  • my-plugin/v1
  • custom-blocks/v1
  • mytheme-api/v2
  • awesome-api/v1

自作のプラグイン・テーマなどの固有の名前を入れることで、他のプラグインやテーマとバッティングを防げます。※これまで使用してきた custom/v1 は実用には適しません。

また、バージョン番号を入れることで、将来の仕様変更や互換性維持がしやすくなり、REST API の一般的な設計パターンです。但し、v0 は開発中の場合は OK ですが、本番では v1 以降を使うのが推奨されます。

以下の例ではテーマの functions.php に記述することを前提としているので my-theme-api/v1 としていますが、my-theme-api の部分は固有の名前などに適宜変更します。

投稿情報のカスタムエンドポイント

投稿情報を返すエンドポイントです。functions.php やプラグインファイルに記述します。

カテゴリーやタグを指定」で作成したコードと同じです。

// 投稿用カスタム REST API エンドポイントのコールバック関数
function my_custom_get_posts(WP_REST_Request $request) {

  // カテゴリースラッグの取得
  $category_param = $request->get_param('category');
  // タグスラッグの取得
  $tag_param = $request->get_param('tag');

  $per_page = max(1, intval($request->get_param('per_page') ?: 10));
  $paged = max(1, intval($request->get_param('page') ?: 1));
  $show_images = $request->get_param('images');

  // WP_Query 用の引数を定義
  $args = array(
    'post_type'           => 'post',
    'posts_per_page'      => $per_page,
    'paged'               => $paged,
    'ignore_sticky_posts' => true,
  );

  // タクソノミークエリ(tax_query の準備)
  $tax_query = array();

  // カテゴリー
  if (!empty($category_param)) {
    // カンマで区切ってスラッグの配列に変換し、前後の空白を除去
    $category_slugs = array_map('trim', explode(',', $category_param));
    // 指定されたスラッグに対応する既存のカテゴリーを取得(存在するものだけ)
    $existing_categories = get_terms(array(
      'taxonomy'   => 'category', // カテゴリータクソノミーを指定
      'slug'       => $category_slugs, // スラッグで絞り込み
      'fields'     => 'slugs', // 結果はスラッグのみ取得(スラッグの配列)
      'hide_empty' => false, // 投稿数0でも取得する
    ));
    // 存在しないカテゴリーを抽出(送信されたスラッグと、取得されたスラッグの差分)
    $invalid_categories = array_diff($category_slugs, $existing_categories);
    // 存在しないカテゴリーがあれば、エラーとして返す(HTTP 400)
    if (!empty($invalid_categories)) {
      return new WP_Error('invalid_category', '存在しないカテゴリー: ' . implode(', ', $invalid_categories), array('status' => 400));
    }

    // tax_query にカテゴリー条件を追加(複数指定に対応)
    $tax_query[] = array(
      'taxonomy' => 'category', // 対象のタクソノミー
      'field'    => 'slug',   // スラッグで絞り込む
      'terms'    => $category_slugs,  // 指定されたカテゴリーすべてを対象
      'operator' => 'IN',    // いずれかに一致すればOK
    );
  }

  // タグ (カテゴリーと同様の処理)
  if (!empty($tag_param)) {
    $tag_slugs = array_map('trim', explode(',', $tag_param));
    $existing_tags = get_terms(array(
      'taxonomy'   => 'post_tag', // タグタクソノミー(post_tag)を指定
      'slug'       => $tag_slugs,
      'fields'     => 'slugs',
      'hide_empty' => false,
    ));
    $invalid_tags = array_diff($tag_slugs, $existing_tags);
    // 存在しないスラッグが含まれていたらエラー
    if (!empty($invalid_tags)) {
      return new WP_Error('invalid_tag', '存在しないタグ: ' . implode(', ', $invalid_tags), array('status' => 400));
    }

    // tax_query にタグ条件を追加(複数指定に対応)
    $tax_query[] = array(
      'taxonomy' => 'post_tag',
      'field'    => 'slug',
      'terms'    => $tag_slugs,
      'operator' => 'IN',
    );
  }

  // $tax_query が空でなければタクソノミークエリを追加
  if (!empty($tax_query)) {
    $args['tax_query'] = $tax_query;
  }

  $query = new WP_Query($args);
  $posts_data = array();

  if ($query->have_posts()) {
    while ($query->have_posts()) {
      $query->the_post();

      $categories = array_map(function ($cat) {
        return array(
          'label' => esc_html($cat->name),
          'slug' => sanitize_title($cat->slug),
          'link'  => esc_url(get_category_link($cat->term_id)),
        );
      }, get_the_category());

      $tags = array_map(function ($tag) {
        return array(
          'label' => esc_html($tag->name),
          'slug' => sanitize_title($tag->slug),
          'link'  => esc_url(get_tag_link($tag->term_id)),
        );
      }, get_the_tags() ?: []);

      $post_data = array(
        'id'                       => get_the_ID(),
        'date'                     => get_the_date('c'),
        'author'                   => esc_html(get_the_author()),
        'title'                    => esc_html(wp_strip_all_tags(get_the_title())),
        'excerpt'                  => esc_html(wp_strip_all_tags(get_the_excerpt())),
        'link'                     => esc_url(get_permalink()),
        'categories'               => $categories,
        'tags'                     => $tags,
      );
      if ($show_images) {
        $post_data['featured_image_full'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'full'));
        $post_data['featured_image_large'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'large'));
        $post_data['featured_image_medium'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'medium'));
        $post_data['featured_image_thumbnail'] = esc_url(get_the_post_thumbnail_url(get_the_ID(), 'thumbnail'));
      }
      $posts_data[] = $post_data;
    }
    wp_reset_postdata();
  }
  $response = array(
    'posts'        => $posts_data,
    'total_pages'  => $query->max_num_pages,
    'current_page' => $paged,
    'per_page'     => $per_page,
  );
  return rest_ensure_response($response);
}

// 投稿用カスタム REST API エンドポイントを登録
function register_my_custom_posts_rest_route() {
  // 'my-theme-api/v1/posts/' というエンドポイントを登録
  register_rest_route('my-theme-api/v1', '/posts/', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'my_custom_get_posts',
    'permission_callback' => '__return_true',
    'args'                => array(
      'category' => array(
        'description' => 'カテゴリースラッグ(カンマ区切り対応)',
        'type'        => 'string',
        'required'    => false,
      ),
      'tag' => array(
        'description' => 'タグスラッグ(カンマ区切り対応)',
        'type'        => 'string',
        'required'    => false,
      ),
      'per_page' => array(
        'description' => '1ページあたりの取得件数',
        'type'        => 'integer',
        'required'    => false,
        'default'     => 10,
        'sanitize_callback' => 'absint',
        'validate_callback' => function ($value) {
          return $value > 0 && $value <= 100;
        },
      ),
      'page' => array(
        'description' => 'ページ番号',
        'type'        => 'integer',
        'required'    => false,
        'default'     => 1,
        'sanitize_callback' => 'absint',
        'validate_callback' => function ($value) {
          return $value > 0;
        },
      ),
      'images' => array(
        'description' => 'true でアイキャッチ画像 URL を含める(デフォルト)、false で除外',
        'type'        => 'boolean',
        'required'    => false,
        'default'     => true,
        'sanitize_callback' => 'rest_sanitize_boolean',
      ),
    ),
  ));
}
add_action('rest_api_init', 'register_my_custom_posts_rest_route');

エンドポイント

GET /wp-json/my-theme-api/v1/posts

パラメータ

以下は指定可能なパラメータです。詳細は上記コードの register_rest_route() の args に記載。

パラメータ 説明 デフォルト
per_page 1ページあたりの取得件数 10
page ページ番号 1
images アイキャッチ画像 URL を含めるかどうかの真偽値 true
category カテゴリースラッグ(カンマ区切り対応) なし
tag タグスラッグ(カンマ区切り対応) なし

指定例

/wp-json/my-theme-api/v1/posts?category=music,nature&per_page=3&page=3

返されるデータはオブジェクトで、投稿のデータは posts に配列で格納されています。

{
  "posts": [
    {
      "id": 1838,
      "date": "2025-04-06T09:18:03+09:00",
      "author": "Foo",
      "title": "Ratione harum",
      "excerpt": "Ratione harum quisquam quasi voluptatibus illo odio aperiam aliquid, voluptatem pariatur ipsam voluptates quos [&hellip;]",
      "link": "https://example.com/ratione-harum/",
      "categories": [
        {
          "label": "Nature",
          "slug": "nature",
          "link": "https://example.com/category/nature/"
        }
      ],
      "tags": [],
      "featured_image_full": "https://example.com/wp-content/uploads/2025/02/407679199b4e28a28.67020044-1536x1152-1.jpeg",
      "featured_image_large": "https://example.com/wp-content/uploads/2025/02/407679199b4e28a28.67020044-1536x1152-1-1024x768.jpeg",
      "featured_image_medium": "https://example.com/wp-content/uploads/2025/02/407679199b4e28a28.67020044-1536x1152-1-300x225.jpeg",
      "featured_image_thumbnail": "https://example.com/wp-content/uploads/2025/02/407679199b4e28a28.67020044-1536x1152-1-150x150.jpeg"
    },
    {
      "id": 1835,
      "date": "2025-04-06T09:15:59+09:00",
      "author": "Foo",
      "title": "Earum minima",
      "excerpt": "Earum minima deleniti possimus itaque. Quas, beatae ipsa libero placeat est atque tempora id itaque ad? Dolori [&hellip;]",
      "link": "https://example.com/earum-minima/",
      "categories": [
        {
          "label": "Music",
          "slug": "music",
          "link": "https://example.com/category/music/"
        }
      ],
      "tags": [
        {
          "label": "Contemporary",
          "slug": "contemporary",
          "link": "https://example.com/tag/contemporary/"
        }
      ],
      "featured_image_full": "https://example.com/wp-content/uploads/2008/06/img_0513-1.jpg",
      "featured_image_large": "https://example.com/wp-content/uploads/2008/06/img_0513-1.jpg",
      "featured_image_medium": "https://example.com/wp-content/uploads/2008/06/img_0513-1-300x200.jpg",
      "featured_image_thumbnail": "https://example.com/wp-content/uploads/2008/06/img_0513-1-150x150.jpg"
    }
  ],
  "total_pages": 3,
  "current_page": 3,
  "per_page": 3
}

カテゴリーとタグのカスタムエンドポイント

カテゴリーとタグの情報を返すエンドポイントです。functions.php やプラグインファイルに記述します。

並び順などのパラメータを受け取る」で作成したコードと同じです。

// ターム(カテゴリーやタグ)を取得する共通関数。taxonomy を引数で切り替え可能。
function my_custom_get_terms(WP_REST_Request $request, $taxonomy) {
  // クエリパラメータに基づいて get_terms() の引数を構築
  $args = array(
    'taxonomy'   => $taxonomy, // タクソノミー(category か post_tag)
    'hide_empty' => $request->get_param('hide_empty') !== false, // デフォルトで空のタームを除外(sanitize_callback で真偽値に変換)
    'orderby'    => sanitize_text_field($request->get_param('orderby') ?: 'name'), // 並び替え
    'order'      => strtoupper(sanitize_text_field($request->get_param('order') ?: 'ASC')), // 昇順 / 降順
    'search'     => sanitize_text_field($request->get_param('search') ?: ''), // 名前検索
    'number'     => absint($request->get_param('number')) ?: 100, // 取得するターム数(上限)
  );

  // include 指定があれば、指定された ID のタームのみ取得
  if ($include = $request->get_param('include')) {
    // sanitize_callback で配列化されている想定だが、念のため文字列で渡された場合にも対応(GET パラメータでは sanitize_callback が通らない可能性もあるため、明示的に整形)
    $ids = is_array($include) ? $include : preg_split('/[\s,]+/', trim($include));
    // そのうえで absint を適用して整数 ID の配列に整形して $args に追加
    $args['include'] = array_map('absint', array_filter($ids, 'is_numeric'));
  }

  // exclude 指定があれば、指定された ID のタームを除外(処理は include と同様)
  if ($exclude = $request->get_param('exclude')) {
    $ids = is_array($exclude) ? $exclude : preg_split('/[\s,]+/', trim($exclude));
    $args['exclude'] = array_map('absint', array_filter($ids, 'is_numeric'));
  }

  // タームを取得
  $terms = get_terms($args);
  // 取得直後にインデックスを振り直しておく(hide_empty => true により連番が飛ぶのを防ぐ)
  $terms =  array_values($terms);

  // エラーチェック
  if (is_wp_error($terms)) {
    return new WP_Error('term_fetch_error', 'タームの取得に失敗しました。', array('status' => 500));
  }

  // 必要な情報だけ抽出し、エスケープ処理を加えて整形
  $data = array_map(function ($term) {
    return array(
      'id'    => (int) $term->term_id,
      'name'  => esc_html($term->name),       // ターム名を HTML エスケープ
      'slug'  => sanitize_title($term->slug), // スラッグをサニタイズ
      'count' => (int) $term->count,          // 投稿数
    );
  }, $terms);

  return rest_ensure_response($data);
}

// カテゴリー用のエンドポイントコールバック関数
function my_custom_get_categories(WP_REST_Request $request) {
  return my_custom_get_terms($request, 'category');
}

// タグ用のエンドポイントコールバック関数
function my_custom_get_tags(WP_REST_Request $request) {
  return my_custom_get_terms($request, 'post_tag');
}

// REST API のエンドポイント登録
add_action('rest_api_init', function () {

  // 共通の引数定義(categories, tags 両方に使う)
  $args = array(
    'search' => array(
      'description'       => '名前による検索(部分一致)',
      'type'              => 'string',
      'required'          => false,
      'sanitize_callback' => 'sanitize_text_field',
    ),
    'orderby' => array(
      'description'       => '並び替えの基準(name, slug, count)',
      'type'              => 'string',
      'required'          => false,
      'sanitize_callback' => 'sanitize_text_field',
      'validate_callback' => function ($value) {
        // 許可された orderby 値(name, slug, count)のみ通す
        return in_array($value, ['name', 'slug', 'count']);
      },
    ),
    'order' => array(
      'description'       => '昇順/降順(asc または desc)',
      'type'              => 'string',
      'required'          => false,
      'default'           => 'asc',
      'sanitize_callback' => function ($value) {
        return strtolower($value);
      },
      'validate_callback' => function ($value) {
        return in_array($value, ['asc', 'desc']);
      },
    ),
    'hide_empty' => array(
      'description'       => '投稿がないものを除外する(true または false)',
      'type'              => 'boolean',
      'required'          => false,
      'default'           => true,
      'sanitize_callback' => 'rest_sanitize_boolean',
    ),
    'number' => array(
      'description'       => '取得するターム数(上限)',
      'type'              => 'integer',
      'required'          => false,
      'default'           => 100,
      'sanitize_callback' => 'absint',
      'validate_callback' => function ($value) {
        return is_numeric($value) && $value >= 1;
      },
    ),
    'include' => array(
      'description'       => '取得するターム ID(配列またはカンマ/スペース区切りの文字列)',
      'type'              => 'array',
      'required'          => false,
      'sanitize_callback' => function ($value) {
        // 文字列の場合はカンマまたはスペースで分割して配列化
        $ids = is_array($value) ? $value : preg_split('/[\s,]+/', trim($value));
        // 数値のみに絞って absint(負数→正数)を適用
        return array_map('absint', array_filter($ids, 'is_numeric'));
      },
      'validate_callback' => function ($value) {
        // 文字列の場合も対応(sanitize が効かない可能性に備える)
        $ids = is_array($value) ? $value : preg_split('/[\s,]+/', trim($value));
        foreach ($ids as $id) {
          // 各要素が数値でなければバリデーション失敗(処理を中断)
          if (!is_numeric($id)) return false;
        }
        return true;
      },
    ),
    'exclude' => array(
      'description'       => '除外するターム ID(配列またはカンマ/スペース区切りの文字列)',
      'type'              => 'array',
      'required'          => false,
      'sanitize_callback' => function ($value) {
        $ids = is_array($value) ? $value : preg_split('/[\s,]+/', trim($value));
        return array_map('absint', array_filter($ids, 'is_numeric'));
      },
      'validate_callback' => function ($value) {
        $ids = is_array($value) ? $value : preg_split('/[\s,]+/', trim($value));
        foreach ($ids as $id) {
          if (!is_numeric($id)) return false;
        }
        return true;
      },
    ),
  );

  // カテゴリーのエンドポイントを登録
  register_rest_route('my-theme-api/v1', '/categories/', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'my_custom_get_categories',
    'permission_callback' => '__return_true',
    'args'                => $args,
  ));

  // タグのエンドポイントを登録
  register_rest_route('my-theme-api/v1', '/tags/', array(
    'methods'             => WP_REST_Server::READABLE,
    'callback'            => 'my_custom_get_tags',
    'permission_callback' => '__return_true',
    'args'                => $args,
  ));
});

エンドポイント

  • GET /wp-json/my-theme-api/v1/categories
  • GET /wp-json/my-theme-api/v1/tags

パラメータ

以下は指定可能なパラメータです。詳細は上記コードの register_rest_route() の args に記載。

パラメータ 説明 デフォルト
search 名前による検索(部分一致 なし
orderby 並び替えの基準(name, slug, count) name
order 昇順/降順(asc または desc) asc
hide_empty 投稿がないものを除外するかどうか true
number 取得するターム数(上限) 100
include 取得するターム ID(配列またはカンマ/スペース区切りの文字列) なし
exclude 除外するターム ID(配列またはカンマ/スペース区切りの文字列) なし

指定例

/wp-json/my-theme-api/v1/categories?include=209,210,211&order=desc

返されるデータはオブジェクトの配列です。

[
  {
    "id": 211,
    "name": "Nature",
    "slug": "nature",
    "count": 2
  },
  {
    "id": 209,
    "name": "Music",
    "slug": "music",
    "count": 6
  },
  {
    "id": 210,
    "name": "Art",
    "slug": "art",
    "count": 2
  }
]

フロントエンド

JavaScript

上記で定義したカスタムエンドポイントを利用する JavaScript コードの例です。

カテゴリーやタグを指定」のコードと「カテゴリーとタグの情報を取得」のコードを1つにまとめたものです。

(() => {
  // HTMLをサニタイズして安全なテキストを返す関数
  const sanitizeHtml = (html) => {
    const div = document.createElement("div");
    div.innerHTML = html;
    return div.textContent || "";
  };

  // ページナビゲーションを表示する関数
  const renderPagination = (wrapper, totalPages, currentPage, url, target) => {
    if (totalPages <= 1) return;

    let pagination = wrapper.querySelector(".pagination-wrapper");
    if (!pagination) {
      pagination = document.createElement("ul");
      pagination.className = "pagination-wrapper";
      wrapper.appendChild(pagination);
    }
    pagination.innerHTML = "";

    for (let i = 1; i <= totalPages; i++) {
      const li = document.createElement("li");
      li.className = "page-number";

      if (i === currentPage) {
        li.classList.add("current");
        li.setAttribute("aria-current", "page");
        const span = document.createElement("span");
        span.textContent = i;
        li.appendChild(span);
      } else {
        const a = document.createElement("a");
        a.href = "#";
        a.textContent = i;
        a.addEventListener("click", (e) => {
          e.preventDefault();
          target.dataset.currentPage = i;
          renderPosts(url, target, i);
        });
        li.appendChild(a);
      }
      pagination.appendChild(li);
    }
  };

  // 投稿を取得して表示する関数
  const renderPosts = (url, target, page = 1) => {
    if (!url) {
      console.error("無効な API URL");
      return;
    }
    let showLoadmore = target.classList.contains("load-more") ? true : false;
    let showPagination = target.classList.contains("pagination") ? true : false;
    const showNoImage = target.classList.contains("show-no-image");

    if (showLoadmore && showPagination) {
      showLoadmore = false;
      showPagination = false;
      console.warn(
        "load-more クラスと pagination クラスは同時に指定できません。現在の指定は無効です。"
      );
    }

    let wrapper = target.querySelector(".post-wrapper");
    if (!wrapper) {
      wrapper = document.createElement("div");
      wrapper.className = "post-wrapper";
      wrapper.style.position = "relative";
      target.appendChild(wrapper);
    }

    let list = wrapper.querySelector("ul.post-items");
    if (!list) {
      list = document.createElement("ul");
      list.className = "post-items";
      wrapper.appendChild(list);
    }

    let loadMoreButton = wrapper.querySelector(".fetch-load-more");
    if (showLoadmore && !loadMoreButton) {
      loadMoreButton = document.createElement("button");
      loadMoreButton.className = "fetch-load-more";
      loadMoreButton.textContent = "Load More";
      loadMoreButton.style.display = "none";
      wrapper.appendChild(loadMoreButton);
    }

    let loadingIndicator = wrapper.querySelector(".loading-indicator");
    if (!loadingIndicator) {
      loadingIndicator = document.createElement("div");
      loadingIndicator.className = "loading-indicator";
      const spinner = document.createElement("span");
      spinner.className = "spinner";
      loadingIndicator.appendChild(spinner);
      loadingIndicator.appendChild(document.createTextNode("Loading..."));
      wrapper.appendChild(loadingIndicator);
    }
    loadingIndicator.classList.add("active");

    const queryParams = {
      per_page: target.dataset.perPage
        ? parseInt(target.dataset.perPage, 10)
        : 10,
      page: page,
    };
    if (showNoImage) queryParams.images = false;

    const category = target.dataset.category;
    if (category) queryParams.category = category;

    const tag = target.dataset.tag;
    if (tag) queryParams.tag = tag;

    const params = new URLSearchParams(queryParams).toString();

    fetch(`${url}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(
            `リクエスト失敗: ${response.status} ${response.statusText}`
          );
        return response.json();
      })
      .then((data) => {
        const posts = data.posts;
        const totalPages = data.total_pages
          ? parseInt(data.total_pages, 10)
          : 1;

        if (!Array.isArray(posts) || posts.length === 0) {
          list.innerHTML = "<li>投稿が見つかりませんでした。</li>";
          return;
        }

        if (showPagination || page === 1) {
          list.innerHTML = "";
        }

        const fragment = document.createDocumentFragment();

        posts.forEach((post) => {
          const link = post.link;
          if (!link) return;
          const title = sanitizeHtml(post.title);
          const date = sanitizeHtml(post.date);
          const excerpt = sanitizeHtml(post.excerpt);
          const imageUrl = showNoImage
            ? null
            : post.featured_image_medium ||
              post.featured_image_large ||
              post.featured_image_thumbnail ||
              post.featured_image_full ||
              undefined;

          const listItem = document.createElement("li");
          listItem.className = "post-item";

          if (imageUrl) {
            const imageWrapper = document.createElement("div");
            imageWrapper.className = "featured-image";
            const imageLink = document.createElement("a");
            imageLink.href = link;
            const img = document.createElement("img");
            img.src = imageUrl;
            img.alt = title;
            imageLink.appendChild(img);
            imageWrapper.appendChild(imageLink);
            listItem.appendChild(imageWrapper);
          }

          const postHeading = document.createElement("h3");
          postHeading.className = "post-title";
          const titleLink = document.createElement("a");
          titleLink.href = link;
          titleLink.textContent = title;
          postHeading.appendChild(titleLink);
          listItem.appendChild(postHeading);

          const postMetaDiv = document.createElement("div");
          postMetaDiv.className = "post-meta";

          const dateP = document.createElement("p");
          dateP.className = "post-date";
          const options = {
            year: "numeric",
            month: "long",
            day: "numeric",
          };
          dateP.textContent = `投稿日: ${new Date(date).toLocaleDateString(
            "ja-JP",
            options
          )}`;
          postMetaDiv.appendChild(dateP);

          if (Array.isArray(post.categories) && post.categories.length > 0) {
            const categoryDiv = document.createElement("div");
            categoryDiv.className = "post-categories";
            categoryDiv.textContent = "カテゴリ: ";
            post.categories.forEach((cat, index) => {
              const catLink = document.createElement("a");
              catLink.href = cat.link;
              catLink.textContent = sanitizeHtml(cat.label);
              catLink.className = "post-category";
              categoryDiv.appendChild(catLink);
              if (index < post.categories.length - 1) {
                categoryDiv.appendChild(document.createTextNode(", "));
              }
            });
            postMetaDiv.appendChild(categoryDiv);
          }

          if (Array.isArray(post.tags) && post.tags.length > 0) {
            const tagDiv = document.createElement("div");
            tagDiv.className = "post-tags";
            tagDiv.textContent = "タグ: ";
            post.tags.forEach((tag, index) => {
              const tagLink = document.createElement("a");
              tagLink.href = tag.link;
              tagLink.textContent = sanitizeHtml(tag.label);
              tagLink.className = "post-tag";
              tagDiv.appendChild(tagLink);
              if (index < post.tags.length - 1) {
                tagDiv.appendChild(document.createTextNode(", "));
              }
            });
            postMetaDiv.appendChild(tagDiv);
          }
          listItem.appendChild(postMetaDiv);

          const excerptP = document.createElement("p");
          excerptP.className = "post-excerpt";
          excerptP.textContent = excerpt;
          listItem.appendChild(excerptP);
          fragment.appendChild(listItem);
        });
        list.appendChild(fragment);

        if (showLoadmore) {
          loadMoreButton.style.display =
            page < totalPages ? "inline-block" : "none";
        }

        if (showPagination && totalPages > 1) {
          renderPagination(wrapper, totalPages, page, url, target);
        }
      })
      .catch((err) => {
        list.innerHTML = `<li>投稿の取得に失敗しました: ${err.message}</li>`;
      })
      .finally(() => {
        target.dataset.isLoading = "false";
        loadingIndicator.classList.remove("active");
      });

    if (showLoadmore) {
      if (!loadMoreButton.dataset.listenerAdded) {
        loadMoreButton.addEventListener("click", () => {
          if (target.dataset.isLoading === "true") return;
          target.dataset.isLoading = "true";
          let currentPage = parseInt(target.dataset.currentPage || "1", 10);
          currentPage++;
          target.dataset.currentPage = currentPage;
          renderPosts(url, target, currentPage);
        });
        loadMoreButton.dataset.listenerAdded = "true";
      }
    }
    target.dataset.currentPage = page;
  };

  // タームのセレクトボックスを表示する関数
  const renderTermSelect = (target) => {
    const baseUrl = "https://example.com/wp-json/my-theme-api/v1/";

    const taxonomy = target.classList.contains("tag") ? "tags" : "categories";
    const taxonomySingular = target.classList.contains("tag") ? "tag" : "category";
    const showCount = target.classList.contains("show-count");
    let showLoadmore = target.classList.contains("load-more");

    const number = target.dataset.number ? parseInt(target.dataset.number, 10) : 100;
    const search = target.dataset.search;
    const orderby = target.dataset.orderby;
    const order = target.dataset.order;
    const hide_empty = target.dataset.hideEmpty !== undefined ? target.dataset.hideEmpty : "true";
    const include = target.dataset.include;
    const exclude = target.dataset.exclude;
    const initialTerm = target.dataset.initialTerm;
    const placeholder = target.dataset.placeholder ? target.dataset.placeholder : "選択してください";

    const queryParams = { number };
    if (search) queryParams.search = search;
    if (orderby) queryParams.orderby = orderby;
    if (order) queryParams.order = order;
    if (hide_empty) queryParams.hide_empty = hide_empty;
    if (include) queryParams.include = include.toString();
    if (exclude) queryParams.exclude = exclude.toString();

    const params = new URLSearchParams(queryParams).toString();

    let postsWrapper = target.querySelector(".post-wrapper");
    if (!postsWrapper) {
      postsWrapper = document.createElement("div");
      postsWrapper.className = "post-wrapper";
      target.appendChild(postsWrapper);
    }

    let list = postsWrapper.querySelector("ul.post-links");
    if (!list) {
      list = document.createElement("ul");
      list.className = "post-links";
      postsWrapper.appendChild(list);
    }

    let loadMoreButton = postsWrapper.querySelector(".fetch-load-more");
    if (showLoadmore && !loadMoreButton) {
      loadMoreButton = document.createElement("button");
      loadMoreButton.className = "fetch-load-more";
      loadMoreButton.textContent = "Load More";
      loadMoreButton.style.display = "none";
      postsWrapper.appendChild(loadMoreButton);
    }

    let loadingIndicator = postsWrapper.querySelector(".loading-indicator");
    if (!loadingIndicator) {
      loadingIndicator = document.createElement("div");
      loadingIndicator.className = "loading-indicator";
      const spinner = document.createElement("span");
      spinner.className = "spinner";
      loadingIndicator.appendChild(spinner);
      loadingIndicator.appendChild(document.createTextNode("Loading..."));
      postsWrapper.appendChild(loadingIndicator);
    }

    const select = document.createElement("select");
    select.className = taxonomy + "-select";
    target.insertBefore(select, postsWrapper);

    fetch(`${baseUrl}${taxonomy}?${params}`)
      .then((response) => {
        if (!response.ok)
          throw new Error(`リクエスト失敗: ${response.status} ${response.statusText}`);
        return response.json();
      })
      .then((data) => {
        const fragment = document.createDocumentFragment();

        const defaultOption = document.createElement("option");
        defaultOption.value = "";
        defaultOption.textContent = placeholder;
        fragment.appendChild(defaultOption);

        data.forEach((term) => {
          const option = document.createElement("option");
          option.value = term.slug;
          if (term.slug === initialTerm) option.selected = true;
          option.textContent = showCount ? `${term.name} (${term.count})` : term.name;
          fragment.appendChild(option);
        });

        select.appendChild(fragment);

        if (initialTerm) {
          select.value = initialTerm;
        }
      })
      .catch((err) => {
        console.error("カテゴリの取得に失敗しました", err);
      });

    const per_page = target.dataset.perPage ? parseInt(target.dataset.perPage, 10) : 10;

    // 投稿一覧を取得・表示する関数
    const renderPostLinks = (url, slug, page = 1) => {
      const queryParams = {
        images: false,
        per_page: per_page,
        page: page,
      };

      if (slug) { queryParams[taxonomySingular] = slug; }

      const params = new URLSearchParams(queryParams).toString();

      loadingIndicator.classList.add("active");

      fetch(`${url}?${params}`)
        .then((response) => {
          if (!response.ok) {
            throw new Error(`リクエスト失敗: ${response.status} ${response.statusText}`);
          }
          return response.json();
        })
        .then((data) => {
          const totalPages = data.total_pages ? parseInt(data.total_pages, 10) : 1;
          target.dataset.totalPages = totalPages;
          const posts = data.posts;
          if (!Array.isArray(posts) || posts.length === 0) {
            list.innerHTML = "<li>投稿が見つかりませんでした。</li>";
            if (showLoadmore && loadMoreButton) {
              loadMoreButton.style.display = "none";
            }
            return;
          }

          if (page === 1) list.innerHTML = "";

          const fragment = document.createDocumentFragment();

          posts.forEach((post) => {
            const link = post.link;
            if (!link) return;
            const title = sanitizeHtml(post.title);
            const listItem = document.createElement("li");
            listItem.className = "post-link";
            const postHeading = document.createElement("h3");
            postHeading.className = "post-title";
            const titleLink = document.createElement("a");
            titleLink.href = link;
            titleLink.textContent = title;
            postHeading.appendChild(titleLink);
            listItem.appendChild(postHeading);
            fragment.appendChild(listItem);
          });

          list.appendChild(fragment);

          if (showLoadmore) {
            loadMoreButton.style.display = page < totalPages ? "inline-block" : "none";
          }
        })
        .catch((err) => {
          list.innerHTML = `<li>投稿の取得に失敗しました: ${err.message}</li>`;
        })
        .finally(() => {
          target.dataset.isLoading = "false";
          loadingIndicator.classList.remove("active");
        });

      if (showLoadmore) {
        if (!loadMoreButton.dataset.listenerAdded) {
          loadMoreButton.addEventListener("click", () => {
            if (target.dataset.isLoading === "true") return;
            target.dataset.isLoading = "true";
            let currentPage = parseInt(target.dataset.currentPage || "1", 10);
            currentPage++;
            target.dataset.currentPage = currentPage;
            const selectedSlug = select.value || null;
            renderPostLinks(`${baseUrl}posts`, selectedSlug, currentPage);
          });
          loadMoreButton.dataset.listenerAdded = "true";
        }
      }
    };

    target.dataset.currentPage = "1";
    renderPostLinks(`${baseUrl}posts`, initialTerm || null);

    select.addEventListener("change", (e) => {
      target.dataset.currentPage = "1";
      renderPostLinks(`${baseUrl}posts`, e.target.value);
    });
  };

  document.addEventListener("DOMContentLoaded", () => {
    const url = "https://example.com/wp-json/my-theme-api/v1/posts";
    // 投稿一覧を表示する要素
    const targets = Array.from(document.getElementsByClassName("fetch-posts"));
    targets.forEach((target) => {
      renderPosts(url, target, 1);
    });

    // タームのセレクトボックスを表示する要素
    const termSelects = Array.from(document.getElementsByClassName("term-select"));
    termSelects.forEach((termSelect) => {
      renderTermSelect(termSelect);
    });
  });
})();
HTML

HTML では上記の JavaScript を読み込みます。

そして投稿一覧を表示する場合は fetch-posts クラスを指定した要素を配置し、カテゴリーやタグのセレクトボックスを表示して投稿リストを表示する場合は term-select クラスを指定した要素を配置します。

それぞれ、1ページに複数配置することが可能です。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>Custom Endpoint Sample</title>
</head>
<body>
  <div>
    <!-- 投稿一覧を取得して表示(ページネーションを表示) -->
    <div class="fetch-posts pagination" data-category="music" data-per-page="3"></div>
    <!-- 投稿一覧を取得して表示(Load More ボタンを表示) -->
    <div class="fetch-posts load-more" data-per-page="5"></div>

    <!-- タグのセレクトボックスを表示して投稿を表示 -->
    <div class="term-select tag" data-placeholder="タグを選択" data-per-page="5"></div>
    <!-- カテゴリーのセレクトボックスを表示して投稿を表示 -->
    <div class="term-select show-count load-more" data-initial-term="music" data-per-page="3"></div>
  </div>

  <!-- JavaScript の読み込み -->
  <script src="custom-endpoint.js"></script>
</body>
</html>
fetch-posts クラス

fetch-posts クラスを指定した div 要素を配置すると、各投稿のタイトル(リンク付き)、アイキャッチ画像(非表示可能)、投稿日、抜粋を表示します。

クラス属性

追加で以下のクラスを指定できます。

  • pagination:投稿数が多い場合はページネーションを表示
  • load-more:投稿数が多い場合は追加読み込みボタンを表示
  • show-no-image:アイキャッチ画像を表示しない

カスタムデータ属性

また、以下のカスタムデータ属性を指定できます。

  • data-per-page:取得する投稿件数(デフォルトは10)
  • data-category:表示する投稿のカテゴリースラッグを指定(カンマ区切りで複数指定可能)
  • data-tag:表示する投稿のタグスラッグを指定(カンマ区切りで複数指定可能)
term-select クラス

term-select クラスを指定した div 要素を配置すると、デフォルトではカテゴリーのセレクトボックスを表示し、選択されたカテゴリーの投稿リスト(リンク付きタイトル)を表示します。

クラス属性

追加で以下のクラスを指定できます。

  • tag:カテゴリーではなくタグのタームを表示
  • show-count:セレクトボックスにタームを表示する際に属する投稿数を表示
  • load-more:投稿数が多い場合は追加読み込みボタンを表示

カスタムデータ属性

また、以下のカスタムデータ属性を指定できます。

  • data-number: 取得するターム数の上限。デフォルト:100。
  • data-order:昇順/降順(asc または desc)。デフォルト:asc
  • data-orderby:並び替えの基準(name, slug, count)デフォルト:name
  • data-include: 取得するターム ID(配列またはカンマ/スペース区切りの文字列)
  • data-exclude: 除外するターム ID(配列またはカンマ/スペース区切りの文字列)
  • data-initial-term: 初期状態で表示する投稿のターム(デフォルトはすべての投稿)
  • data-per-page:取得する投稿件数。デフォルト:10
  • data-placeholder: セレクトボックスのラベル。デフォルト:「選択してください」
  • data-hide-empty:false を指定すると、投稿がないタームも取得。デフォルト: true
CSS

以下は CSS の例です。環境や好みに応じて変更します。

body {
  max-width: 960px;
  margin: 0 auto;
}

.fetch-posts {
  margin: 80px 0;
}

.post-items,
.post-links {
  list-style-type: none;
}

.post-item {
  margin-bottom: 1.5rem;
  background-color: #eee;
  padding: 10px;
  max-width: 600px;
}

.post-title {
  margin: 20px 0;
}

.post-title a {
  text-decoration: none;
  font-size: 16px;
  color: #0073aa;
}

.featured-image {
  max-width: 240px;
}

.featured-image img {
  object-fit: cover;
  aspect-ratio: 16/10;
  width: 100%;
}

.post-date,
.post-categories,
.post-tags {
  display: inline-block;
  margin-right: 1rem;
  font-size: .875rem;
}

.post-categories a,
.post-tags a {
  text-decoration: none;
  color: #0073aa;
}

/* Load More ボタン */
.fetch-load-more {
  display: block;
  width: 160px;
  padding: 10px;
  margin-top: 15px;
  font-size: 16px;
  color: #fff;
  background-color: #0073aa;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s;
}

.fetch-load-more:hover {
  background-color: #005f8d;
}

/* ページネーション */
.pagination-wrapper {
  display: flex;
  gap: 0.5rem;
  list-style: none;
  padding: 0;
  margin-top: 1rem;
  flex-wrap: wrap;
}

.pagination-wrapper li a {
  padding: 0.4rem 0.8rem;
  text-decoration: none;
  border: 1px solid #ccc;
  border-radius: 4px;
  color: #333;
  transition: background-color 0.3s;
}

.pagination-wrapper li a:hover {
  background-color: #005f8d;
  color: #fff;
}

.pagination-wrapper li.current span {
  background-color: #76acc6;
  color: #fff;
  padding: 0.4rem 0.8rem;
  text-decoration: none;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.pagination-wrapper li.disabled a {
  color: #aaa;
  pointer-events: none;
}

.pagination-wrapper li.page-dots {
  padding: 0.4rem 0.8rem;
  color: #999;
}

/* ローディングインジケーター */
.loading-indicator {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.7);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 10;
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.loading-indicator.active {
  opacity: 1;
  pointer-events: auto;
}

.spinner {
  width: 24px;
  height: 24px;
  border: 3px solid #ccc;
  border-top-color: #3498db;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

/* セレクトボックス */
.term-select select {
  border: 1px solid #aaa;
  padding: 5px 20px 5px 10px;
  cursor: pointer;
  margin: 30px 0 20px;
}