Wordpress Gutenbergエディタで記事タイトルをバリデーションする方法

標準のJavascriptで実装できます。
React.jsの環境構築やBabel.jsなどの用意も不要です。

## Wordpressデータパッケージ(`@wordpress/data`)の基本

以下の記事を読むと、ほぼすべてわかります

https://bdwm.be/gutenberg-how-to-prevent-post-from-being-saved/

### 送信ボタンをロックする方法

記事編集画面で開発者ツールを開き、コンソール画面で以下のスクリプトを入れれば動作確認できます

```javascript
wp.data.dispatch('core/editor').lockPostSaving('my-lock');
```

### 送信ボタンのロックを解除する方法

```javascript
wp.data.dispatch('core/editor').unlockPostSaving('my-lock');
```

### エラーメッセージを表示する方法

```javascript
wp.data.dispatch('core/notices').createNotice(
  'error',
  'Please enter a title',
  { id: 'title-lock', isDismissible: false }
);
```

### エラーメッセージを非表示にする方法

```javascript
wp.data.dispatch('core/notices').removeNotice('title-lock');
```

## 具体例

以下に具体例として、記事タイトルに西暦だけ入れられるようにするバリデーションを掲載します。

### 仕様

* 特定の投稿タイプの時だけバリデーションを実行する
* 記事タイトルが西暦2000年～2200年の時だけ、送信可能にする
* 文字数が0～3文字、5文字以上の時は、送信不可能にする

### 下準備

まずは、jsファイルを然るべき場所に配置し、 `functions.php` なり何なりで、読み込むようにします。

```php:/wp-content/themes/your-theme/functions.php
function my_enqueue_scripts() {
    wp_enqueue_script(
        'my-title-validation',
        '/wp-content/themes/your-theme/js/title-validation.js',
        array('wp-edit-post', 'wp-data', 'wp-plugins', 'wp-element', 'wp-components'),
        '1.0.0',
        true
    );
}
add_action('admin_enqueue_scripts', 'my_enqueue_scripts');
```

以下のような形で実装します

```javascript:/wp-content/themes/your-theme/js/title-validation.js
wp.domReady(() => {
  let prevTitle = null;
  // バリデーション実行対象の投稿タイプを指定
  const allowedPostTypes = ["events", "sports"];

  // 入力状態を監視する(ページ読込直後はnull または undefined がしばらく続く)
  wp.data.subscribe(() => {
    const postType = wp.data.select("core/editor").getCurrentPostType();
    //console.log("postType:", postType);
    const isAllowedPostType = allowedPostTypes.includes(postType);

    // 指定した投稿タイプの場合のみバリデーションを実行
    if (!isAllowedPostType) return;

    const postTitle = wp.data
      .select("core/editor")
      .getEditedPostAttribute("title");

    // タイトルに変化がない場合は処理中止(Maximum call stack size exceededエラーを回避するため)
    if (postTitle === prevTitle) {
      return;
    }

    // 前回のタイトルと異なる場合のみバリデーションを実行
    if (postTitle !== prevTitle) {
      prevTitle = postTitle;
    }

    // バリデーションを実行
    const handleTitleValidation = () => {
      //console.log("postTitle:", postTitle);
      const errorMessage = validateTitle(postTitle);
      //console.log("errorMessage:", errorMessage);

      // 初期化
      wp.data.dispatch("core/notices").removeNotice("title-lock");
      wp.data.dispatch("core/editor").unlockPostSaving("title-lock");

      // エラーメッセージがある場合
      if (errorMessage) {
        // エラーメッセージを表示
        wp.data
          .dispatch("core/notices")
          .createNotice("error", validationMessage(errorMessage), {
            id: "title-lock",
            isDismissible: true,
          });
        // 送信ボタンをロックする
        wp.data.dispatch("core/editor").lockPostSaving("title-lock");
      }
    };

    handleTitleValidation();
  });

  /**
   * タイトルのバリデーションを行う
   * @param {string} title タイトル
   * @returns {string|null} エラーメッセージ
   */
  function validateTitle(title) {
    // 空文字チェックを最初に行う
    if (isEmpty(title)) {
      return "記事タイトルを入力してください。";
    }
    // 数字のみかチェック
    if (!isNumeric(title)) {
      return "記事タイトルは数字のみで入力してください。";
    }
    // 文字数チェック
    if (!isValidLength(title)) {
      return "記事タイトルは西暦4桁で入力してください。";
    }
    // 西暦の範囲チェック
    const minYear = 2000;
    const maxYear = 2200;
    if (!isValidEra(title, minYear, maxYear)) {
      return `記事タイトルは西暦${minYear}年から${maxYear}年の間で入力してください。`;
    }
    return null;
  }

  /**
   * タイトルが空かどうかをチェックする
   * @param {string} title タイトル
   * @returns {boolean} タイトルが空かどうか
   */
  function isEmpty(title) {
    return !title || title.trim() === "";
  }

  /**
   * タイトルが数字のみかどうかをチェックする
   * @param {string} title タイトル
   * @returns {boolean} タイトルが数字のみかどうか
   */
  function isNumeric(title) {
    return /^\d+$/.test(title);
  }

  /**
   * タイトルの長さが4桁かどうかをチェックする
   * @param {string} title タイトル
   * @returns {boolean} タイトルの長さが4桁かどうか
   */
  function isValidLength(title) {
    return title.length === 4;
  }

  /**
   * タイトルが有効な西暦の範囲内かどうかをチェックする
   * @param {string} title タイトル
   * @param {number} minYear 最小西暦
   * @param {number} maxYear 最大西暦
   * @returns {boolean} タイトルが有効な西暦の範囲内かどうか
   */
  function isValidEra(title, minYear, maxYear) {
    const year = parseInt(title, 10);
    return year >= minYear && year <= maxYear;
  }

  /**
   * エラーメッセージを返す
   * @param {string} message エラーメッセージ
   * @returns {string} エラーメッセージ
   */
  function validationMessage(message) {
    return message;
  }
});
```