WordPressプラグイン開発記 vol.3 – スラッグ変更時に重複チェックを行う

当ページのリンクには広告が含まれています。
WordPressのアイキャッチ

前回の記事でWordPressに作成したプラグインを認識させることができました。いよいよ、実際に機能を実装していきたいと思います🫡

ブロックエディターはReactベースなので、Reactをほぼ触ったことがない筆者には難易度高めな気がしてなりませんが、まずは小さな機能から始めてみようと思います。

目次

この記事でわかること

  • WordPressのブロックエディターでスラッグの重複チェックを行う方法。
  • PHP(REST API)とJavaScript(MutationObserver・wp.data・wp.apiFetch)を組み合わせた実装手順。
  • プラグインの共通クラス構成(Config・管理クラス)の作り方。

スラッグの重複チェックを行いたい

スラッグって変更した時点では重複しているかどうかわかりませんよね。全記事のスラッグ覚えろってかー、です。で、既に存在しているスラッグのまま記事を保存するとWordPressが *****-2 みたいにしれっと連番をつけて保存してくれます。警告やメッセージもなく、いつの間にか変更されてたスラッグが保存されます。確かに重複しないのですが、意図しないURLになってしまうのは非常に残念です……。

というわけで記事編集画面でスラッグを変更したときに重複チェックを行う機能を追加してみようと思います。

投稿編集画面の右側にある設定エリアでスラッグを変更できますが、ここが変わったタイミングで重複を検知できれば、そのまま保存してスラッグが変わってしまうことは防げそうです。

スラッグを変更するためのボタン。

ただしこのエリアのボタンは button タグなのでchangeイベントを検知することができません。そのため今回はスラッグを入力するポップアップのクローズボタンクリック時に確認する方針にしてみます。

スラッグ変更のポップアップの閉じるボタン

ファイル・ディレクトリ構成

今回の機能追加後のプラグインディレクトリ内の構成は下記の通りです。

wp-content/plugins/chun-log-customize/
  ├assets/
  │  ├css/
  │  │  └slug-check.css
  │  └js/
  │      └slug-check.js
  ├includes/
  │  └classes/
  │      ├ChunLogCustomize.php
  │      ├ChunLogCustomize_Config.php
  │      └ChunLogCustomize_SlugChecker.php
  └chun-log-customize.php

実装内容(共通部)

今回が初めての機能実装になるので、個別機能の実装の前に共通的なクラスを整えておきます。

プラグイン本体

追加していく機能を読み込んだりするクラスをロードするようにしています、のでプラグインヘッダー以外は今後あまり変更することはない!?と思います。

chun-log-customize.php
<?php
/*
 * Plugin Name:       Chun Log Customize
 * Plugin URI:        http://localhost/chun-log-customize/
 * Description:       chun-log用にWordPressに機能を追加するプラグインです。
 * Version:           0.0.1
 * Author:            chun-log
 * Author URI:        https://chun-log.jp/
 */
namespace ChunLogCustomize;

use ChunLogCustomize\Classes\ChunLogCustomize_Config;
use ChunLogCustomize\Classes\ChunLogCustomize;

if (!defined('ABSPATH')) {
    exit;
}

require_once plugin_dir_path(__FILE__) . 'includes/classes/ChunLogCustomize_Config.php';
require_once plugin_dir_path(__FILE__) . 'includes/classes/ChunLogCustomize.php';

add_action('init', [ChunLogCustomize::class, 'get_instance']);

プラグインの構成情報を管理するクラス

プラグイン全体でよく使う情報(バージョン・ディレクトリパス・URL)をまとめたクラスです。各機能クラスからここを参照する形にしています。

ChunLogCustomize_Config.php
<?php
namespace ChunLogCustomize\Classes;

if (!defined('ABSPATH')) {
    exit;
}

/**
 * プラグインの構成情報を管理するクラス。
 */
class ChunLogCustomize_Config {

