2026年7月5日
2026年7月5日
WordPressでブロック単位のコメント(議論スレッド)を実装する方法
「ブロックごとにコメントを付けて議論したい」という要望には、2つの意味があります。編集チーム内で原稿のブロックにコメントを残したいなら、WordPress 6.9で追加されたコア機能「メモ(Notes)」を使うのが近道です。読者に段落ごとの議論スレッドを提供したいなら、コメント機能を土台に自分で実装します。この記事では前者の使いどころを整理したうえで、後者を子テーマのコードで実装する手順を、スパム対策や運用上の注意も含めて解説します。
編集用ならコアの「メモ」機能を先に検討する
WordPress 6.9では、ブロックエディター上で個々のブロックにコメント(メモ)を残し、返信したり解決済みにしたりできる機能が追加されました(2026年10月現在)。記事を書く人とレビューする人がブロック単位でやり取りする用途なら、まずこの機能で足りるかを確かめてください。
押さえておきたい点は次のとおりです。
- 公開ページには表示されない: 編集画面の中だけのやり取りです。読者向けの機能ではありません。
- 記事を編集できるユーザーが対象: 投稿の編集権限を持つユーザー同士で使う想定です。
- データはコメントの仕組みで保存される: 通常のコメントとは別の種類として保存される仕組みです。コメント関連のプラグインや独自コードが種類を区別せずにコメントを扱っている場合、想定外の表示や件数のずれが出ないか、更新前にステージングで確認してください。
画面上の表記や操作の場所はバージョンによって変わることがあるため、使い方はWordPress.orgのリリース情報やヘルプで最新を確認してください。6.9より前のバージョンを使い続けている場合は、まず本体の更新を検討します。
ここから先は、読者が段落ごとに議論できる仕組みの実装です。
読者向けスレッドの設計
一から専用のテーブルを作るより、WordPressのコメント機能に載せる方が、承認制・スパム対策・管理画面での管理・個人データのエクスポートや削除といった既存の仕組みをそのまま使えます。設計は次のとおりです。
- どのブロックか: 段落・見出しブロックの「HTMLアンカー」(ブロック設定の「高度な設定」にある欄)を目印にします。アンカーを付けたブロックだけが議論の対象になるので、編集者が対象を選べます。
- 保存先: コメントとして保存し、コメントの種類を
wpdr_block、どのブロックかをコメントメタ_wpdr_block_anchorに記録します。 - 返信: コメントの親子関係(
comment_parent)を使い、1段階の返信まで受け付けます。 - 投稿できる人: ログインユーザーに限定します。ゲストにも開放するとスパム対策の負担が大きく増えるためです。
- 通常のコメント欄とは分ける: 記事下のコメント一覧には出さず、管理画面では通常どおり承認・削除できるようにします。
コードは子テーマの functions.php に追加する想定です。追加前にバックアップを取り、ステージング環境で動作を確認してください。
サーバー側(PHP)の実装
まず、アンカー付きのブロックに目印の属性を付け、スレッドの取得と投稿のためのREST APIを用意します。
<?php
define( 'WPDR_BT_TYPE', 'wpdr_block' );
define( 'WPDR_BT_META', '_wpdr_block_anchor' );
function wpdr_bt_valid_anchor( $value ) {
return is_string( $value ) && 1 === preg_match( '/^[A-Za-z0-9_-]{1,64}$/', $value );
}
// アンカー付きの段落・見出しに data-wpdr-thread 属性を付ける
add_filter( 'render_block', function ( $content, $block ) {
if ( ! is_singular( 'post' ) || get_the_ID() !== get_queried_object_id() ) {
return $content;
}
if ( ! in_array( $block['blockName'], array( 'core/paragraph', 'core/heading' ), true ) ) {
return $content;
}
$p = new WP_HTML_Tag_Processor( $content );
if ( ! $p->next_tag() ) {
return $content;
}
$anchor = $p->get_attribute( 'id' );
if ( ! wpdr_bt_valid_anchor( $anchor ) ) {
return $content;
}
$p->set_attribute( 'data-wpdr-thread', $anchor );
return $p->get_updated_html();
}, 10, 2 );
add_action( 'rest_api_init', function () {
register_rest_route( 'wpdr/v1', '/block-threads/(?P<post>\d+)', array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'wpdr_bt_list',
'permission_callback' => '__return_true',
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'wpdr_bt_create',
'permission_callback' => 'is_user_logged_in',
'args' => array(
'anchor' => array(
'type' => 'string',
'required' => true,
'validate_callback' => 'wpdr_bt_valid_anchor',
),
'content' => array( 'type' => 'string', 'required' => true ),
'parent' => array( 'type' => 'integer', 'default' => 0 ),
),
),
) );
} );
function wpdr_bt_get_open_post( $post_id ) {
$post = get_post( $post_id );
if ( ! $post || 'post' !== $post->post_type || 'publish' !== $post->post_status || post_password_required( $post ) ) {
return null;
}
return $post;
}
// 承認済みのスレッドを一覧で返す
function wpdr_bt_list( WP_REST_Request $request ) {
$post = wpdr_bt_get_open_post( (int) $request['post'] );
if ( ! $post ) {
return new WP_Error( 'wpdr_bt_not_found', '記事が見つかりません。', array( 'status' => 404 ) );
}
$comments = get_comments( array(
'post_id' => $post->ID,
'type' => WPDR_BT_TYPE,
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => 'ASC',
'number' => 500,
) );
$items = array();
foreach ( $comments as $c ) {
$items[] = array(
'id' => (int) $c->comment_ID,
'parent' => (int) $c->comment_parent,
'anchor' => get_comment_meta( $c->comment_ID, WPDR_BT_META, true ),
'author' => get_comment_author( $c ),
'date' => mysql_to_rfc3339( $c->comment_date_gmt ),
'text' => wp_strip_all_tags( $c->comment_content ),
);
}
return rest_ensure_response( $items );
}
// 新しいコメント・返信を受け付ける
function wpdr_bt_create( WP_REST_Request $request ) {
$post = wpdr_bt_get_open_post( (int) $request['post'] );
if ( ! $post || ! comments_open( $post ) ) {
return new WP_Error( 'wpdr_bt_closed', 'この記事にはコメントできません。', array( 'status' => 403 ) );
}
$content = trim( sanitize_textarea_field( $request['content'] ) );
if ( '' === $content || mb_strlen( $content ) > 2000 ) {
return new WP_Error( 'wpdr_bt_length', '本文は1〜2000文字で入力してください。', array( 'status' => 400 ) );
}
$anchor = $request['anchor'];
$parent = (int) $request['parent'];
if ( $parent ) {
$pc = get_comment( $parent );
if ( ! $pc || (int) $pc->comment_post_ID !== $post->ID || WPDR_BT_TYPE !== $pc->comment_type ) {
return new WP_Error( 'wpdr_bt_parent', '返信先が正しくありません。', array( 'status' => 400 ) );
}
if ( $pc->comment_parent ) {
$parent = (int) $pc->comment_parent; // 返信への返信は、元のスレッドにまとめる
}
$anchor = get_comment_meta( $parent, WPDR_BT_META, true );
}
$user = wp_get_current_user();
$comment_id = wp_new_comment( wp_slash( array(
'comment_post_ID' => $post->ID,
'comment_content' => $content,
'comment_type' => WPDR_BT_TYPE,
'comment_parent' => $parent,
'user_id' => $user->ID,
'comment_author' => $user->display_name,
'comment_author_email' => $user->user_email,
'comment_author_url' => $user->user_url,
'comment_meta' => array( WPDR_BT_META => $anchor ),
) ), true );
if ( is_wp_error( $comment_id ) ) {
// 連続投稿・重複投稿など。エラーのデータにHTTPステータスが入っていないため付け直す
return new WP_Error(
$comment_id->get_error_code(),
$comment_id->get_error_message(),
array( 'status' => 400 )
);
}
return new WP_REST_Response( array(
'id' => (int) $comment_id,
'status' => wp_get_comment_status( $comment_id ),
), 201 );
}
// 記事下の通常のコメント一覧からは除外する(管理画面では表示)
add_action( 'pre_get_comments', function ( $query ) {
if ( is_admin() || in_array( WPDR_BT_TYPE, (array) $query->query_vars['type'], true ) ) {
return;
}
// 空文字を残すと種類が空の古いコメントまで除外されるため、array_filter で取り除く
$not_in = array_filter( (array) $query->query_vars['type__not_in'] );
$not_in[] = WPDR_BT_TYPE;
$query->query_vars['type__not_in'] = $not_in;
} );
なぜこう書くのか
**投稿の処理を wp_new_comment に任せる**: この関数を通すと、ディスカッション設定の承認ルール、連続投稿の制限、重複投稿の検出、コメント用のフックが通常のコメントと同じように働きます。コメントスパム対策のプラグインも、多くはこの流れにフックしているため効果が期待できます(対応の範囲はプラグインごとに確認してください)。値は wp_slash してから渡します。WordPressのコア自体も、REST APIからコメントを作るときに同じ形で渡しています。
返信先を検証する: 送られてきた parent が別の記事のコメントや通常のコメントを指していないかを確認し、返信のアンカーは親コメントのものを使います。クライアントから送られた値をそのまま信用しないためです。
**一覧の除外は pre_get_comments で行う**: クラシックテーマのコメント一覧とブロックテーマのコメントブロックのどちらも、内部ではコメントの検索クエリを使うため、ここで除外すれば両方に効きます。テーマが表示するコメント数には、この種類のコメントが含まれる場合があります。気になるときは get_comments_number フィルターで調整を検討してください。
ブラウザ側(JavaScript)の実装
次に、目印の付いたブロックの下に「議論を見る」ボタンとスレッドを表示します。子テーマに assets/js/block-threads.js を作り、読み込みの設定を functions.php に追加します。
add_action( 'wp_enqueue_scripts', function () {
$post_id = get_queried_object_id();
if ( ! is_singular( 'post' ) || ! comments_open( $post_id ) ) {
return;
}
wp_enqueue_script(
'wpdr-block-threads',
get_stylesheet_directory_uri() . '/assets/js/block-threads.js',
array(),
'1.0.0',
array( 'in_footer' => true, 'strategy' => 'defer' )
);
wp_add_inline_script( 'wpdr-block-threads', 'window.wpdrBlockThreads = ' . wp_json_encode( array(
'endpoint' => rest_url( 'wpdr/v1/block-threads/' . $post_id ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'loggedIn' => is_user_logged_in(),
'loginUrl' => wp_login_url( get_permalink( $post_id ) ),
) ) . ';', 'before' );
} );
(function () {
'use strict';
var cfg = window.wpdrBlockThreads;
var blocks = document.querySelectorAll('[data-wpdr-thread]');
if (!cfg || !blocks.length) { return; }
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) { node.className = className; }
if (text) { node.textContent = text; } // innerHTML は使わない
return node;
}
function renderItem(c) {
var li = el('li', 'wpdr-thread-item');
li.appendChild(el('p', 'wpdr-thread-meta', c.author + '(' + new Date(c.date).toLocaleString() + ')'));
li.appendChild(el('p', 'wpdr-thread-text', c.text));
return li;
}
function post(anchor, content, parent) {
return fetch(cfg.endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': cfg.nonce },
body: JSON.stringify({ anchor: anchor, content: content, parent: parent || 0 })
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) { throw new Error(data.message || '送信に失敗しました。'); }
return data;
});
});
}
function setup(block, comments) {
var anchor = block.dataset.wpdrThread;
var panel = el('div', 'wpdr-thread');
panel.id = 'wpdr-thread-' + anchor;
panel.hidden = true;
var toggle = el('button', 'wpdr-thread-toggle', 'この段落の議論(' + comments.length + '件)');
toggle.type = 'button';
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-controls', panel.id);
toggle.addEventListener('click', function () {
panel.hidden = !panel.hidden;
toggle.setAttribute('aria-expanded', String(!panel.hidden));
});
var list = el('ul', 'wpdr-thread-list');
comments.filter(function (c) { return !c.parent; }).forEach(function (top) {
var item = renderItem(top);
var replies = el('ul', 'wpdr-thread-replies');
comments.filter(function (c) { return c.parent === top.id; }).forEach(function (r) {
replies.appendChild(renderItem(r));
});
item.appendChild(replies);
list.appendChild(item);
});
panel.appendChild(list);
if (cfg.loggedIn) {
var form = el('form', 'wpdr-thread-form');
var label = el('label', '', 'この段落にコメントする');
var textarea = el('textarea');
textarea.required = true;
textarea.maxLength = 2000;
label.appendChild(textarea);
var submit = el('button', '', '送信');
submit.type = 'submit';
var status = el('p', 'wpdr-thread-status');
status.setAttribute('role', 'status');
form.append(label, submit, status);
form.addEventListener('submit', function (e) {
e.preventDefault();
submit.disabled = true;
post(anchor, textarea.value, 0).then(function (res) {
textarea.value = '';
status.textContent = res.status === 'approved'
? '投稿しました。再読み込みすると表示されます。'
: '投稿しました。承認後に表示されます。';
}).catch(function (err) {
status.textContent = err.message;
}).finally(function () {
submit.disabled = false;
});
});
panel.appendChild(form);
} else {
var login = el('a', '', 'ログインして議論に参加する');
login.href = cfg.loginUrl;
panel.appendChild(login);
}
block.after(toggle, panel);
}
fetch(cfg.endpoint, { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (items) {
var byAnchor = {};
items.forEach(function (c) {
(byAnchor[c.anchor] = byAnchor[c.anchor] || []).push(c);
});
blocks.forEach(function (block) {
setup(block, byAnchor[block.dataset.wpdrThread] || []);
});
});
})();
返信フォームは長くなるため省略していますが、各コメントに「返信」ボタンを付け、post(anchor, text, c.id) を呼べば同じAPIで返信できます。コメントの表示には textContent だけを使い、innerHTML に入れないことで、投稿内容に含まれたHTMLやスクリプトが実行されるのを防いでいます。
データの取得は記事ごとに1回だけ行い、ブロックごとに振り分けています。ブロックの数だけリクエストを送るとサーバーの負荷が増えるためです。
運用で気をつけること
- アンカーを変えるとスレッドが離れる: 既存のスレッドは元のアンカー名に結び付いています。公開後にアンカーを変えたり、ブロックを削除したりすると、そのスレッドはページに表示されなくなります(管理画面には残ります)。アンカーは公開前に決め、変えないルールにしておきます。
- 承認制とスパム対策: 新しい種類のコメントも、ディスカッション設定の承認ルールの対象になります。最初は「手動で承認」から始め、管理画面のコメント一覧でこの種類のコメントが表示・承認できるかを確認してください。
- ページキャッシュ: ログインユーザーはページキャッシュの対象外にしている構成が一般的ですが、そうでない場合はnonceの期限切れで投稿が403エラーになります。キャッシュ系プラグインの設定を確認します。
- 個人データ: コメントとして保存するので、投稿者名・メールアドレス・IPアドレスが記録されます。プライバシーポリシーのコメントに関する記述がこの機能にも当てはまるかを見直してください。WordPressの個人データのエクスポート・削除機能の対象にもなります。
まとめ
- 編集チーム内のやり取りなら、WordPress 6.9で追加されたコアの「メモ」機能で足りるかを先に確認します。
- 読者向けの段落ごとのスレッドは、コメント機能に載せて実装すると、承認・スパム対策・管理画面をそのまま使えます。
- ブロックの目印にはHTMLアンカーを使い、
WP_HTML_Tag_Processorで属性を付け、REST APIで取得・投稿します。 - 投稿は
wp_new_commentに任せ、入力の検証、nonce、ログイン確認、textContentでの表示で安全性を保ちます。 - 公開後はアンカーを変えない、承認制から始める、プライバシーポリシーを見直す、といった運用面も決めておきます。