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() の書式と引数
引数 | 説明 |
---|---|
$namespace | エンドポイント(API)の名前空間(例: myplugin/v1)。通常は plugin-name/v1 のようにバージョンを含めて指定します。前後にスラッシュは付けません。推奨される $namespace の指定方法 |
$route | ルートの URL パス。最終的な URL は /wp-json/namespace/route になります。先頭にスラッシュを付けますが、末尾のスラッシュはあってもなくてもOK。 |
$args | ルートの詳細設定(HTTPメソッド、コールバック関数、パーミッションなど)を含む配列です。以下参照。 |
プロパティ | 説明 |
---|---|
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 にラップして処理してくれます。
以下はオブジェクトを返す例です。
WP_REST_Response オブジェクト
より細かく HTTP ステータスやヘッダーを制御したい場合に使います。
WP_Error オブジェクト(エラーを返す場合)
rest_ensure_response()
rest_ensure_response() はREST API のレスポンス形式を自動的に整えてくれる便利な関数です。
この関数を使えば、戻り値が配列・オブジェクト・エラーなどであっても、WordPress REST API に適した形(WP_REST_Response)に自動変換して返してくれます。
入力値の型 | 処理内容 |
---|---|
配列 or オブジェクト | WP_REST_Response にラップして返す |
WP_REST_Response | そのまま返す(変更なし) |
WP_Error | そのまま返す(変更なし) |
配列を返す場合
オブジェクトを返す場合
WP_REST_Response を使って直接レスポンスを操作したい場合
エラーを返す場合(WP_Error)
REST API では、WP_Error を使ってエラーレスポンスを返すことができます。rest_ensure_response() を通すことで、正しい HTTP ステータスとともに返されます。
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 を明示的に指定することが推奨されます。
認証が必要な場合の指定方法
一方、API に認証が必要な場合や、アクセス制限を設けたい場合は、permission_callback でユーザーの権限をチェックする必要があります。たとえば、投稿の編集権限を持つユーザーのみに限定したい場合は以下のように記述できます。
このように permission_callback は、API の公開・非公開を制御する重要なセキュリティ機能です。意図せずデータを誰にでも公開してしまうことがないように注意する必要があります。
アイキャッチ画像を取得
標準の投稿のエンドポイント(/wp-json/wp/v2/posts/)では、認証なしではアイキャッチ画像のデータを取得できませんが、カスタムエンドポイントを設定すれば取得することができます。
以下はカスタム REST API エンドポイント(/wp-json/custom/v1/posts/)を作成し、必要なデータ(タイトル、抜粋、リンク、アイキャッチ画像)を含めて公開する例です。
functions.php やプラグインファイルに記述します。
この例では、get_posts() ではなく new WP_Query() を使用しているため、パフォーマンス向上のために以下のパラメータを明示的に指定しています。
- no_found_rows:ページネーション情報(総件数やページ数)を取得しないようにする。
- ignore_sticky_posts:先頭固定表示の投稿(スティッキーポスト)を無視する。
※ get_posts() の内部では WP_Query が使われていますが、簡易クエリとして no_found_rows や ignore_sticky_posts は true に設定されているため、これらの値を明示的に指定できません。
// 投稿データ(アイキャッチ画像付き)を取得する関数
function my_custom_get_posts() {
// WP_Query 用のクエリパラメータを定義
$args = array(
'post_type' => 'post', // 投稿タイプ(通常の投稿)
'posts_per_page' => 10, // 取得する投稿数(最大10件)
'no_found_rows' => true, // ページネーション情報を無視して高速化(ページ数や総件数が不要な場合に有効)
'ignore_sticky_posts' => true, // スティッキーポスト(先頭に固定表示された投稿)を無視(必要に応じて)
);
// WP_Query インスタンスを作成し、投稿を取得
$query = new WP_Query($args);
$posts_data = array(); // 投稿データを格納する配列
// 投稿が存在する場合の処理
if ($query->have_posts()) {
while ($query->have_posts()) {
$query->the_post(); // 現在の投稿データをセット
// 取得した投稿データをエスケープして配列に追加
$posts_data[] = array(
// 投稿タイトルから HTML を除去してエスケープ
'title' => esc_html(wp_strip_all_tags(get_the_title())),
// 抜粋から HTML タグを除去してエスケープ
'excerpt' => esc_html(wp_strip_all_tags(get_the_excerpt())),
// 投稿 URL をエスケープ
'link' => esc_url(get_permalink()),
// アイキャッチ画像の各サイズの URL を取得・エスケープ
'featured_image_full' => esc_url(get_the_post_thumbnail_url(get_the_ID(), 'full')),
'featured_image_large' => esc_url(get_the_post_thumbnail_url(get_the_ID(), 'large')),
'featured_image_medium' => esc_url(get_the_post_thumbnail_url(get_the_ID(), 'medium')),
'featured_image_thumbnail' => esc_url(get_the_post_thumbnail_url(get_the_ID(), 'thumbnail')),
);
}
wp_reset_postdata(); // グローバルな投稿データをリセット
}
// REST API レスポンスとしてデータを返す
return rest_ensure_response($posts_data);
}
// カスタム REST API エンドポイントを登録する関数
function register_my_custom_rest_route() {
register_rest_route('custom/v1', '/posts/', array(
'methods' => WP_REST_Server::READABLE, // GET リクエスト(+ HEAD リクエスト)
'callback' => 'my_custom_get_posts', // 呼び出す関数
'permission_callback' => '__return_true', // 認証なしでアクセス可能にする(明示的に指定)
));
}
// REST API が初期化されるタイミングでカスタムエンドポイントを登録
add_action('rest_api_init', 'register_my_custom_rest_route');
アイキャッチ画像が存在しない場合の処理
上記のコードでは get_the_post_thumbnail_url() が false を返した場合、そのまま esc_url(false) になるため、アイキャッチ画像が存在しない場合、結果として空文字が返ります。
以下のように has_post_thumbnail() を使用すれば、明示的に null を返すことができます。
'featured_image_full' => has_post_thumbnail() ? esc_url(get_the_post_thumbnail_url(get_the_ID(), 'full')) : null,
'featured_image_large' => has_post_thumbnail() ? esc_url(get_the_post_thumbnail_url(get_the_ID(), 'large')) : null,
'featured_image_medium' => has_post_thumbnail() ? esc_url(get_the_post_thumbnail_url(get_the_ID(), 'medium')) : null,
'featured_image_thumbnail' => has_post_thumbnail() ? esc_url(get_the_post_thumbnail_url(get_the_ID(), 'thumbnail')) : null,
アイキャッチ画像がない投稿では null を返すことで、API 利用者が「画像がない投稿」として判断しやすくなる(仕様として明確になる)というメリットがあります。必要に応じて変更します。
確認
上記のコードを追加して、以下の URL(example.com の部分は適宜変更)にアクセスすると、10件の各投稿のデータが JSON 形式で取得できます。
https://example.com/wp-json/custom/v1/posts
JavaScript(フロントエンド)
作成したカスタムエンドポイントを使って、WordPress の外部のサイトで投稿を取得して表示する例です。
以下は HTML の例です。
fetch-posts クラスを指定した要素に投稿リストを出力します(1ページに複数配置可能)。必要に応じて、同時に show-no-image クラスを指定するとアイキャッチ画像は出力しません。
以下では、JavaScript を script タグで読み込んでいますが、script タグ内に記述することもできます。
<!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>Sample</title>
</head>
<body>
<div>
<h1>WordPress REST API Custom Endpoint Sample </h1>
<div class="fetch-posts"></div><!-- 出力先の要素(ターゲット) -->
<div class="fetch-posts show-no-image"></div><!-- アイキャッチ画像を非表示に -->
</div>
<script src="custom-endpoint-sample.js"></script>
</body>
</html>
以下が JavaScript です。
カスタムエンドポイント側では、投稿タイトルと抜粋をあらかじめエスケープして返していますが、フロントエンドでは HTML を生成する際に innerHTML を使用しているため、念のため XSS 対策として、独自のサニタイズ関数 sanitizeHtml() を用いて文字列をクリーンなテキストに変換しています。
また、1ページに複数の .fetch-posts 要素(出力先の要素)を配置可能な構成としているため、各要素ごとに個別に fetch を実行するのではなく、1回の fetch 処理で取得した投稿データを複数の要素に分配しています。
このため、fetch() は DOMContentLoaded イベント発火時に 1回だけ実行し、取得した投稿データを使って、各 .fetch-posts 要素に対して renderPosts() 関数で投稿一覧を描画しています。
なお、アイキャッチ画像の表示に関しては、featured_image_medium を優先的に使用し、取得できない場合は large、thumbnail、full の順で代替画像を使用しています(この優先順位は必要に応じて環境に合わせて調整可能です)。
(() => {
// HTML の文字列からプレーンテキストを抽出する関数(innerHTML に代入し、textContent で HTML タグを除去)
const sanitizeHtml = (htmlRendered) => {
if (!htmlRendered) return ""; // null や undefined 対策
const elem = document.createElement("div");
elem.innerHTML = htmlRendered;
return elem.textContent || ""; // タグ除去後のテキストを返す
};
// 取得した投稿データを指定された要素(target)に描画する関数
// posts: 取得した投稿データ(配列)
// target: 描画先のDOM要素(.fetch-posts)
const renderPosts = (posts, target) => {
// show-no-image クラスが含まれているかチェック
const showNoImage = target.classList.contains("show-no-image");
// 投稿リスト(ul 要素)を生成して出力先要素に追加
const list = document.createElement("ul");
list.className = "post-items";
target.appendChild(list);
// 投稿が空( 0 件)だった場合の処理
if (!Array.isArray(posts) || posts.length === 0) {
list.innerHTML = "<li>投稿が見つかりませんでした。</li>";
return;
}
// 投稿データをHTML化してリストに挿入
list.innerHTML = posts
.map((post) => {
const link = post.link;
const sanitizedTitle = sanitizeHtml(post.title); // タイトルをサニタイズ
const sanitizedExcerpt = 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;
// 投稿リンクが存在すればHTML構造を返す
return link
? `<li class="post-item">
${imageUrl ? `<div class="featured-image"><a href="${link}"><img src="${imageUrl}" alt="${sanitizedTitle}"></a></div>` : ""}
<h3 class="post-title"><a href="${link}">${sanitizedTitle}</a></h3>
<p class="post-excerpt">${sanitizedExcerpt}</p>
</li>`
: "";
})
.join(""); // 各投稿の HTML を連結して ul 要素に挿入
};
document.addEventListener("DOMContentLoaded", () => {
// カスタムREST APIエンドポイントのURL
const url = "https://example.com/wp-json/custom/v1/posts";
// 投稿表示対象の全ての要素を取得(.fetch-posts クラス)
const targets = Array.from(document.getElementsByClassName("fetch-posts"));
// 対象が1つもなければ処理しない
if (!targets.length) return;
// 投稿データをAPIから1回だけ取得(fetch)
fetch(url)
.then((response) => {
// ステータスコードが正常でなければエラーを投げる
if (!response.ok)
throw new Error(
`リクエスト失敗: ${response.status} ${response.statusText}`
);
return response.json(); // レスポンスをJSONに変換
})
.then((posts) => {
// 各ターゲット要素に投稿を描画(同じデータを使いまわし)
targets.forEach((target) => {
renderPosts(posts, target);
});
})
.catch((error) => {
// 通信失敗時の処理:各ターゲットにエラーメッセージを表示
console.warn("投稿の取得エラー:", error);
targets.forEach((target) => {
const list = document.createElement("ul");
list.className = "post-items";
list.innerHTML = `<li>投稿の取得に失敗しました:${error.message}</li>`;
target.appendChild(list);
});
});
});
})();
投稿データを HTML のリストに変換する処理
以下は投稿データを HTML のリストに変換する処理する部分のコードです(上記コードの29-52行目)。
posts は投稿データの配列なので、map() と join() を使って HTML のマークアップを文字列として作成し、それをまとめて list.innerHTML にセットしています。その際、有効なリンクがある投稿だけを出力するようにしています。
post.title と post.excerpt はエンドポイント側でエスケープ処理されていますが、innerHTML を使用して挿入しているので、安全対策として再度サニタイズしてから表示するようにしています。
// 投稿データをHTML化してリストに挿入
list.innerHTML = posts
.map((post) => {
const link = post.link;
const sanitizedTitle = sanitizeHtml(post.title); // タイトルをサニタイズ
const sanitizedExcerpt = 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;
// 投稿リンクが存在すればHTML構造を返す
return link
? `<li class="post-item">
${imageUrl ? `<div class="featured-image"><a href="${link}"><img src="${imageUrl}" alt="${sanitizedTitle}"></a></div>` : ""}
<h3 class="post-title"><a href="${link}">${sanitizedTitle}</a></h3>
<p class="post-excerpt">${sanitizedExcerpt}</p>
</li>`
: "";
})
.join(""); // 各投稿の HTML を連結して ul 要素に挿入
innerHTML を使用しない例
上記の場合、HTML を文字列として一気に作成しているので高速ですが、.innerHTML を直接代入する方法は XSS のリスクもあります(この例の場合はタイトルと抜粋をサニタイズしているので安全ですが)。
セキュリティ対策や Content Security Policy(CSP)の制約がある場合、innerHTML の使用が制限されることがあります。そのような場合でも、createElement() と textContent を使って DOM を構築すれば、JavaScript 側での XSS(クロスサイトスクリプティング)リスクを回避できます。
以下のコードは、innerHTML を使わず、createElement() と textContent によって安全に要素を組み立てています。この場合も念の為タイトルと抜粋はサニタイズします。
また、複数の要素を追加する際には DocumentFragment を活用することで、一時的にメモリ上で DOM を組み立ててから一括で追加しており、描画回数を減らしてパフォーマンスの向上も図っています。
// 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);
(() => {
// HTML の文字列からプレーンテキストを抽出する関数(innerHTML に代入し、textContent で HTML タグを除去)
const sanitizeHtml = (htmlRendered) => {
if (!htmlRendered) return ""; // null や undefined 対策
const elem = document.createElement("div");
elem.innerHTML = htmlRendered;
return elem.textContent || ""; // タグ除去後のテキストを返す
};
// 取得した投稿データを指定された要素(target)に描画する関数
// posts: 取得した投稿データ(配列)
// target: 描画先のDOM要素(.fetch-posts)
const renderPosts = (posts, target) => {
// show-no-image クラスが含まれているかチェック
const showNoImage = target.classList.contains("show-no-image");
// 投稿リスト(ul 要素)を生成して出力先要素に追加
const list = document.createElement("ul");
list.className = "post-items";
target.appendChild(list);
// 投稿が空( 0 件)だった場合の処理
if (!Array.isArray(posts) || posts.length === 0) {
list.innerHTML = "<li>投稿が見つかりませんでした。</li>";
return;
}
// 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);
};
document.addEventListener("DOMContentLoaded", () => {
// カスタムREST APIエンドポイントのURL
const url = "https://example.com/wp-json/custom/v1/posts";
// 投稿表示対象の全ての要素を取得(.fetch-posts クラス)
const targets = Array.from(document.getElementsByClassName("fetch-posts"));
// 対象が1つもなければ処理しない
if (!targets.length) return;
// 投稿データをAPIから1回だけ取得(fetch)
fetch(url)
.then((response) => {
// ステータスコードが正常でなければエラーを投げる
if (!response.ok)
throw new Error(
`リクエスト失敗: ${response.status} ${response.statusText}`
);
return response.json(); // レスポンスをJSONに変換
})
.then((posts) => {
// 各ターゲット要素に投稿を描画(同じデータを使いまわし)
targets.forEach((target) => {
renderPosts(posts, target);
});
})
.catch((error) => {
// 通信失敗時の処理:各ターゲットにエラーメッセージを表示
console.warn("投稿の取得エラー:", error);
targets.forEach((target) => {
const list = document.createElement("ul");
list.className = "post-items";
list.innerHTML = `<li>投稿の取得に失敗しました:${error.message}</li>`;
target.appendChild(list);
});
});
});
})();
ページネーション情報を取得
フロントエンドでの「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 など全てを統一して取り扱ってくれます。
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 のパラメータ処理の流れは以下のようになっています。
- クライアントがリクエストを送る(例:?orderby=name&number=10)
- register_rest_route() の args 配列に定義された各パラメータごとに処理:
- sanitize_callback(あれば)を実行 → 戻り値を次に渡す
- そのサニタイズ済みの値を validate_callback に渡す (以下参照)
- validate_callback が true を返せば通過、false または WP_Error を返せばエラーとして処理終了
- すべてのパラメータの検証が通過した場合に、エンドポイントの 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 を適用しています。
カスタムエンドポイントサンプル
以下はカスタムエンドポイントを作成する 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 […]",
"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 […]",
"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;
}