    /** プラグインのバージョン */
    private static string $plugin_version = '0.0.1';
    /** プラグインディレクトリーの物理パス */
    private static ?string $plugin_dir_path = null;
    /** プラグインディレクトリーの公開URL */
    private static ?string $plugin_dir_url = null;

    /**
     * プラグインの現在のバージョンを取得する。
     *
     * @return string バージョン文字列
     */
    public static function getPluginVersion(): string {
        return self::$plugin_version;
    }

    /**
     * プラグインディレクトリーの絶対パス(サーバー上のフルパス)を取得する。
     * 取得例: /var/www/html/wp-content/plugins/chun-log-customize/
     * ※末尾にスラッシュが含まれます。
     *
     * @return string プラグインのベースディレクトリーパス
     */
    public static function getPluginDirPath(): string {
        return self::$plugin_dir_path ??= plugin_dir_path(dirname(__DIR__));
    }

    /**
     * プラグインディレクトリーのURL(ブラウザでアクセス可能な形式)を取得する。
     * 取得例: https://localhost/wp-content/plugins/chun-log-customize/
     * ※末尾にスラッシュが含まれます。
     *
     * @return string プラグインのベースディレクトリーURL
     */
    public static function getPluginDirUrl(): string {
        return self::$plugin_dir_url ??= plugin_dir_url(dirname(__DIR__));
    }

}

サーバー側のクラスを一括管理するクラス

各機能クラスのインスタンスをここで生成・保持して「どのクラスを初期化するか」を1ファイルに集約しています。また、誤って複数回インスタンスを生成してしまうのを防ぐためにシングルトンパターンで実装しています。

ChunLogCustomize.php
<?php
namespace ChunLogCustomize\Classes;

use ChunLogCustomize\Classes\ChunLogCustomize_SlugChecker;

if (!defined('ABSPATH')) {
    exit;
}

require_once ChunLogCustomize_Config::getPluginDirPath() . 'includes/classes/ChunLogCustomize_SlugChecker.php';

/**
 * プラグイン機能管理クラス。
 */
class ChunLogCustomize {

    private static ?self $instance = null;
    private ChunLogCustomize_SlugChecker $slug_checker;

    private function __construct() {
        $this->slug_checker = new ChunLogCustomize_SlugChecker();
    }

    public static function get_instance(): self {
        return self::$instance ??= new self();
    }

}

実装内容(スラッグ重複チェック機能)

スラッグ重複チェックを行うクラス

重複チェックのREST APIエンドポイント登録・権限チェック・JS/CSSの読み込みをこのクラスにまとめています。このクラスをロードするとスラッグ重複チェックの一式が準備されるようなイメージですね。

ChunLogCustomize_SlugChecker.php
<?php
namespace ChunLogCustomize\Classes;

use WP_Query;

if (!defined('ABSPATH')) {
    exit;
}

/**
 * 投稿スラッグの重複チェックを行う。
 */
class ChunLogCustomize_SlugChecker {

    /**
     * コンストラクタ。
     */
    public function __construct() {

        // カスタムREST APIエンドポイントを登録する。
        add_action('rest_api_init', [$this, 'register_routes']);

        // ブロックエディターの起動時にのみ読み込む。
        add_action('enqueue_block_editor_assets', [$this, 'enqueue_editor_assets']);

    }

    /**
     * スラッグ重複チェックのREST APIを登録する。
     * エンドポイント: [サイトURL]/wp-json/custom/v1/check-slug
     */
    public function register_routes(): void {

        register_rest_route('custom/v1', '/check-slug', [
            'methods'             => 'GET',
            'callback'            => [$this, 'handle_check_slug'],
            'permission_callback' => [$this, 'check_edit_permission'],
            'args'                => [
                'slug' => [
                    'required'          => true,
                    'sanitize_callback' => 'sanitize_title',
                ],
                'post_id' => [
                    'required'          => true,
                    'sanitize_callback' => 'absint',
                ],
            ],
        ]);

    }

    /**
     * assets内のcss、jsを読み込む。
     */
    public function enqueue_editor_assets(): void {

        wp_enqueue_style(
            'custom-slug-check-style',
            ChunLogCustomize_Config::getPluginDirUrl() . '/assets/css/slug-check.css',
            [],
            ChunLogCustomize_Config::getPluginVersion()
        );

        wp_enqueue_script(
            'custom-slug-check',
            ChunLogCustomize_Config::getPluginDirUrl() . '/assets/js/slug-check.js',
            ['wp-data', 'wp-editor', 'wp-notices', 'wp-api-fetch'],
            ChunLogCustomize_Config::getPluginVersion(),
            true
        );

    }

    /**
     * スラッグの重複を判定するコールバックメソッド。
     *
     * @param \WP_REST_Request $request APIリクエストデータ
     * @return \WP_REST_Response レスポンスボディ: { exists: bool } true=重複あり
     */
    public function handle_check_slug(\WP_REST_Request $request): \WP_REST_Response {

        // スラッグはサイト全体でユニークにする仕様のため、投稿タイプを横断して検索する。
        // 編集中の投稿はチェック対象から除外し、ゴミ箱にある投稿は重複扱いしない。
        $query = new WP_Query([
            'name'           => $request['slug'],
            'post_type'      => 'any',
            'post_status'    => ['publish', 'pending', 'draft', 'private', 'future'],
            'post__not_in'   => [$request['post_id']],
            'posts_per_page' => 1,
        ]);

        return new \WP_REST_Response(['exists' => $query->have_posts()], 200);

    }

    /**
     * ユーザー情報、リクエスト情報からAPIの権限チェックを行う。
     *
     * @param \WP_REST_Request $request APIリクエストデータ
     * @return bool true=使用OK
     */
    public function check_edit_permission(\WP_REST_Request $request):bool {

        return current_user_can('edit_posts') && (bool)wp_verify_nonce($request->get_header('X-WP-Nonce'), 'wp_rest');

    }

}

WP_Queryで発行されるSQLは下記の通りです。post_type => 'any'と指定したのにSQLではIN ('post', 'page', 'attachment')になっていますが、これはWordPressが内部的に登録されている投稿タイプを展開した結果です。カスタム投稿タイプを追加している場合はそれも含まれるので意図通りの動作です。

発行されたSQL
SELECT
    wp_posts.*
FROM
    wp_posts
WHERE
    1=1
    AND wp_posts.post_name = '【入力したスラッグ】'
    AND wp_posts.ID NOT IN (【編集中の投稿ID】)
    AND wp_posts.post_type IN ('post', 'page', 'attachment')
    AND ((
        wp_posts.post_status = 'publish'
        OR wp_posts.post_status = 'future'
        OR wp_posts.post_status = 'draft'
        OR wp_posts.post_status = 'pending'
        OR wp_posts.post_status = 'private'
    ))
ORDER BY
    wp_posts.post_date DESC

スタイルシート

エラー発生時にスラッグ設定行の背景を赤くするスタイルです。WordPressのエラー通知と同じ背景色を設定しています。

slug-check.css
slug-check.css
.chun-log-has-error {
    background-color: #f4a2a2;
}

エディター画面のJavaScript

3つのポイントを補足します。

  • MutationObserverでポップアップの出現を監視する
    スラッグ編集ポップアップはブロックエディターが動的に生成するため、ページロード時点ではDOMに存在しません。document.bodyを監視してポップアップが出現したタイミングでクローズボタンにイベントを登録しています。
  • dataset.boundで二重登録を防止する
    MutationObserverは細かいDOM変化でも発火するため、同じボタンに何度もイベントリスナーが登録されないようフラグを立てています。
  • スラッグの取得にはwp.dataを使う
    DOMから直接値を取得するのではなくselect('core/editor').getEditedPostAttribute('slug')でエディターのストアから取得することで、WordPressが処理した後の正規化済みスラッグを確認できます。
slug-check.js
(function() {

    const { dispatch, select } = wp.data;

    /**
     * スラッグの重複チェックを行い、結果に応じて通知とスタイルを更新する
     * 
     * @param {string} newSlug - チェック対象のスラッグ
     * @param {number} postId  - 現在の投稿ID
     */
    const checkSlug = async (newSlug, postId) => {

        try {
            // APIにスラッグの重複チェックをリクエストする。
            const data = await wp.apiFetch({ 
                path: `custom/v1/check-slug?slug=${ encodeURIComponent(newSlug) }&post_id=${ postId }`
            });

            // スラッグ入力欄の親行要素を取得する。
            const slugInputRow = document.querySelector('button.editor-post-url__panel-toggle')?.closest('.editor-post-panel__row');

            if (data.exists) {
                // 重複あり:エラー通知を表示、入力欄にエラースタイルを付与する。
                dispatch('core/notices').createErrorNotice(
                    `スラッグ「${newSlug}」は既に使用されています。`,
                    { id: 'slug-duplicate-warning', isDismissible: true }
                );
                slugInputRow?.classList.add('chun-log-has-error');
            } else {
                // 重複なし:エラー通知を削除、入力欄のエラースタイルを解除する。
                dispatch('core/notices').removeNotice('slug-duplicate-warning');
                slugInputRow?.classList.remove('chun-log-has-error');
            }
        } catch (error) {
            console.error('Slug Checker: API Error', error);
        }

    };

    /**
     * MutationObserverでエディター上のスラッグ確認ボタンの出現を監視する。
     * ボタンがDOMに追加されたタイミングでクリックイベントを登録する。
     */
    new MutationObserver(() => {

        // ポップオーバー内のスラッグ確認ボタンを取得する。
        const btnSlug = document.querySelector('.components-popover__fallback-container .editor-post-url button');

        // data-boundフラグで二重登録を防止する。
        if (btnSlug && !btnSlug.dataset.bound) {
            btnSlug.dataset.bound = 'true';
            btnSlug.addEventListener('click', () => {
                // クリック時点の最新スラッグと投稿IDを取得してチェックを実行する。
                checkSlug(
                    select('core/editor').getEditedPostAttribute('slug'),
                    select('core/editor').getCurrentPostId()
                );
            });
        }

    }).observe(document.body, { childList: true, subtree: true });

})();

動作確認

実装が完了したので動作を確認してみます。既存の記事と同じスラッグを入力してポップアップを閉じると……画面上部にエラー通知が表示され、スラッグの入力行が赤くハイライトされました🎉

スラッグ重複エラーの状態

重複していないスラッグに変更するとエラーが消えることも確認できました。

Q&A

ゴミ箱に入れた投稿のスラッグは重複扱いになる?

なりません。post_statustrashを含めていないため、ゴミ箱の投稿はチェック対象から除外されます。一度ゴミ箱に移した後で同じスラッグを再利用したい場合も問題なく使えます。

カスタム投稿タイプも重複チェックの対象にな

なります。post_type => 'any'を指定しているためWordPressに登録されている全投稿タイプが対象になります。発行されるSQLでIN ('post', 'page', 'attachment', ...)のように展開されるので、カスタム投稿タイプのスラッグとの重複も検知できます。

REST APIにnonce検証は必要ですか?

必須です。wp.apiFetchが自動でX-WP-Nonceヘッダーを付与してくれますが、サーバー側でもwp_verify_nonce()を使って必ずチェックするようにしましょう。認証なしでエンドポイントを公開するのはセキュリティ上のリスクになります。

おわりに

ブロックエディターのDOM構造はReactで動的に生成されるためReactをほぼ知らない筆者にはとっつきにくい部分もありましたがwp.dataMutationObserverを使うことでなんとか動くものができました。

PHPは5の時代に触っていましたが今回PHP 8を使ってみると??=演算子や型宣言まわりでだいぶ変わったなぁという感じでした。次回はまた別の機能を追加していきます。


次の記事

To Be Continue…

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次