WordPress Logo WordPress ダイナミックブロックの作成

Create Block ツールで雛形を作成して、フロントエンド側のレンダリングは render.php を使用し、「最新の投稿」ブロックのようなダイナミックブロックを作成します。

グリッドレイアウトでカラム表示する方法や QueryControls コンポーネントや ImageSizeControl コンポーネントの使い方などについても掲載しています。

この時点での WordPress のバージョンは 6.7.2 です。

更新日:2025年02月26日

作成日:2025年2月14日

以下ではすでにローカル環境と Node(NPM)がインストールされていることを前提にしています。

また、Create Block ツールの基本的な使い方やカスタムブロックの基本的な作成方法についての説明はありませんので、よろしければ以下のページを御覧ください。

関連ページ:カスタム投稿タイプを表示するダイナミックブロックの作成

ダイナミックブロックの作成

以下は投稿データにアクセスして、投稿記事一覧を表示するダイナミックブロックを作成する例です。

取得する投稿の件数やカテゴリー、表示するコンテンツをインスペクターパネルで選択できるようにします。機能的には「最新の投稿」ブロックに近いもので以下のようなブロックが表示されます。

投稿記事を取得してそれらのデータを表示するので、準備として数件の投稿記事を作成しておきます。

Create Block ツールでひな型を作成

Create Block ツールの create-block コマンドを使って、ブロックのひな型と開発環境を構築します。

create-block コマンドの書式は npx @wordpress/create-block@latest [options] [slug] です。

ターミナルでプラグインディレクトリ(wp-content/plugins)に移動して以下を実行します。

% npx @wordpress/create-block@latest --variant=dynamic --namespace wdl-block my-dynamic-block

--variant=dynamic オプションを指定してダイナミックブロックのひな型を作成しています

この例では --namespace オプション(ブロック名の内部名前空間)に wdl-block、slug(ひな形ファイルの出力先)に my-dynamic-block を指定していますが、必要に応じて適宜変更します。

または、slug を指定せずに対話モードでコマンドを実行して The template variant to use for this block で dynamic を選択し、slug や namespace、などを適宜指定することもできます。

 % npx @wordpress/create-block@latest

Let's customize your WordPress plugin with blocks:
? The template variant to use for this block:
  static  // デフォルトは static なので dynamic を選択します
❯ dynamic
・・・以下省略・・・

上記を実行すると、slug に指定した名前のディレクトリ my-dynamic-block が作成されます。

開発では src ディレクトリ内のファイルとプラグインファイル(my-dynamic-block.php)を使用します。

この例では --variant=dynamic オプションを指定したので src/my-dynamic-block フォルダ内に render.php ファイルが作成されています(save.js は生成されません)。

ダイナミックブロックを作成する方法

ダイナミックブロックを作成するには以下の 2 つの方法があります。

  1. register_block_type() 関数で render_callback に、ブロックの表示内容を生成するコールバック関数を指定する(従来の方法)
  2. block.json に render プロパティを追加し、ブロックの表示内容を出力する PHP ファイルを指定する(WordPress v6.1 以降でサポート)

create-block コマンドに --variant=dynamic を指定して実行した場合、2番目の方法が使われます。

以下が生成された block.json です。render プロパティにブロックの表示内容を出力する PHP ファイル render.php が指定されています(18行目)。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl-block/my-dynamic-block",
  "version": "0.1.0",
  "title": "My Dynamic Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false
  },
  "textdomain": "my-dynamic-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScript": "file:./view.js"
}

npm start 開発を開始

ターミナルで作成したブロックのディレクトリ(my-dynamic-block)に移動して npm start を実行し開発を開始します。これにより、src ディレクトリ内のファイルを変更して保存すると自動的にビルドが実行され、待機してファイルの変更を監視します。

% cd my-dynamic-block
% npm start

開発を停止(npm start コマンドを終了)するには control + c を押します。再開するには再度 npm start を実行します。

プラグインを有効化

管理画面のプラグインページで作成したブロックのプラグイン(My Dynamic Block)を有効化します。

新規に投稿を作成し、作成したブロックを挿入すると以下のように表示されます。

エディター側のブロックの表示は以下の src/my-dynamic-bloc/ の edit.js によるもので、単に静的なテキストを表示しているだけです(オリジナルのコメント部分は削除してあります)。

import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import './editor.scss';

export default function Edit() {
  return (
    <p { ...useBlockProps() }>
      { __(
        'My Dynamic Block – hello from the editor!',
        'my-dynamic-block'
      ) }
    </p>
  );
}

フロントエンド側は以下のように表示されます。

フロントエンド側の表示は以下の src/my-dynamic-bloc/ の render.php によるものです。

<p <?php echo get_block_wrapper_attributes(); ?>>
  <?php esc_html_e( 'My Dynamic Block – hello from a dynamic block!', 'my-dynamic-block' ); ?>
</p>

※ 以降は npm start を実行して開発モードで作業をしている前提です。

また、以降では edit.js は src ディレクトリの src/my-dynamic-bloc/edit.js を指します。

エディターに投稿を表示

投稿一覧を取得してエディターに側に表示するには、edit.js を編集します。

まず、@wordpress/data から useSelect フックをインポートします。

そして、Edit() 関数(コンポーネント)で、useSelect フックのコールバック内で select 関数を使って core ストアにアクセスして getEntityRecords セレクターで投稿データを取得します。

関連ページ:ダイナミックブロックでのデータへのアクセス

edit.js を以下のように書き換えます。

select 関数にデータを取得するストアの名前空間(ストア名)を指定すると、そのストアのセレクターが返されるので、getEntityRecords セレクターを使って投稿のデータにアクセスできます。

getEntityRecords にはデータの種類(postType)とその名前(post)、及び抽出条件 { per_page: 5 } を指定して、投稿のデータを5件取得しています。

getEntityRecords は REST API を介して非同期にデータを取得するので、初回呼び出し時には null を返しますが、useSelect フックを使っているので、状態の変化を自動的に検知し、データが変更されるとコンポーネントが再レンダリングされ、安全にデータを取得できます。

import { __ } from "@wordpress/i18n";
import { useBlockProps } from "@wordpress/block-editor";
// @wordpress/data から useSelect をインポート
import { useSelect } from "@wordpress/data";
import "./editor.scss";

export default function Edit() {
  // core ストアから投稿データを取得
  const posts = useSelect(
    (select) =>
      // select 関数で core ストアにアクセスして getEntityRecords セレクターで投稿データを5件取得
      select("core").getEntityRecords("postType", "post", { per_page: 5 }),
    [],
  );

  console.log(posts);

  return (
    <p {...useBlockProps()}>
      {__("My Dynamic Block – hello from the editor!", "my-dynamic-block")}
    </p>
  );
}

投稿の編集画面で開発ツールのコンソールを確認すると、以下のように取得した5件の投稿データ(オブジェクトの配列)がコンソールに出力されます。※ 投稿に作成したブロックが挿入されている必要があります。

getEntityRecords が Rest API にリクエストを送信すると、リクエストが完了するまでレスポンスは null になるので、最初に null が出力されています。

左端のアイコン ▶ をクリックして展開すると詳細を確認できます。

以下は最初(インデックスが0)の投稿データを展開しています。コンテンツは content の rendered プロパティに、抜粋は excerpt の rendered プロパティに格納されているのが確認できます。

また、日付(date_gmt)やリンク(link)など投稿に関する様々なプロパティが確認できます。

以下は取得した投稿データ(posts)を使って各投稿のタイトルと抜粋をレンダリングする例です。

取得した投稿データ(posts)はオブジェクト(エンティティレコード)の配列になっているので、JavaScript の Array.prototype.map() メソッドを使います。

まず、map() を呼び出す前に posts && で投稿データが取得できていること(posts が null でないこと)を確認します(条件付きレンダリング)。

map() のコールバックでは、配列の各要素(post)からタイトル( post.title.rendered )とリンク(post.link)及び抜粋(post.excerpt.rendered)を取得してマークアップを作成して div 要素のコンテンツとして返しています。

その際、配列の各要素(この場合は div 要素)には key 属性を設定します。key の値には一意となる post.id を指定します。

また、抜粋(post.excerpt.rendered)は、HTML が含まれているため dangerouslySetInnerHTML を使用して実際の HTML としてレンダリングするようにしています(HTML としてレンダリング)。

import { __ } from "@wordpress/i18n";
import { useBlockProps } from "@wordpress/block-editor";
import { useSelect } from "@wordpress/data";
import "./editor.scss";

export default function Edit() {
  const posts = useSelect(
    (select) =>
      select("core").getEntityRecords("postType", "post", { per_page: 5 }),
    [],
  );

  // 取得した投稿データ(posts)を map() でループして各投稿のタイトルと抜粋をレンダリング
  return (
    <div {...useBlockProps()}>
      {posts &&
        posts.map((post) => (
          <div key={post.id}>
            <h3>
              <a href={ post.link }>{post.title.rendered}</a>
            </h3>
            <div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />
          </div>
        ))}
    </div>
  );
}

エディターで挿入したブロックを確認すると、例えば以下のように表示されます。

クラス属性を追加

スタイルなどを適用しやすいようにコンテンツの要素にクラス属性を追加し、各投稿の出力全体をラップする div 要素を追加します。

この例の場合、自動的にブロックに付与されるクラス名は editor.scss や style.scss で使われている .wp-block-wdl-block-my-dynamic-block なので、このクラス名を使ってクラス属性を指定します。

return ステートメントは以下のようになります。JSX ではクラス属性は className になります。

// クラス属性を追加
return (
  <div {...useBlockProps()}>
    <div className="wp-block-wdl-block-my-dynamic-block__post-items">
      {posts &&
        posts.map((post) => (
          <div key={post.id} className="wp-block-wdl-block-my-dynamic-block__post-item">
            <h3 className="wp-block-wdl-block-my-dynamic-block__post-title">
              <a href={post.link}>{post.title.rendered}</a>
            </h3>
            <div className="wp-block-wdl-block-my-dynamic-block__post-excerpt"
              dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}
            />
          </div>
        ))}
    </div>
  </div>
);

クラス名が長いので、以下のようにクラス名の共通部分を変数 pre に定義して書き換えます。

  • className="wp-block-wdl-block-my-dynamic-block__post-items"
  • className={`${pre}post-items`}
import { __ } from "@wordpress/i18n";
import { useBlockProps } from "@wordpress/block-editor";
import { useSelect } from "@wordpress/data";
import "./editor.scss";

export default function Edit() {
  const posts = useSelect(
    (select) =>
      select("core").getEntityRecords("postType", "post", { per_page: 5 }),
    [],
  );

  // クラス名の共通部分
  const pre = 'wp-block-wdl-block-my-dynamic-block__';

  return (
    <div {...useBlockProps()}>
      <div className={`${pre}post-items`}>
        {posts &&
          posts.map((post) => (
            <div key={post.id} className={`${pre}post-item`}>
              <h3 className={`${pre}post-title`}>
                <a href={post.link}>{post.title.rendered}</a>
              </h3>
              <div className={`${pre}post-excerpt`}
                dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}
              />
            </div>
          ))}
      </div>
    </div>
  );
}

タイトルと抜粋のテキストをレンダリング

前述の例では、抜粋(post.excerpt.rendered)を dangerouslySetInnerHTML を使用して HTML としてレンダリングするようにしていますが、以下はテキストとして取得して p 要素でレンダリングする例です。

タイトルもコードエディターを使えば HTML を記述できるので、タイトルもテキストとして取得して h3 要素でレンダリングするように変更しています。

getTextContent() は独自に定義した textContent または innerText を使って HTML からテキストを取得する関数です。

また、post.excerpt が undefined の可能性があるので、念の為、post.excerpt?.rendered とオプショナルチェイニングを使って、post.excerpt が undefined でもエラーを回避できるようにしています。post.title も同様です。

import { __ } from "@wordpress/i18n";
import { useBlockProps } from "@wordpress/block-editor";
import { useSelect } from "@wordpress/data";
import "./editor.scss";

export default function Edit() {
  const posts = useSelect(
    (select) =>
      select("core").getEntityRecords("postType", "post", { per_page: 5 }),
    [],
  );

  const pre = 'wp-block-wdl-block-my-dynamic-block__';

  // HTML からテキストを取得する関数を定義
  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  return (
    <div {...useBlockProps()}>
      <div className={`${pre}post-items`}>
        {posts &&
          posts.map((post) => (
            <div key={post.id} className={`${pre}post-item`}>
              {post.title?.rendered && (
                <h3 className={`${pre}post-title`}>
                  <a href={post.link}>{getTextContent(post.title.rendered)}</a>
                </h3>
              )}
              {post.excerpt?.rendered && (
                <p className={`${pre}post-excerpt`}>
                  {getTextContent(post.excerpt.rendered)}
                </p>
              )}
            </div>
          ))}
      </div>
    </div>
  );
}

アイキャッチ画像

アイキャッチ画像のデータも取得するには、getEntityRecords の query パラメーター_embed: true を指定します(または _embed: 'wp:featuredmedia' を指定します)。

これにより、アイキャッチ画像を表示するために必要な画像の詳細を提供する _embedded プロパティを含む投稿オブジェクトが提供されます。

アイキャッチ画像が設定されている投稿の _embedded プロパティを確認すると以下のうような構造になっていて、wp:featuredmedia の配列の 0 に画像の情報が格納されています。

上記の場合、_embed: true を指定しているので、_embedded プロパティには wp:featuredmedia 以外にも author や wp:term も取得されています。

以下はアイキャッチ画像のデータも取得して表示する例です。

この例では getEntityRecords の query パラメーターに _embed: true を追加して、投稿データにアイキャッチ画像の情報の _embedded プロパティを含めるようにしています。

アイキャッチ画像のレンダリングでは、33-35 行目で post._embedded["wp:featuredmedia"][0] が存在する場合にのみ、そのプロパティにアクセスして画像の src や alt 属性を設定しています(この確認がないと、アイキャッチ画像がない投稿のデータでエラーになります)。

以下では alt_text が空であれば、投稿タイトルを代替テキスト(alt 属性)に設定しています。

query パラメーターの exclude には select("core/editor").getCurrentPostId() で現在の投稿の ID を指定して、取得するデータから除外しています。

img 要素にはタイトル(h3)同様、a 要素でその投稿へのリンク(post.link)を設定しています。

import { __ } from "@wordpress/i18n";
import { useBlockProps } from "@wordpress/block-editor";
import { useSelect } from "@wordpress/data";
import "./editor.scss";

export default function Edit() {
  const posts = useSelect(
    (select) =>
      select("core").getEntityRecords("postType", "post", {
        per_page: 5,
        _embed: true, // データに _embedded プロパティ(埋め込みデータ)を含める
        exclude: [select("core/editor").getCurrentPostId()], // 現在の投稿を除外
      }),
    [],
  );

  const pre = 'wp-block-wdl-block-my-dynamic-block__';

  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // アイキャッチ画像とリンクを追加(29-38)
  return (
    <div {...useBlockProps()}>
      <div className={`${pre}post-items`}>
        {posts &&
          posts.map((post) => (
            <div key={post.id} className={`${pre}post-item`}>
              {post._embedded &&
                post._embedded["wp:featuredmedia"] &&
                post._embedded["wp:featuredmedia"][0] && (
                  <div className={`${pre}featured-image`}>
                    <a href={ post.link }>
                      <img
                        src={ post._embedded['wp:featuredmedia'][0].media_details.sizes.medium.source_url }
                        alt={ post._embedded['wp:featuredmedia'][0].alt_text || post.title.rendered ? getTextContent(post.title.rendered): ''}
                      />
                    </a>
                  </div>
                )}
              {post.title?.rendered && (
                <h3 className={`${pre}post-title`}>
                  <a href={post.link}>{getTextContent(post.title.rendered)}</a>
                </h3>
              )}
              {post.excerpt?.rendered && (
                <p className={`${pre}post-excerpt`}>
                  {getTextContent(post.excerpt.rendered)}
                </p>
              )}
            </div>
          ))}
      </div>
    </div>
  );
}
オプショナルチェイニングの使用

上記の 33-35 行目の以下のコードでも、殆どの場合は問題ありませんが、例えば、medium サイズの画像を生成しない設定がしてある場合など _embedded のデータ構造が保証されていない場合、エラーになる可能性があります。

post._embedded &&
  post._embedded["wp:featuredmedia"] &&
  post._embedded["wp:featuredmedia"][0] && 

以下のようにオプショナルチェイニング ?. を使用することで、undefined のプロパティにアクセスしようとした際のエラーを防ぐことができ、_embedded のデータをより安全に参照できます。

?. はデータが存在しない場合に undefined を返すので、安全にネストされたデータにアクセスでき、post._embedded のようなデータが null や undefined でも、エラーが発生せず処理を続行できます。

// アイキャッチ画像の medium サイズの URL を取得
const featuredMedia =
  post._embedded?.["wp:featuredmedia"]?.[0]?.media_details?.sizes?.medium?.source_url;

// 代替テキストを取得
const altText = post._embedded?.["wp:featuredmedia"]?.[0]?.alt_text || getTextContent(post.title?.rendered);

上記の altText では、

  • post._embedded?.["wp:featuredmedia"]?.[0]?.alt_text でアイキャッチ画像の alt_text があれば、それを使用
  • alt_text がない場合は、getTextContent(post.title?.rendered) で 投稿タイトルを使用
  • 投稿タイトルが undefined の場合は、getTextContent() は空文字列を返します。

前述のコードは以下のように書き換えることができます。

post._embedded やそれ以降のプロパティが undefined の場合は、featuredMedia は undefined になり、アイキャッチ画像は表示されませんがエラーにはなりません。

import { __ } from "@wordpress/i18n";
import { useBlockProps } from "@wordpress/block-editor";
import { useSelect } from "@wordpress/data";
import "./editor.scss";

export default function Edit() {
  const posts = useSelect(
    (select) =>
      select("core").getEntityRecords("postType", "post", {
        per_page: 5,
        _embed: true,
        exclude: [select("core/editor").getCurrentPostId()],
      }),
    [],
  );

  const pre = 'wp-block-wdl-block-my-dynamic-block__';

  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  return (
    <div {...useBlockProps()}>
      <div className={`${pre}post-items`}>
        {posts &&
          posts.map((post) => {
            const featuredMedia =
              post._embedded?.["wp:featuredmedia"]?.[0]?.media_details?.sizes?.medium?.source_url;
            const altText =
              post._embedded?.["wp:featuredmedia"]?.[0]?.alt_text || getTextContent(post.title?.rendered);

            return (
              <div key={post.id} className={`${pre}post-item`}>
                {featuredMedia && (
                  <div className={`${pre}featured-image`}>
                    <a href={post.link}>
                      <img src={featuredMedia} alt={altText} />
                    </a>
                  </div>
                )}
                {post.title?.rendered && (
                  <h3 className={`${pre}post-title`}>
                    <a href={post.link}>{getTextContent(post.title.rendered)}</a>
                  </h3>
                )}
                {post.excerpt?.rendered && (
                  <p className={`${pre}post-excerpt`}>
                    {getTextContent(post.excerpt.rendered)}
                  </p>
                )}
              </div>
            );
          })}
      </div>
    </div>
  );
}
埋め込みデータの最適化(_embed: 'wp:featuredmedia')

getEntityRecords の query パラメーターに _embed: true を指定する場合、すべての埋め込みデータを取得するため、パフォーマンスに影響を与える可能性があります。

アイキャッチ画像だけが必要な場合は、_embed: 'wp:featuredmedia' と指定することができます。

const posts = useSelect(
  (select) =>
    select("core").getEntityRecords("postType", "post", {
      per_page: 5,
      _embed: 'wp:featuredmedia',
      exclude: [select("core/editor").getCurrentPostId()],
    }),
  [],
);

日付

日付を表示するには、@wordpress/date パッケージから日付を管理およびフォーマットするための JavaScript 関数をインポートします。

  • dateI18n: 日付をサイトのロケールに翻訳してフォーマットします。
  • format: 日付をフォーマットします。
  • getSettings: WordPress の一般設定で現在定義されている日付設定を返します。
import { dateI18n, format, getSettings } from "@wordpress/date";

以下が日付をレンダリングする JSX です。time 要素を使って日付をマークアップします。

<time dateTime={format("c", post.date_gmt)} >
  {dateI18n(getSettings().formats.date, post.date_gmt)}
</time>

dateTime 属性(HTML では datetime 属性)には、format 関数を使用して、第2引数の post.date_gmt(投稿の日付を GMT で表した値)を ISO 8601 形式(c フォーマット)に変換しています。

出力される日付部分は、dateI18n の第1引数に日付フォーマット、第2引数に日付の値を指定して WordPress のロケール(言語設定)に基づいて日時をフォーマットします。getSettings() は WordPress の日時設定を返し、formats.date は管理画面で設定された「日付フォーマット」です。post.date_gmt は投稿の日付を GMT で表した値です。

たとえば、post.date_gmt が 2025-01-21T11:09:44+09:00 の場合、以下のように出力されます。

<time datetime="2025-01-21T11:09:44+09:00">2025年1月21日</time>

但し、post.date_gmt が undefined の場合、format("c", undefined) でエラーになる可能性があるので、以下のように post.date_gmt が undefined でないことを確認してレンダリングします。

{post.date_gmt && (
  <time dateTime={format("c", post.date_gmt)}>
    {dateI18n(getSettings().formats.date, post.date_gmt)}
  </time>
)}

edit.js の全体のコードは以下のようになります。

import { __ } from "@wordpress/i18n";
import { useBlockProps } from "@wordpress/block-editor";
import { useSelect } from "@wordpress/data";
// @wordpress/date パッケージから日付用の関数をインポート
import { dateI18n, format, getSettings } from "@wordpress/date";
import "./editor.scss";

export default function Edit() {
  const posts = useSelect(
    (select) =>
      select("core").getEntityRecords("postType", "post", {
        per_page: 5,
        _embed: 'wp:featuredmedia',
        exclude: [select("core/editor").getCurrentPostId()],
      }),
    [],
  );

  const pre = 'wp-block-wdl-block-my-dynamic-block__';

  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // 日付を追加(53-57)
  return (
    <div {...useBlockProps()}>
      <div className={`${pre}post-items`}>
        {posts &&
          posts.map((post) => {
            const featuredMedia =
              post._embedded?.["wp:featuredmedia"]?.[0]?.media_details?.sizes?.medium?.source_url;
            const altText =
              post._embedded?.["wp:featuredmedia"]?.[0]?.alt_text || getTextContent(post.title?.rendered);

            return (
              <div key={post.id} className={`${pre}post-item`}>
                {featuredMedia && (
                  <div className={`${pre}featured-image`}>
                    <a href={post.link}>
                      <img src={featuredMedia} alt={altText} />
                    </a>
                  </div>
                )}
                {post.title?.rendered && (
                  <h3 className={`${pre}post-title`}>
                    <a href={post.link}>{getTextContent(post.title.rendered)}</a>
                  </h3>
                )}
                {post.date_gmt && (
                  <time dateTime={format("c", post.date_gmt)} className={`${pre}post-date`}>
                    {dateI18n(getSettings().formats.date, post.date_gmt)}
                  </time>
                )}
                {post.excerpt?.rendered && (
                  <p className={`${pre}post-excerpt`}>
                    {getTextContent(post.excerpt.rendered)}
                  </p>
                )}
              </div>
            );
          })}
      </div>
    </div>
  );
}

エディターで確認すると、例えば以下のように表示されます。

Disabled コンポーネント

この例ではタイトルとアイキャッチ画像にその投稿のページへのリンクを設定しています。

エディター画面でブロックの各投稿のリンクをクリックすることができますが、クリックするとその編集画面内にリンク先の投稿が表示されてしまい、混乱を招く可能性があります。

また、この状態で画面幅を変更すると以下のようなエラーが発生する場合があります(バグ?)。

Disabled コンポーネントを使うと、出力にレンダリングされるリンクが誤ってクリックされることを防ぐことができます。

Disabled は子孫の要素を無効にし、ポインターの操作を防止するコンポーネントです。

edit.js を以下のように書き換えます。これでリンクがクリックされることを防げます。

import { __ } from "@wordpress/i18n";
import { useBlockProps } from "@wordpress/block-editor";
import { useSelect } from "@wordpress/data";
import { dateI18n, format, getSettings } from "@wordpress/date";
// Disabled コンポーネントをインポート
import { Disabled } from "@wordpress/components";
import "./editor.scss";

export default function Edit() {
  const posts = useSelect(
    (select) =>
      select("core").getEntityRecords("postType", "post", {
        per_page: 5,
        _embed: 'wp:featuredmedia',
        exclude: [select("core/editor").getCurrentPostId()],
      }),
    [],
  );

  const pre = 'wp-block-wdl-block-my-dynamic-block__';

  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // Disabled コンポーネントで投稿のレンダリング部分をラップ
  return (
    <div {...useBlockProps()}>
      <Disabled>
        <div className={`${pre}post-items`}>
          {posts &&
            posts.map((post) => {
              const featuredMedia =
                post._embedded?.["wp:featuredmedia"]?.[0]?.media_details?.sizes?.medium?.source_url;
              const altText =
                post._embedded?.["wp:featuredmedia"]?.[0]?.alt_text || getTextContent(post.title?.rendered);

              return (
                <div key={post.id} className={`${pre}post-item`}>
                  {featuredMedia && (
                    <div className={`${pre}featured-image`}>
                      <a href={post.link}>
                        <img src={featuredMedia} alt={altText} />
                      </a>
                    </div>
                  )}
                  {post.title?.rendered && (
                    <h3 className={`${pre}post-title`}>
                      <a href={post.link}>{getTextContent(post.title.rendered)}</a>
                    </h3>
                  )}
                  {post.date_gmt && (
                    <time dateTime={format("c", post.date_gmt)} className={`${pre}post-date`}>
                      {dateI18n(getSettings().formats.date, post.date_gmt)}
                    </time>
                  )}
                  {post.excerpt?.rendered && (
                    <p className={`${pre}post-excerpt`}>
                      {getTextContent(post.excerpt.rendered)}
                    </p>
                  )}
                </div>
              );
            })}
        </div>
      </Disabled>
    </div>
  );
}

RichText で見出しを追加

ブロック全体の見出しを RichText コンポーネントを使って追加します。

attributes(属性)の追加

見出しを表示するかどうかや見出しのテキストやタグの情報を保存するため、以下の属性を追加します。

  • displayHeading: 見出しを表示するかどうかの真偽値
  • headingTag: 見出しのタグ(h1-h6)
  • headingText: 見出しのテキスト(デフォルト値として空文字列を指定)

block.json の attributes プロパティに上記の属性(14-26行目)を追加します。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl-block/my-dynamic-block",
  "version": "0.1.0",
  "title": "My Dynamic Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false
  },
  "attributes": {
    "displayHeading": {
      "type": "boolean",
      "default": true
    },
    "headingTag": {
      "type": "string",
      "default": "h2"
    },
    "headingText": {
      "type": "string",
      "default": ""
    }
  },
  "textdomain": "my-dynamic-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScript": "file:./view.js"
}

JSX に RichText コンポーネントを追加

edit.js を以下のように書き換えます。

まず、RichText を useBlockProps と同じ @wordpress/block-editor パッケージからインポートします。

そして Edit 関数(コンポーネント)のパラメータに属性(attributes)と属性を更新する関数(setAttributes)を分割代入で受け取り(10行目)、各属性を分割代入で変数に代入します(12行目)。

続いて、Edit によって return される JSX に RichText コンポーネントを追加します(36-43行目)。

その際、displayHeading && により、見出しを表示するかどうかの属性(displayHeading)の値が true の場合にのみ RichText コンポーネントをレンダリングするようにしています。

RichText コンポーネントの tagName プロパティには headingTag 属性を指定しているので、この時点では見出しのタグは attributes に設定したデフォルトの h2 になります。

displayHeading や headingTag の値はインスペクターパネルでユーザーが選択できるようにします。

import { __ } from "@wordpress/i18n";
// RichText のインポートを追加
import { useBlockProps, RichText } from "@wordpress/block-editor";
import { useSelect } from "@wordpress/data";
import { dateI18n, format, getSettings } from "@wordpress/date";
import { Disabled } from "@wordpress/components";
import "./editor.scss";

// Edit 関数のパラメータに attributes と属性を更新する関数 setAttributes を分割代入で受け取る
export default function Edit({ attributes, setAttributes }) {
  // attributes から属性を分割代入で変数に代入
  const { displayHeading, headingTag, headingText } = attributes;
  const posts = useSelect(
    (select) =>
      select("core").getEntityRecords("postType", "post", {
        per_page: 5,
        _embed: 'wp:featuredmedia',
        exclude: [select("core/editor").getCurrentPostId()],
      }),
    [],
  );

  const pre = 'wp-block-wdl-block-my-dynamic-block__';

  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // RichText コンポーネントで見出しを追加
  return (
    <div {...useBlockProps()}>
      {displayHeading && (
        <RichText
          tagName={headingTag}
          onChange={(value) => setAttributes({ headingText: value })}
          value={headingText}
          className={ `${pre}heading` }
          placeholder={__("Enter heading text", "my-dynamic-block")}
        />
      )}
      <Disabled>
        <div className={`${pre}post-items`}>
          {posts &&
            posts.map((post) => {
              const featuredMedia =
                post._embedded?.["wp:featuredmedia"]?.[0]?.media_details?.sizes?.medium?.source_url;
              const altText =
                post._embedded?.["wp:featuredmedia"]?.[0]?.alt_text || getTextContent(post.title?.rendered);

              return (
                <div key={post.id} className={`${pre}post-item`}>
                  {featuredMedia && (
                    <div className={`${pre}featured-image`}>
                      <a href={post.link}>
                        <img src={featuredMedia} alt={altText} />
                      </a>
                    </div>
                  )}
                  {post.title?.rendered && (
                    <h3 className={`${pre}post-title`}>
                      <a href={post.link}>{getTextContent(post.title.rendered)}</a>
                    </h3>
                  )}
                  {post.date_gmt && (
                    <time dateTime={format("c", post.date_gmt)} className={`${pre}post-date`}>
                      {dateI18n(getSettings().formats.date, post.date_gmt)}
                    </time>
                  )}
                  {post.excerpt?.rendered && (
                    <p className={`${pre}post-excerpt`}>
                      {getTextContent(post.excerpt.rendered)}
                    </p>
                  )}
                </div>
              );
            })}
        </div>
      </Disabled>
    </div>
  );
}

ブロックには見出しの入力欄が追加され、任意のテキストを入力して保存することができます。

インスペクターコントロールの追加

エディターのインスペクターパネルにコントロールを追加して以下を制御できるようにします。

  • 見出しの表示・非表示(属性は前項で定義済み)
  • 見出しのレベル(属性は前項で定義済み)
  • 表示する投稿数
  • 現在の投稿を除外するかどうか
  • 日付を表示するかどうか
  • 抜粋を表示するかどうか
  • 抜粋の文字数
  • アイキャッチ画像を表示するかどうか
  • 投稿タイトルのタグのレベル
  • 並び順
  • 並び替え基

attributes(属性)の追加

以下の属性を attributes に追加します。

  • numberOfItems: 表示する投稿数
  • excludeCurrentPost: 現在の投稿を除外するかどうかの真偽値
  • displayDate: 日付を表示するかどうかの真偽値
  • displayExcerpt: 抜粋を表示するかどうかの真偽値
  • excerptLength: 抜粋の文字数
  • displayFeaturedImage: アイキャッチ画像を表示するかどうかの真偽値
  • titleTag: 投稿タイトルのタグ(見出しタグのレベル)
  • order: 並び順
  • orderBy: 並び替え基準

block.json を以下のように書き換えます(26-57行目を追加)。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl-block/my-dynamic-block",
  "version": "0.1.0",
  "title": "My Dynamic Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false
  },
  "attributes": {
    "displayHeading": {
      "type": "boolean",
      "default": true
    },
    "headingTag": {
      "type": "string",
      "default": "h2"
    },
    "headingText": {
      "type": "string",
      "default": ""
    },
    "numberOfItems": {
      "type": "number",
      "default": 5
    },
    "excludeCurrentPost": {
      "type": "boolean",
      "default": true
    },
    "displayDate": {
      "type": "boolean",
      "default": true
    },
    "displayExcerpt": {
      "type": "boolean",
      "default": true
    },
    "excerptLength": {
      "type": "number",
      "default": 55
    },
    "displayFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "titleTag": {
      "type": "string",
      "default": "h3"
    },
    "order": {
      "type": "string",
      "default": "desc"
    },
    "orderBy": {
      "type": "string",
      "default": "date"
    }
  },
  "textdomain": "my-dynamic-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScript": "file:./view.js"
}

edit.js を以下のように書き換えます。

InspectorControls やパネルのコンポーネントと各コントロールのコンポーネントをインポートします。

そして Edit() 関数内で attributes から block.json に追加した属性を分割代入で変数に代入します。

useSelect フックでは、getEntityRecords の query パラメータに attributes から取得した属性の値を指定し、useSelect の依存配列に抽出条件の属性を追加します。

ブロックの見出しと投稿タイトルの見出しのレベルを設定する SelectControl に指定する見出しタグのオプションのリスト(headingTagOptions)は同じなのであらかじめ定義しておきます(詳細は後述)。

投稿タイトルの見出しのレベルはコントロールで選択するので、タグを動的に生成する TitleTag コンポーネントを定義します(詳細は後述)。

Edit() 関数の return の中で InspectorControls コンポーネントで囲んだ内容はサイドバーのインスペクターに表示され、独自のカスタムパネルを作成できます。

通常、InspectorControls コンポーネントの中にパネルのコンポーネント PanelBody を配置し、その中にコントロールのコンポーネントを配置します。必要に応じて PanelBody の中に PanelRow を配置することもできます。

InspectorControls を追加するので、return する JSX 全体をフラグメント (<>〜</>) で囲みます。

見出しやアイキャッチ画像、日付、抜粋は displayHeading && のように条件付きレンダリングで属性の値によりレンダリングするようにします。

import { __ } from "@wordpress/i18n";
// InspectorControls のインポートを追加
import {
  useBlockProps,
  RichText,
  InspectorControls,
} from "@wordpress/block-editor";
// パネルやコントロールのコンポーネントをインポート
import {
  PanelBody,
  PanelRow,
  QueryControls,
  SelectControl,
  ToggleControl,
  RangeControl,
} from "@wordpress/components";
import { useSelect } from "@wordpress/data";
import { dateI18n, format, getSettings } from "@wordpress/date";
import { Disabled } from "@wordpress/components";
import "./editor.scss";

export default function Edit({ attributes, setAttributes }) {
  // attributes から追加した属性を分割代入で変数に代入
  const {
    displayHeading,
    headingTag,
    headingText,
    numberOfItems,
    excludeCurrentPost,
    displayDate,
    displayExcerpt,
    excerptLength,
    displayFeaturedImage,
    titleTag,
    order,
    orderBy,
  } = attributes;

  const posts = useSelect(
    (select) => {
      // 現在の投稿IDを取得(クエリの exclude に指定)
      const postId = select("core/editor")?.getCurrentPostId();
      return select("core").getEntityRecords("postType", "post", {
        per_page: numberOfItems, // 表示件数
        _embed: 'wp:featuredmedia',
        order, // 並び順 (order: order, と同じこと)
        orderby: orderBy, // 並び替え基準 (キーと値で b の小文字大文字が異なります)
        exclude: excludeCurrentPost && postId ? [postId] : [], // 除外する投稿
      });
    },
    // 依存配列に抽出条件の属性を追加
    [numberOfItems, excludeCurrentPost, order, orderBy],
  );

  const pre = "wp-block-wdl-block-my-dynamic-block__";

  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  // 見出しタグのオプションのリストを作成
  const headingTagOptions = Array.from({ length: 6 }, (_, i) => ({
    label: `H${i + 1}`,
    value: `h${i + 1}`,
  }));

  // 投稿タイトルのタグを動的に生成コンポーネント
  const TitleTag = ({ tagName = "h3", children, ...props }) => {
    return React.createElement(tagName, { ...props }, children);
  };

  // インスペクターパネルにコントロールを追加
  return (
    <>
      <InspectorControls>
        <PanelBody title={__("Heading Settings", "my-dynamic-block")}>
          <ToggleControl
            label={__("Display Heading", "my-dynamic-block")}
            checked={displayHeading}
            onChange={() => setAttributes({ displayHeading: !displayHeading })}
          />
          {displayHeading && (
            <SelectControl
              label={__("Heading Tag", "my-dynamic-block")}
              onChange={(value) => setAttributes({ headingTag: value })}
              value={headingTag}
              options={headingTagOptions}
            />
          )}
        </PanelBody>
        <PanelBody title={__("Content Settings", "my-dynamic-block")}>
          <QueryControls
            numberOfItems={numberOfItems}
            onNumberOfItemsChange={(value) =>
              setAttributes({ numberOfItems: value })
            }
            minItems={1}
            maxItems={10}
            {...{ order, orderBy }}
            onOrderChange={(value) => setAttributes({ order: value })}
            onOrderByChange={(value) => setAttributes({ orderBy: value })}
          />
          <PanelRow>
            <ToggleControl
              label={__("Display featured image", "my-dynamic-block")}
              checked={displayFeaturedImage}
              onChange={() =>
                setAttributes({ displayFeaturedImage: !displayFeaturedImage })
              }
            />
          </PanelRow>
          <PanelRow>
            <ToggleControl
              label={__("Display Date", "my-dynamic-block")}
              checked={displayDate}
              onChange={() => setAttributes({ displayDate: !displayDate })}
            />
          </PanelRow>
          <PanelRow>
            <ToggleControl
              label={__("Display Excerpt", "my-dynamic-block")}
              checked={displayExcerpt}
              onChange={() =>
                setAttributes({ displayExcerpt: !displayExcerpt })
              }
            />
          </PanelRow>
          {displayExcerpt && (
            <RangeControl
              label={__("Max number of words", "my-dynamic-block")}
              value={excerptLength}
              onChange={(value) => setAttributes({ excerptLength: value })}
              min={10}
              max={100}
            />
          )}
          <PanelRow>
            <ToggleControl
              label={__("Exclude Current Post", "my-dynamic-block")}
              checked={excludeCurrentPost}
              onChange={() =>
                setAttributes({ excludeCurrentPost: !excludeCurrentPost })
              }
            />
          </PanelRow>
          <SelectControl
            label={__("Title Tag", "my-dynamic-block")}
            onChange={(value) => setAttributes({ titleTag: value })}
            value={titleTag}
            options={headingTagOptions}
          />
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        {displayHeading && (
          <RichText
            tagName={headingTag}
            onChange={(value) => setAttributes({ headingText: value })}
            value={headingText}
            className={`${pre}heading`}
            placeholder={__("Enter heading text", "my-dynamic-block")}
          />
        )}
        <Disabled>
          <div className={`${pre}post-items`}>
            {posts &&
              posts.map((post) => {
                const title = getTextContent(post.title?.rendered);
                const excerpt = getTextContent(post.excerpt?.rendered)?.slice(0, excerptLength) + "...";
                const featuredMedia = post._embedded?.["wp:featuredmedia"]?.[0]?.media_details?.sizes?.medium?.source_url;
                const altText = post._embedded?.["wp:featuredmedia"]?.[0]?.alt_text || title;
                return (
                  <div key={post.id} className={`${pre}post-item`}>
                    {displayFeaturedImage && featuredMedia && (
                      <div className={`${pre}featured-image`}>
                        <a href={post.link}>
                          <img src={featuredMedia} alt={altText} />
                        </a>
                      </div>
                    )}
                    {title && (
                      <TitleTag tagName={titleTag} className={`${pre}post-title`}>
                        <a href={post.link}>{title}</a>
                      </TitleTag>
                    )}
                    {displayDate && post.date_gmt && (
                      <time dateTime={format("c", post.date_gmt)} className={`${pre}post-date`}>
                        {dateI18n(getSettings().formats.date, post.date_gmt)}
                      </time>
                    )}
                    {displayExcerpt && post.excerpt?.rendered && (
                      <p className={`${pre}post-excerpt`}>{excerpt}</p>
                    )}
                  </div>
                );
              })}
          </div>
        </Disabled>
      </div>
    </>
  );
}

[訂正]getEntityRecords のパラメータの並び替え基準(47行目)が短縮構文 orderBy, となっていたのを orderby: orderBy, に修正。キー名は orderby のように b が小文字です。

今までのコードの以下の部分も変更しています。

exclude: [select("core/editor").getCurrentPostId()], のように exclude に直接 select の取得結果を渡していましたが、事前に投稿 ID を取得し、IDが null の可能性を考慮しています(42,48行目)。

171-174行目では、タイトルや抜粋をあらかじめ取得してプロパティ取得を事前に解決し、コードを整理しています。

エディターでブロックを選択すると、サイドバーのインスペクターパネルにコントロールが表示され、見出しの表示・非表示と見出しタグの選択、表示投稿数や並び順などの抽出条件、アイキャッチ画像や日付の表示・非表示などを操作することができます。

コントロールのコンポーネント

以下はインスペクターコントロールに使用する主なコンポーネントについてそれぞれの役割と概要です。

各コンポーネントの使い方は、コンポーネント名のリンクから確認できます。

コンポーネント名 役割 概要
InspectorControls ブロックのサイドバー(インスペクター)にカスタムコントロールを追加するためのコンテナ ブロック編集画面のサイドバーにコントロールを表示するためのラッパー。通常は「PanelBody」などのコンポーネントと一緒に使用。
PanelBody 折りたたみ可能なセクションを作成するためのコンテナ インスペクターコントロール内でセクションを整理し、必要に応じて展開または折りたたむことが可能。
PanelRow フォーム要素やコントロールを水平方向に配置するためのラッパー コントロールを水平方向に flexbox で整列させます。
QueryControls 投稿のクエリ(取得条件)を制御するためのコントロール 投稿タイプ、カテゴリ、並び順、表示件数などのパラメータを設定するためのインターフェースを提供。
SelectControl ドロップダウンメニューを提供 ユーザーが選択肢から1つのオプションを選ぶことを可能にします。
ToggleControl オン/オフスイッチを提供 チェックボックスの代替として使用され、真偽値の設定や切り替えを簡単に行えます。
TextControl テキスト入力フィールドを提供 シンプルなテキスト入力をユーザーに許可します。値の設定や変更に対応しています。
RangeControl スライダーで数値範囲を選択するためのコントロール ユーザーが最小値と最大値の間で数値を選べるようにします。
CheckboxControl 複数選択可能なチェックボックスを提供 真偽値の制御を提供し、複数の選択肢を個別に制御できます。
RadioControl ラジオボタンを提供 排他的な選択肢を提供し、1つのオプションだけを選べるようにします。
TextareaControl 複数行のテキスト入力フィールドを提供 長いテキストや複数行のデータ入力に対応します。
FontSizePicker フォントサイズを選択するコントロール プリセットされたフォントサイズまたはカスタムサイズを選択できるようにします。
ColorPalette 色を選択するためのカラーパレットを提供 プリセットされた色やカスタムカラーを選択できるインターフェースを表示。
PanelRow コンポーネント

PanelRow コンポーネントはコンテンツに top margin や Flexbox の設定(flex-direction: row)を適用するため、PanelRow コンポーネントでラップすることで top margin が適用されますが、 SelectControl などコンポーネントによってはラベルなどの幅が狭くなって正しく表示されないことがあります。

基本的に PanelRow はコントロールを水平方向に配置するものなのでで、PanelRow コンポーネント内にコンポーネントが 1 つしかない場合は、通常、PanelRow コンポーネントを使用する必要はありません。

上記の例ではトップマージンがほしいコントロールを PanelRow コンポーネントでラップしています。

SelectControl のオプション

SelectControl の選択肢のリストを指定する options プロパティは label と value プロパティを持つオブジェクトの配列で指定します(必要に応じて disabled プロパティも指定可能)。

この例では見出しのレベルを Array.from() を使って以下のように定義しています。

Array.from() は、配列のようなオブジェクトから新しい配列を作成するためのメソッドで、第1引数に { length:6 } を渡すことで、長さが6の空の配列を作成し、第2引数にコールバック関数を渡すことで、各要素を埋めることができます。

コールバック関数の第1引数は現在処理中の要素の値ですが、空なので(undefined になるので)使わないため _ としていますが引数名は何でもかまいません。第2引数は配列のインデックスです。

 const headingTagOptions = Array.from({ length: 6 }, (_, i) => ({
  label: `H${i + 1}`,
  value: `h${i + 1}`,
}));

for ループを使って以下のように記述するのと同じことになります。

const headingTagOptions = [];
for (let i = 1; i <= 6; i++) {
  headingTagOptions.push({ label: `H${i}`, value: `h${i}` });
}

上記を実行すると、以下の配列が生成されます。

[
  { label: "H1", value: "h1" },
  { label: "H2", value: "h2" },
  { label: "H3", value: "h3" },
  { label: "H4", value: "h4" },
  { label: "H5", value: "h5" },
  { label: "H6", value: "h6" }
]
QueryControls コンポーネント

QueryControls は、WordPress ブロックエディタ内で投稿やカスタム投稿タイプを表示する際に、その表示条件(クエリ)をカスタマイズできるようにするコンポーネントです。

この例では表示条件(クエリ)として、以下の属性を attributes プロパティに定義しています。

  • numberOfItems: 表示する投稿数
  • order: 並び順(投稿順)
  • orderBy: 並び替え基準

そして QueryControls コンポーネントに以下のプロパティを設定しています。

  • numberOfItems:表示する投稿数を指定
  • onNumberOfItemsChange:投稿数変更時のコールバック関数を指定
  • minItems:表示する投稿の最小数(デフォルトは1なので以下は省略可能)
  • maxItems:表示する投稿の最大数(デフォルトは100)
  • order:投稿の並び順を指定(asc または desc)
  • onOrderChange:並び順変更時のコールバック関数を指定
  • orderBy:並び替え基準(例:日付、タイトル)を指定
  • onOrderByChange:並び替え基準変更時のコールバック関数を指定
<QueryControls
  numberOfItems={numberOfItems}
  onNumberOfItemsChange={(value) =>
    setAttributes({ numberOfItems: value })
  }
  minItems={1}
  maxItems={10}
  {...{ order, orderBy }}
  onOrderChange={(value) => setAttributes({ order: value })}
  onOrderByChange={(value) => setAttributes({ orderBy: value })}
/>

上記8行目の ...{ order, orderBy } は、オブジェクトのスプレッド構文を使って、order と orderBy を展開しています。以下のように書いたほうがわかりやすいかもしれません。

<QueryControls
  numberOfItems={numberOfItems}
  onNumberOfItemsChange={(value) =>
    setAttributes({ numberOfItems: value })
  }
  minItems={1}
  maxItems={10}
  order={order}
  onOrderChange={(value) => setAttributes({ order: value })}
  orderBy={orderBy}
  onOrderByChange={(value) => setAttributes({ orderBy: value })}
/>

※ getEntityRecords の query パラメータに orderBy を指定する際は、orderby: orderBy のようにキーと値で b と B が異なります(orderBy: orderBy では機能しないので注意が必要です)。

上記の場合、例えば以下のようなコントロールが表示されます(WordPress の翻訳が適用されます)。

並び順のプルダウンをクリックすると、以下のように並び順及び並び替え基準が選択できます。

QueryControls を使ってカテゴリーを選択する方法は QueryControls のカテゴリー を御覧ください。

タグを動的に生成

このブロックでは投稿タイトルのタグをインスペクターのコントロールで指定できるようにしているため、投稿タイトルのタグを動的に生成するようにしています。

以下の TitleTag は tagName で指定した HTML タグを動的に生成するコンポーネントです。

引数はオブジェクト形式で渡され、tagName は生成したい HTML タグの名前、children はそのタグの中に挿入されるコンテンツ、...propsスプレッド構文)でクラス名やスタイルなどを受け取れるようになっています。tagName にはデフォルトタグとして h3 を指定しています(省略可能)。

そして受け取った引数をもとに、React.createElement で要素を生成しています。

const TitleTag = ({ tagName = "h3", children, ...props }) => {
  return React.createElement(tagName, { ...props }, children);
}

上記のコンポーネントは以下のように JSX を使って記述してもほぼ同じです。

const TitleTag = ({ tagName = "h3", children, ...props }) => {
  const Tag = tagName; // タグ名を変数として使用
  return <Tag {...props}>{children}</Tag>;
};

例えば、以下を記述すると

// クラスは className、スタイルは {{}} で囲み、カンマ区切り、キャメルケースで指定
<TitleTag tagName="div" className="bar" style={{ color:"red", backgroundColor:"yellow"}}>
  <p>Hello World!</p>
</TitleTag>

tagName に div を指定しているので、以下の div 要素が生成されます。

<div class="bar" style="color: red; background-color: yellow;">
  <p>Hello World!</p>
</div>

作成しているブロックの Edit() の return の中では、以下のように、TitleTag 要素の tagName 属性に attributes の titleTag 属性の値を指定します。

<TitleTag tagName={titleTag}>
  <a href={post.link}>{post.title.rendered}</a>
</TitleTag>

React.createElement

React.createElement は React が提供する関数で、要素(タグ)をプログラム的に生成します(React の要素を生成して返します)。

React.createElement(type, props, ...children)
  • type: 作成する要素の種類(例: 'h1', 'div')。
  • props: その要素に渡す属性(例: className, style)。
  • children: 要素の中に入る内容(テキストや他の要素)。

以下のコードは <h2 class="title-class" id="title-id">Hello</h2> を生成します。

React.createElement(
  'h2',
  { className: 'title-class', id: 'title-id' },
  'Hello!'
)

カテゴリーの選択

SelectControl コンポーネントを使ってカテゴリーを選択するコントロールを追加します。

QueryControls を使ってカテゴリーを選択する方法は QueryControls のカテゴリーを御覧ください。

block.json

block.json の attributes に選択されたカテゴリーを保存するための属性(categories)とすべてのカテゴリーの投稿を表示するかどうかの真偽値を保存する属性(allCategories)を追加します。

categories は複数のカテゴリーを選択できるように type は array(配列)としています。

,
"categories": {
  "type": "array",
  "default": []
},
"allCategories": {
  "type": "boolean",
  "default": true
}
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl-block/my-dynamic-block",
  "version": "0.1.0",
  "title": "My Dynamic Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false
  },
  "attributes": {
    "displayHeading": {
      "type": "boolean",
      "default": true
    },
    "headingTag": {
      "type": "string",
      "default": "h2"
    },
    "headingText": {
      "type": "string",
      "default": ""
    },
    "numberOfItems": {
      "type": "number",
      "default": 5
    },
    "excludeCurrentPost": {
      "type": "boolean",
      "default": true
    },
    "displayDate": {
      "type": "boolean",
      "default": true
    },
    "displayExcerpt": {
      "type": "boolean",
      "default": true
    },
    "excerptLength": {
      "type": "number",
      "default": 55
    },
    "displayFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "titleTag": {
      "type": "string",
      "default": "h3"
    },
    "order": {
      "type": "string",
      "default": "desc"
    },
    "orderBy": {
      "type": "string",
      "default": "date"
    },
    "categories": {
      "type": "array",
      "default": []
    },
    "allCategories": {
      "type": "boolean",
      "default": true
    }
  },
  "textdomain": "my-dynamic-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScript": "file:./view.js"
}

edit.js

Edit() 関数では、まず、追加した属性 categories と allCategories を分割代入で取得します。

そして、useSelect フックで投稿データに加えてカテゴリーのデータも取得します。投稿データもカテゴリーデータも core ストアの getEntityRecords を使うので分割代入で取得しておきます(24行目)。

カテゴリーのデータを取得する際は、query パラメータに { per_page: -1, hide_empty: true } を指定して、投稿がないカテゴリー以外のすべてのカテゴリーを取得しています(28-31行目)。投稿がないカテゴリーも取得する場合は hide_empty に false を指定します(Categories)。

投稿のデータを取得する際は、query パラメータに categories を追加してカテゴリーを抽出条件に加えます。その際、allCategories が true の場合は、undefined を指定してカテゴリーを抽出条件から外し、false の場合は、categories.length が1以上なら categories を指定し、categories.length === 0 なら undefined にしています(40行目)。

useSelect の第二引数の依存配列に categories と allCategories を追加します(45行目)。

また、カテゴリーを選択するコントロールは SelectControl コンポーネントを使うので、取得したカテゴリーのデータ(categoriesData)を使って SelectControl の options に指定するカテゴリーのリスト(配列) categoryOptions を定義します(49-54行目)。

export default function Edit({ attributes, setAttributes }) {
  const {
    displayHeading,
    headingTag,
    headingText,
    numberOfItems,
    excludeCurrentPost,
    displayDate,
    displayExcerpt,
    excerptLength,
    displayFeaturedImage,
    titleTag,
    order,
    orderBy,
    categories,  // 追加
    allCategories,  // 追加
  } = attributes;

  // 投稿データに加えてカテゴリーのデータも取得
  const { categoriesData, posts } = useSelect(
    (select) => {
      const postId = select("core/editor")?.getCurrentPostId();
      // getEntityRecords を core ストアから取得
      const { getEntityRecords } = select("core");

      return {
        // カテゴリーのデータ
        categoriesData: getEntityRecords("taxonomy", "category", {
          per_page: -1,
          hide_empty: true //投稿がないカテゴリーも取得する場合は false を指定
        }) || [],
        // 投稿のデータ
        posts: getEntityRecords("postType", "post", {
          per_page: numberOfItems,
          _embed: 'wp:featuredmedia',
          order,
          orderby: orderBy,
          exclude: excludeCurrentPost && postId ? [postId] : [],
          // query にカテゴリーを追加
          categories: allCategories ? undefined : (categories.length > 0 ? categories : undefined),
        }),
      };
    },
    // 依存配列に categories と allCategories を追加
    [numberOfItems, excludeCurrentPost, order, orderBy, categories, allCategories],
  );

  // SelectControl コンポーネントの options に指定するカテゴリーのリスト
  let categoryOptions = [];
  if (categoriesData) {
    categoryOptions = categoriesData.map((cat) => {
      return { label: __(cat.name, "my-dynamic-block"), value: cat.id };
    });
  }
  //・・・以下省略・・・
}

SelectControl コンポーネントの options に指定するカテゴリーのリスト categoryOptions は以下のような label がカテゴリー名(cat.name)、value がカテゴリーID(cat.id)のオブジェクトの配列になります。

[
  {
    "label": "General",
    "value": 1
  },
  {
    "label": "News",
    "value": 13
  },
  {
    "label": "WordPress",
    "value": 14
  }
]

InspectorControls に以下の PanelBody でラップした ToggleControl と SelectControl を追加します。

ToggleControl では allCategories(すべてのカテゴリーを表示するかどうか)を制御するトグルボタンを表示し、オフにした場合、つまり、allCategories が false の場合(!allCategories &&)に、カテゴリーを選択する SelectControl を表示します。

SelectControl では、カテゴリーを複数選択できるように multiple 属性を指定しています。

multiple を指定しているので value は配列にする必要があります。

SelectControl の options に指定する categoryOptions の value は整数ですが、onChange で受け取る value は ['1', '13', '14'] のような文字列の配列になっているため、onChange では、value が null や undefined でない場合は、value.map(Number) で値を整数値の配列にし、value が null や undefined の場合は空の配列 [].map(Number) になります。

value.map(Number) は value.map(x => Number(x)) と同じです。

但し、categories が null の場合、.map(Number) を実行するとエラーになる可能性があるので、Array.isArray(categories) で確認しています。

<PanelBody title={__("Category Selection", "my-dynamic-block")}>
  <ToggleControl
    label={__("All Categories", "my-dynamic-block")}
    checked={allCategories}
    onChange={() => setAttributes({ allCategories: !allCategories })}
  />
  {!allCategories && (
    <SelectControl
      multiple
      label={__("Select Categories", "my-dynamic-block")}
      onChange={(value) => setAttributes({ categories: (value || []).map(Number) })}
      value={Array.isArray(categories) ? categories : []}
      options={categoryOptions}
    />
  )}
</PanelBody>

カテゴリーを1つだけ選択するようにするには、以下のように multiple 属性を削除して、setAttributes で categories に [value] のように value を配列で指定します。

{!allCategories && (
    <SelectControl
    label={__("Select Categories", "my-dynamic-block")}
    onChange={(value) => setAttributes({ categories: value ? [value] : []})}
    value={Array.isArray(categories) ? categories : []}
    options={categoryOptions}
  />;
)}

コンテンツのレンダリングでは、選択したカテゴリーに属する投稿がない場合のために以下を追加します。

posts が undefined の場合、posts.length を参照しようとしてエラーになる可能性があるので、単に posts && ではなく、念の為、Array.isArray(posts) && としています。

{Array.isArray(posts) && posts.length === 0 && (
  <p>No post for the selected category.</p>
)}

以下は edit.js のコード全体です。

import { __ } from "@wordpress/i18n";
import {
  useBlockProps,
  RichText,
  InspectorControls,
} from "@wordpress/block-editor";
import {
  PanelBody,
  PanelRow,
  QueryControls,
  SelectControl,
  ToggleControl,
  RangeControl,
} from "@wordpress/components";
import { useSelect } from "@wordpress/data";
import { dateI18n, format, getSettings } from "@wordpress/date";
import { Disabled } from "@wordpress/components";
import "./editor.scss";

export default function Edit({ attributes, setAttributes }) {
  const {
    displayHeading,
    headingTag,
    headingText,
    numberOfItems,
    excludeCurrentPost,
    displayDate,
    displayExcerpt,
    excerptLength,
    displayFeaturedImage,
    titleTag,
    order,
    orderBy,
    categories,  // 追加
    allCategories,  // 追加
  } = attributes;

  // 投稿データに加えてカテゴリーのデータも取得
  const { categoriesData, posts } = useSelect(
    (select) => {
      const postId = select("core/editor")?.getCurrentPostId();
      // getEntityRecords を core ストアから取得
      const { getEntityRecords } = select("core");

      return {
        // カテゴリーのデータ
        categoriesData: getEntityRecords("taxonomy", "category", {
          per_page: -1,
          hide_empty: true //投稿がないカテゴリーも取得する場合は false を指定
        }) || [],
        // 投稿のデータ
        posts: getEntityRecords("postType", "post", {
          per_page: numberOfItems,
          _embed: 'wp:featuredmedia',
          order,
          orderby: orderBy,
          exclude: excludeCurrentPost && postId ? [postId] : [],
          // query にカテゴリーを追加
          categories: allCategories ? undefined : (categories.length > 0 ? categories : undefined),
        }),
      };
    },
    // 依存配列に categories と allCategories を追加
    [numberOfItems, excludeCurrentPost, order, orderBy, categories, allCategories],
  );

  // SelectControl コンポーネントの options に指定するカテゴリーのリスト
  let categoryOptions = [];
  if (categoriesData) {
    categoryOptions = categoriesData.map((cat) => {
      return { label: __(cat.name, "my-dynamic-block"), value: cat.id };
    });
  }

  const pre = "wp-block-wdl-block-my-dynamic-block__";

  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  const headingTagOptions = Array.from({ length: 6 }, (_, i) => ({
    label: `H${i + 1}`,
    value: `h${i + 1}`,
  }));

  const TitleTag = ({ tagName = "h3", children, ...props }) => {
    return React.createElement(tagName, { ...props }, children);
  };

  // インスペクターパネルにコントロールを追加
  return (
    <>
      <InspectorControls>
        <PanelBody title={__("Heading Settings", "my-dynamic-block")}>
          <ToggleControl
            label={__("Display Heading", "my-dynamic-block")}
            checked={displayHeading}
            onChange={() => setAttributes({ displayHeading: !displayHeading })}
          />
          {displayHeading && (
            <SelectControl
              label={__("Heading Tag", "my-dynamic-block")}
              onChange={(value) => setAttributes({ headingTag: value })}
              value={headingTag}
              options={headingTagOptions}
            />
          )}
        </PanelBody>
        <PanelBody title={__("Content Settings", "my-dynamic-block")}>
          <QueryControls
            numberOfItems={numberOfItems}
            onNumberOfItemsChange={(value) =>
              setAttributes({ numberOfItems: value })
            }
            minItems={1}
            maxItems={10}
            {...{ order, orderBy }}
            onOrderChange={(value) => setAttributes({ order: value })}
            onOrderByChange={(value) => setAttributes({ orderBy: value })}
          />
          <PanelRow>
            <ToggleControl
              label={__("Display featured image", "my-dynamic-block")}
              checked={displayFeaturedImage}
              onChange={() =>
                setAttributes({ displayFeaturedImage: !displayFeaturedImage })
              }
            />
          </PanelRow>
          <PanelRow>
            <ToggleControl
              label={__("Display Date", "my-dynamic-block")}
              checked={displayDate}
              onChange={() => setAttributes({ displayDate: !displayDate })}
            />
          </PanelRow>
          <PanelRow>
            <ToggleControl
              label={__("Display Excerpt", "my-dynamic-block")}
              checked={displayExcerpt}
              onChange={() =>
                setAttributes({ displayExcerpt: !displayExcerpt })
              }
            />
          </PanelRow>
          {displayExcerpt && (
            <RangeControl
              label={__("Max number of words", "my-dynamic-block")}
              value={excerptLength}
              onChange={(value) => setAttributes({ excerptLength: value })}
              min={10}
              max={100}
            />
          )}
          <PanelRow>
            <ToggleControl
              label={__("Exclude Current Post", "my-dynamic-block")}
              checked={excludeCurrentPost}
              onChange={() =>
                setAttributes({ excludeCurrentPost: !excludeCurrentPost })
              }
            />
          </PanelRow>
          <SelectControl
            label={__("Title Tag", "my-dynamic-block")}
            onChange={(value) => setAttributes({ titleTag: value })}
            value={titleTag}
            options={headingTagOptions}
          />
        </PanelBody>
        <PanelBody title={__("Category Selection", "my-dynamic-block")}>
          <ToggleControl
            label={__("All Categories", "my-dynamic-block")}
            checked={allCategories}
            onChange={() => setAttributes({ allCategories: !allCategories })}
          />
          {!allCategories && (
            <SelectControl
              multiple
              label={__("Select Categories", "my-dynamic-block")}
              onChange={(value) => setAttributes({ categories: (value || []).map(Number) })}
              value={Array.isArray(categories) ? categories : []}
              options={categoryOptions}
            />
          )}
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        {displayHeading && (
          <RichText
            tagName={headingTag}
            onChange={(value) => setAttributes({ headingText: value })}
            value={headingText}
            className={`${pre}heading`}
            placeholder={__("Enter heading text", "my-dynamic-block")}
          />
        )}
        <Disabled>
          {Array.isArray(posts) && posts.length === 0 && (
            <p>No post for the selected category.</p>
          )}
          <div className={`${pre}post-items`}>
            {posts &&
              posts.map((post) => {
                const title = getTextContent(post.title?.rendered);
                const excerpt = getTextContent(post.excerpt?.rendered)?.slice(0, excerptLength) + "...";
                const featuredMedia = post._embedded?.["wp:featuredmedia"]?.[0]?.media_details?.sizes?.medium?.source_url;
                const altText = post._embedded?.["wp:featuredmedia"]?.[0]?.alt_text || title;
                return (
                  <div key={post.id} className={`${pre}post-item`}>
                    {displayFeaturedImage && featuredMedia && (
                      <div className={`${pre}featured-image`}>
                        <a href={post.link}>
                          <img src={featuredMedia} alt={altText} />
                        </a>
                      </div>
                    )}
                    {title && (
                      <TitleTag tagName={titleTag} className={`${pre}post-title`}>
                        <a href={post.link}>{title}</a>
                      </TitleTag>
                    )}
                    {displayDate && post.date_gmt && (
                      <time dateTime={format("c", post.date_gmt)} className={`${pre}post-date`}>
                        {dateI18n(getSettings().formats.date, post.date_gmt)}
                      </time>
                    )}
                    {displayExcerpt && post.excerpt?.rendered && (
                      <p className={`${pre}post-excerpt`}>{excerpt}</p>
                    )}
                  </div>
                );
              })}
          </div>
        </Disabled>
      </div>
    </>
  );
}

以下のようなカテゴリー選択のパネルが追加されます。

All Categories のトグルをオフにすると、カテゴリーの選択が表示されます。複数のカテゴリーを選択するには、command キーを押しながら選択します。

ブロックサポートを追加

ブロックのコンテンツ幅や文字色、背景色、パディング、マージン、フォントサイズなどを設定できるように以下のブロックサポートを block.json に追加します。

"supports": {
  "html": false,
  "align": true,
  "color": {
    "gradients": true,
    "link": true
  },
  "spacing": {
    "margin": true,
    "padding": true
  },
  "typography": {
    "fontSize": true,
    "lineHeight": true
  }
},
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl-block/my-dynamic-block",
  "version": "0.1.0",
  "title": "My Dynamic Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false,
    "align": true,
    "color": {
      "gradients": true,
      "link": true
    },
    "spacing": {
      "margin": true,
      "padding": true
    },
    "typography": {
      "fontSize": true,
      "lineHeight": true
    }
  },
  "attributes": {
    "displayHeading": {
      "type": "boolean",
      "default": true
    },
    "headingTag": {
      "type": "string",
      "default": "h2"
    },
    "headingText": {
      "type": "string",
      "default": ""
    },
    "numberOfItems": {
      "type": "number",
      "default": 5
    },
    "excludeCurrentPost": {
      "type": "boolean",
      "default": true
    },
    "displayDate": {
      "type": "boolean",
      "default": true
    },
    "displayExcerpt": {
      "type": "boolean",
      "default": true
    },
    "excerptLength": {
      "type": "number",
      "default": 55
    },
    "displayFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "featuredImageSizeSlug": {
      "type": "string",
      "default": "medium"
    },
    "addLinkToFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "titleTag": {
      "type": "string",
      "default": "h3"
    },
    "order": {
      "type": "string",
      "default": "desc"
    },
    "orderBy": {
      "type": "string",
      "default": "date"
    },
    "categories": {
      "type": "array",
      "default": []
    },
    "allCategories": {
      "type": "boolean",
      "default": true
    }
  },
  "textdomain": "my-dynamic-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScript": "file:./view.js"
}

edit.js の変更はありません。

インスペクターにスタイルタブが追加され、以下のようなコントロールが表示され、ブロックのスタイルを設定することができます。

これで、エディターでブロックをレンダリングする基本的なコードは完了です。

フロントエンドにコンテンツを表示

フロントエンドでレンダリングするブロックを構築します。

フロントエンド側の表示は block.json の render プロパティに指定されている render.php に記述します。render.php はフロントエンドでレンダリングされる HTML を生成する役割を担います。

<p <?php echo get_block_wrapper_attributes(); ?>>
  <?php esc_html_e( 'My Dynamic Block – hello from a dynamic block!', 'my-dynamic-block' ); ?>
</p>

render プロパティに指定した PHP ファイル(この例の場合は render.php )や render_callback に指定したコールバック関数では、以下のデータを自動的に受け取ります。

  • $attributes: ブロックの属性の配列
  • $content: データベース内に保存されたブロックのマークアップ(もしあれば)
  • $block: レンダリングされたブロックを表す WP_Block クラスのインスタンス

ブロックのラッパー要素の属性を取得するには、get_block_wrapper_attributes() を使います。

render.php

edit.js のエディター側でのレンダリングと同じ構造の HTML をフロントエンドでレンダリングするように render.php でコーディングします。

具体的には WP_Queryget_posts で生成したクエリを使ってループ処理して投稿を表示します。

以下が render.php です。

最初に、エディター側で選択した条件の投稿を取得するためのクエリの引数を設定します。

ブロックの属性は $attributes 配列で受け取ることができ、例えば、表示する投稿数の属性 numberOfItems は $attributes['numberOfItems'] でアクセスできます。

基本的なクエリの引数を変数 $args に設定しています(6-12行目)

属性 allCategories と categories の値からカテゴリー指定の引数 category__in を $args に追加します。

また、除外する投稿の ID を取得するため、global $post を宣言(3行目)し、現在の投稿 ID を取得して、属性 excludeCurrentPost が true の場合は、$args に post__not_in を追加します。詳細

そして new WP_Query($args) でクエリを生成し、マークアップを作成します。

<?php

global $post; // 現在の投稿情報を取得

// クエリの引数を設定
$args = array(
  'posts_per_page'      => $attributes['numberOfItems'], // 表示する投稿数
  'post_status'         => 'publish', // 公開済みの投稿のみ取得
  'order'               => $attributes['order'], // 並び順(昇順・降順)
  'orderby'             => $attributes['orderBy'], // 並び替えの基準(例: 日付, タイトル)
  'ignore_sticky_posts' => true, // スティッキーポスト(先頭に固定表示された投稿)を無視
  'no_found_rows'       => true, // クエリの総投稿数を取得しない(パフォーマンス向上)
);

// allCategories と categories の設定からカテゴリーの絞り込み
if (empty($attributes['allCategories']) && !empty($attributes['categories']) && is_array($attributes['categories'])) {
  $args['category__in'] = array_map('intval', $attributes['categories']);  // カテゴリーIDを整数に変換
}

// 現在の投稿 ID を取得
$current_post_id = get_the_ID();

// excludeCurrentPost の値により現在の投稿を除外
if (isset($attributes['excludeCurrentPost']) && $attributes['excludeCurrentPost'] && $current_post_id) {
  $args['post__not_in'] = [(int) $current_post_id];
}

// WP_Query で投稿を取得
$query = new WP_Query($args);

// アイキャッチ画像のキャッシュを有効化(パフォーマンス向上)
if (isset($attributes['displayFeaturedImage']) && $attributes['displayFeaturedImage'] && $query->have_posts()) {
  update_post_thumbnail_cache($query);
}

// CSS クラスのプレフィックスを設定
$pre = 'wp-block-wdl-block-my-dynamic-block__';

// 投稿リストのHTMLを格納する変数
$post_items_markup = '';

// displayHeading の値により見出しを表示する場合
if (!empty($attributes['displayHeading']) && !empty($attributes['headingText']) && !empty($attributes['headingTag'])) {
  $post_items_markup .= sprintf(
    '<%1$s class="%2$sheading">%3$s</%1$s>',
    esc_attr($attributes['headingTag']), // 指定された見出しタグ(例: h2, h3)
    esc_attr($pre), // クラス名のプレフィックス
    esc_html($attributes['headingText']) // 見出しのテキスト
  );
}

// 投稿リストのコンテナを作成
$post_items_markup .= sprintf(
  '<div class="%1$spost-items">',
  esc_attr($pre)
);

// 投稿が存在すれば
if ($query->have_posts()) {
  // 投稿のループ処理
  while ($query->have_posts()) {
    $query->the_post();
    $post_link = esc_url(get_permalink());  // 投稿のURL
    $title     = wp_strip_all_tags(get_the_title());  // 投稿のタイトル(HTMLタグを除去)

    // 投稿アイテムのコンテナを作成
    $post_items_markup .= sprintf(
      '<div class="%1$spost-item">',
      esc_attr($pre)
    );

    // アイキャッチ画像を表示する場合
    if (isset($attributes['displayFeaturedImage']) && $attributes['displayFeaturedImage'] && has_post_thumbnail()) {

      $thumbnail_alt_value = get_post_meta(get_post_thumbnail_id(), '_wp_attachment_image_alt', true);

      // alt属性が空の場合、投稿タイトルを代わりに設定
      if (!$thumbnail_alt_value) {
        $thumbnail_alt_value = $title;
      }

      // アイキャッチ画像のHTMLを取得
      $featured_image = get_the_post_thumbnail(
        get_the_ID(),
        $attributes['featuredImageSizeSlug'],  // 画像サイズ
        array(
          'alt'   => esc_attr($thumbnail_alt_value),  // alt属性の設定
        )
      );

      // アイキャッチ画像にリンクを追加する場合
      if (!empty($attributes['addLinkToFeaturedImage'])) {
        $featured_image = sprintf(
          '<a href="%1$s" aria-label="%2$s">%3$s</a>',
          esc_url($post_link),
          esc_attr($title), // アクセシビリティのためのラベル
          $featured_image
        );
      }

      // 画像のHTMLを追加
      $post_items_markup .= sprintf(
        '<div class="%1$sfeatured-image">%2$s</div>',
        esc_attr($pre),
        $featured_image
      );
    }

    // 投稿タイトルのHTMLを追加
    $post_items_markup .= sprintf(
      '<%1$s class="%2$spost-title"><a href="%3$s">%4$s</a></%1$s>',
      $attributes['titleTag'],  // 指定されたタイトルタグ(例: h2, h3)
      esc_attr($pre),
      esc_url($post_link),
      $title
    );

    // 投稿日を表示する場合
    if (isset($attributes['displayDate']) && $attributes['displayDate']) {
      $post_items_markup .= sprintf(
        '<time datetime="%1$s" class="%2$spost-date">%3$s</time>',
        esc_attr(get_the_date('c')),  // ISOフォーマットの日付
        esc_attr($pre),
        esc_html(get_the_date()) // フォーマット済みの日付
      );
    }

    // 抜粋を表示する場合
    if (isset($attributes['displayExcerpt']) && $attributes['displayExcerpt'] && isset($attributes['excerptLength']) && $attributes['excerptLength']) {
      $post_items_markup .= sprintf(
        '<p class="%1$spost-excerpt">%2$s</p>',
        esc_attr($pre),
        // 抜粋の長さを制限(コンテンツからは wp_strip_all_tags でタグを除去)
        wp_trim_words(wp_strip_all_tags(get_the_excerpt()), (int) $attributes['excerptLength'], '...')
      );
    }
    // 投稿アイテムの終了タグ
    $post_items_markup .= "</div>\n";
  }
} else {
  // 投稿がない場合のメッセージ
  $post_items_markup .= '<p>' . esc_html__('No posts to display.', 'my-dynamic-block') . '</p>';
}

// 投稿リストの終了タグ
$post_items_markup .= "</div>\n";

// 投稿データをリセット
wp_reset_postdata();

// ブロックのラッパー属性を取得
$wrapper_attributes = get_block_wrapper_attributes();

// wp_kses() に指定するデフォルトの許可タグを取得
$allowed_tags = wp_kses_allowed_html('post');
// 許可タグのリストに img の srcset 属性を追加
$allowed_tags['img']['srcset'] = true;
// 許可タグのリストに img の sizes 属性を追加
$allowed_tags['img']['sizes'] = true;

// 最終的なHTMLを出力
printf(
  '<div %1$s>%2$s</div>',
  $wrapper_attributes,
  // セキュリティ対策としてHTMLのコンテンツをサニタイズ(無害化)
  wp_kses($post_items_markup, $allowed_tags)
);

投稿リストのHTML(マークアップ)を格納する変数 $post_items_markup を宣言し(40行目)、まず displayHeading の値により見出しを作成して追加します。

その後、投稿リストのコンテナを作成し、ループの中で投稿アイテムのコンテナを作成してその中にマークアップを追加していきます(詳細はコメントを参照ください)。

出力する値は、esc_attr()esc_url()esc_html() などを使って適切にエスケープします。タイトルや抜粋は HTML を含む可能性がるので、以下では wp_strip_all_tags() を使ってタグを削除しています。

WP_Query を使ったサブループは the_post() を使うのでグローバル変数 $post を書き換えるため、最後に wp_reset_post_data() を実行して書き換えられた $post の値を元に戻します(149行目)。

サニタイズ

最終的な HTML は、念の為、適切なタグを許可する関数 wp_kses() を使ってコンテンツをサニタイズして、printf() で出力しています(wp_kses() は省略しても良いかもしれません)。

注意点としては、サニタイズに wp_kses_post() を使うと、アイキャッチ画像の img 要素の srcset や sizes 属性が削除されてしまいます。

そのため、wp_kses_allowed_html() でデフォルトの許可タグを取得し、img の srcset 属性と sizes 属性を許可タグのリストに追加して(155-159)、wp_kses() の第2引数に指定しています(162行目)。

ブロックラッパー要素の属性

ブロックのラッパー要素には get_block_wrapper_attributes() で取得したブロックのラッパー属性を追加します(152,164行目)。

属性の値のチェック

上記のコードでは、以下のように $attributes の値を empty() や isset() && でチェックしています。

empty() でチェック

以下はカテゴリーの絞り込みの部分です(16-18行目)。

これは $attributes の allCategories と categories の値をチェックして、allCategories が true でない場合に特定のカテゴリーの記事だけを取得するためのコードです。

// allCategories と categories の設定からカテゴリーの絞り込み
if (empty($attributes['allCategories'])
      && !empty($attributes['categories'])
        && is_array($attributes['categories'])) {
  $args['category__in'] = array_map('intval', $attributes['categories']);  // カテゴリーIDを整数に変換
}
  • empty($attributes['allCategories']) は $attributes['allCategories'] が 空(null, false, 0, '', [] など) のときに true になり、「すべてのカテゴリを取得する設定ではない」ことを意味します。
  • !empty($attributes['categories'])は $attributes['categories'] が空でない(つまり、カテゴリが指定されている)ことを確認しています。
  • is_array($attributes['categories']) は $attributes['categories'] が配列であることを確認しています。
  • 条件がすべて満たされた場合、$attributes['categories'] は空でない配列になります。念のため各要素を intval() で整数に変換して WP_Query のパラメータ category__in に指定しています。

isset() && でチェック

以下は現在表示している投稿を、取得する投稿一覧から除外する設定部分です(24-26行目)。

// excludeCurrentPost の値により現在の投稿を除外
if (isset($attributes['excludeCurrentPost'])
  && $attributes['excludeCurrentPost']
    && $current_post_id) {
  $args['post__not_in'] = [(int) $current_post_id];
}
  • isset($attributes['excludeCurrentPost']) は $attributes['excludeCurrentPost'] が存在しているかをチェックしています。isset() を使うことで、変数が未定義の場合にエラーが出るのを防げます。
  • && $attributes['excludeCurrentPost'] は値が true(または 1 などの真値)かをチェックしています(false や 0 なら処理をスキップ)。
  • && $current_post_id は $current_post_id に現在の投稿の ID が入っているかをチェックしています(0 や null の場合は処理をスキップ)。
  • 条件がすべて満たされた場合、$current_post_id を int に変換し、配列 [ ] に入れて post__not_in に指定しています。
PHP でタグを動的に生成

render.php の 44-49 や 11-115行目では、属性の値を使ってタグを作成しています。PHP でタグを動的に生成するのは、文字列の操作だけなので、JavaScript に比べると簡単です。

例えば、タグ <h2 class="my-heading">Hello!</h2> を作成するには以下のように記述できます。

$tagName = 'h2';
$className = 'my-heading';
$hadingText = 'Hello!';
$headingTag = '<' .$tagName . ' class="' .$className  .'">' .$hadingText . '</' .$tagName .'>';
echo $headingTag; // <h2 class="my-heading">Hello!</h2> を出力

以下は上記を sprintf() を使って書き換えたコードで、可読性が向上し、文字列の結合がスッキリします。

$tagName = 'h2';
$className = 'my-heading';
$hadingText = 'Hello!';

$headingTag = sprintf(
  '<%1$s class="%2$s">%3$s</%1$s>',
  $tagName,
  $className,
  $hadingText
);

echo $headingTag; 

%1$s は数字付きのプレースホルダーで、1つ目の引数($tagName)が %1$s に、2つ目の引数($className)が %2$s に、3つ目の引数($hadingText)が %3$s に入ります。開始タグと終了タグで同じ値 %1$s を使用しています。%s は文字列用のプレースホルダーで 1$ や 2$ が位置指定子です。

変数に入れずに、直接出力するには以下のように printf() を使います。

$tagName = 'h2';
$className = 'my-heading';
$hadingText = 'Hello!';

printf(
  '<%1$s class="%2$s">%3$s</%1$s>',
  $tagName,
  $className,
  $hadingText
);

フロントエンドを確認すると、例えば以下のようにエディター側と同じように表示されます。

グリッドレイアウトでカラム表示

カラム数を指定して、グリッドレイアウトで投稿をカラム表示する例です。

CSS でブレークポイントを指定して画面幅に応じてカラム数を調整します。

例えば、カラム数に4を指定すると以下のように画面幅が広い場合は4列で表示され、

画面幅を縮小するとブレークポイントの設定により、例えば、以下のように2列で表示されます。

block.json

block.json の attributes にカラム数を保存する属性 columns を追加します。

,
"columns": {
  "type": "number",
  "default": 1
}
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl-block/my-dynamic-block",
  "version": "0.1.0",
  "title": "My Dynamic Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false,
    "align": true,
    "color": {
      "gradients": true,
      "link": true
    },
    "spacing": {
      "margin": true,
      "padding": true
    },
    "typography": {
      "fontSize": true,
      "lineHeight": true
    }
  },
  "attributes": {
    "displayHeading": {
      "type": "boolean",
      "default": true
    },
    "headingTag": {
      "type": "string",
      "default": "h2"
    },
    "headingText": {
      "type": "string",
      "default": ""
    },
    "numberOfItems": {
      "type": "number",
      "default": 5
    },
    "excludeCurrentPost": {
      "type": "boolean",
      "default": true
    },
    "displayDate": {
      "type": "boolean",
      "default": true
    },
    "displayExcerpt": {
      "type": "boolean",
      "default": true
    },
    "excerptLength": {
      "type": "number",
      "default": 55
    },
    "displayFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "featuredImageSizeSlug": {
      "type": "string",
      "default": "medium"
    },
    "addLinkToFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "titleTag": {
      "type": "string",
      "default": "h3"
    },
    "order": {
      "type": "string",
      "default": "desc"
    },
    "orderBy": {
      "type": "string",
      "default": "date"
    },
    "categories": {
      "type": "array",
      "default": []
    },
    "allCategories": {
      "type": "boolean",
      "default": true
    },
    "columns": {
      "type": "number",
      "default": 1
    }
  },
  "textdomain": "my-dynamic-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScript": "file:./view.js"
}

edit.js

edit.js を以下のように書き換えます。

追加した属性 columns を分割代入で取得します(37行目)。

インスペクターパネルに RangeControl コンポーネントを追加して columns の値を設定できるようにします。値は 1-5 の範囲としています。(172-178行目)。

投稿リストのラッパー要素に属性 columns の値を使ってカラム数を表す columns-カラム数 というクラスを追加します。属性 columns のデフォルトは1ですが、念の為、columns が undefined や 0 の場合は1になるようにしています(251行目)。

import { __ } from "@wordpress/i18n";
import {
  useBlockProps,
  RichText,
  InspectorControls,
} from "@wordpress/block-editor";
import {
  PanelBody,
  PanelRow,
  QueryControls,
  SelectControl,
  ToggleControl,
  RangeControl,
} from "@wordpress/components";
import { useSelect, useDispatch } from "@wordpress/data";
import { dateI18n, format, getSettings } from "@wordpress/date";
import "./editor.scss";

export default function Edit({ attributes, setAttributes }) {
  const {
    displayHeading,
    headingTag,
    headingText,
    numberOfItems,
    excludeCurrentPost,
    displayDate,
    displayExcerpt,
    excerptLength,
    displayFeaturedImage,
    featuredImageSizeSlug,
    addLinkToFeaturedImage,
    titleTag,
    order,
    orderBy,
    categories,
    allCategories,
    columns,  // 追加
  } = attributes;

  const { imageSizes, categoriesData, posts, } = useSelect(
    (select) => {
      const postId = select("core/editor")?.getCurrentPostId();
      const { getEntityRecords } = select("core");
      const settings = select("core/block-editor").getSettings();
      return {
        categoriesData: getEntityRecords("taxonomy", "category", {
          per_page: -1,
          hide_empty: true
        }) || [],
        posts: getEntityRecords("postType", "post", {
          per_page: numberOfItems,
          _embed: 'wp:featuredmedia',
          order,
          orderby: orderBy,
          exclude: excludeCurrentPost && postId ? [postId] : [],
          categories: allCategories ? undefined : (categories.length > 0 ? categories : undefined),
        }),
        imageSizes: settings.imageSizes,
      };
    },
    [ numberOfItems, excludeCurrentPost, order, orderBy, categories, allCategories ],
  );

  let categoryOptions = [];
  if (categoriesData) {
    categoryOptions = categoriesData.map((cat) => {
      return { label: __(cat.name, "my-dynamic-block"), value: cat.id };
    });
  }

  const pre = "wp-block-wdl-block-my-dynamic-block__";

  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  const headingTagOptions = Array.from({ length: 6 }, (_, i) => ({
    label: `H${i + 1}`,
    value: `h${i + 1}`,
  }));

  const TitleTag = ({ tagName = "h3", children, ...props }) => {
    return React.createElement(tagName, { ...props }, children);
  };

  function getFeaturedImageDetails( post, size ) {
    const image = post._embedded?.['wp:featuredmedia']?.[0];
    return {
      url:
        image?.media_details?.sizes?.[ size ]?.source_url ?? image?.source_url,
      alt: image?.alt_text || (post.title?.rendered ? getTextContent(post.title.rendered) : ""),
    };
  }

  const imageSizeOptions = imageSizes
    .filter( ( { slug } ) => slug !== 'full' )
    .map( ( { name, slug } ) => ( {
      value: slug,
      label: name,
    } ) );

  const { createWarningNotice } = useDispatch( 'core/notices' );

  const showRedirectionPreventedNotice = ( event ) => {
    event.preventDefault();
    createWarningNotice( __( "Links are disabled in the editor.", "my-dynamic-block" ), {
      type: 'snackbar',
    } );
  };

  return (
    <>
      <InspectorControls>
        <PanelBody title={__("Heading Settings", "my-dynamic-block")}>
          <ToggleControl
            label={__("Display Heading", "my-dynamic-block")}
            checked={displayHeading}
            onChange={() => setAttributes({ displayHeading: !displayHeading })}
          />
          {displayHeading && (
            <SelectControl
              label={__("Heading Tag", "my-dynamic-block")}
              onChange={(value) => setAttributes({ headingTag: value })}
              value={headingTag}
              options={headingTagOptions}
            />
          )}
        </PanelBody>
        <PanelBody title={__("Sorting and filtering", "my-dynamic-block")}>
          <QueryControls
            numberOfItems={numberOfItems}
            onNumberOfItemsChange={(value) =>
              setAttributes({ numberOfItems: value })
            }
            minItems={1}
            maxItems={10}
            {...{ order, orderBy }}
            onOrderChange={(value) => setAttributes({ order: value })}
            onOrderByChange={(value) => setAttributes({ orderBy: value })}
          />
        </PanelBody>
        <PanelBody title={__("Featured image", "my-dynamic-block")}>
          <ToggleControl
            label={__("Display featured image", "my-dynamic-block")}
            checked={displayFeaturedImage}
            onChange={() =>
              setAttributes({ displayFeaturedImage: !displayFeaturedImage })
            }
          />
          {displayFeaturedImage && (
            <>
              <SelectControl
                label={__("Resolution", "my-dynamic-block")}
                onChange={(value) => setAttributes({ featuredImageSizeSlug: value })}
                value={featuredImageSizeSlug}
                options={imageSizeOptions}
              />
              <ToggleControl
                label={ __( 'Add link to featured image', "my-dynamic-block") }
                checked={ addLinkToFeaturedImage }
                onChange={ ( value ) =>
                  setAttributes( { addLinkToFeaturedImage: value } )
                }
              />
            </>
          )}
        </PanelBody>
        <PanelBody title={__("Content Settings", "my-dynamic-block")}>
          <RangeControl
            label={__("Number of columns", "my-dynamic-block")}
            value={columns}
            onChange={(value) => setAttributes({ columns: value })}
            min={1}
            max={5}
          />
          <PanelRow>
            <ToggleControl
              label={__("Display Date", "my-dynamic-block")}
              checked={displayDate}
              onChange={() => setAttributes({ displayDate: !displayDate })}
            />
          </PanelRow>
          <PanelRow>
            <ToggleControl
              label={__("Display Excerpt", "my-dynamic-block")}
              checked={displayExcerpt}
              onChange={() =>
                setAttributes({ displayExcerpt: !displayExcerpt })
              }
            />
          </PanelRow>
          {displayExcerpt && (
            <RangeControl
              label={__("Max number of words", "my-dynamic-block")}
              value={excerptLength}
              onChange={(value) => setAttributes({ excerptLength: value })}
              min={10}
              max={100}
            />
          )}
          <PanelRow>
            <ToggleControl
              label={__("Exclude Current Post", "my-dynamic-block")}
              checked={excludeCurrentPost}
              onChange={() =>
                setAttributes({ excludeCurrentPost: !excludeCurrentPost })
              }
            />
          </PanelRow>
          <SelectControl
            label={__("Title Tag", "my-dynamic-block")}
            onChange={(value) => setAttributes({ titleTag: value })}
            value={titleTag}
            options={headingTagOptions}
          />
        </PanelBody>
        <PanelBody title={__("Category Selection", "my-dynamic-block")}>
          <ToggleControl
            label={__("All Categories", "my-dynamic-block")}
            checked={allCategories}
            onChange={() => setAttributes({ allCategories: !allCategories })}
          />
          {!allCategories && (
            <SelectControl
              multiple
              label={__("Select Categories", "my-dynamic-block")}
              onChange={(value) => setAttributes({ categories: (value || []).map(Number) })}
              value={Array.isArray(categories) ? categories : []}
              options={categoryOptions}
            />
          )}
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        {displayHeading && (
          <RichText
            tagName={headingTag}
            onChange={(value) => setAttributes({ headingText: value })}
            value={headingText}
            className={`${pre}heading`}
            placeholder={__("Enter heading text", "my-dynamic-block")}
          />
        )}
        {Array.isArray(posts) && posts.length === 0 && (
          <p>No post for the selected category.</p>
        )}

        <div className={`${pre}post-items columns-${columns > 0 ? columns : 1}`}>
          {posts &&
            posts.map((post) => {
              const title = getTextContent(post.title?.rendered);
              const excerpt = getTextContent(post.excerpt?.rendered)?.slice(0, excerptLength) + "...";
              const { url: featuredMedia, alt: altText } = getFeaturedImageDetails( post, featuredImageSizeSlug );
              const renderFeaturedImage = displayFeaturedImage && featuredMedia;
              const featuredImage = renderFeaturedImage && (
                <img src={ featuredMedia } alt={ altText } />
              );
              return (
                <div key={post.id} className={`${pre}post-item`}>
                  {renderFeaturedImage && (
                    <div className={`${pre}featured-image`}>
                      { addLinkToFeaturedImage ? (
                        <a href={post.link} onClick={ showRedirectionPreventedNotice}>
                          { featuredImage }
                        </a>
                      ) :  (
                        featuredImage
                      )  }
                    </div>
                  )}
                  {title && (
                    <TitleTag tagName={titleTag} className={`${pre}post-title`}>
                      <a href={post.link} onClick={ showRedirectionPreventedNotice}>
                        {title}
                      </a>
                    </TitleTag>
                  )}
                  {displayDate && post.date_gmt && (
                    <time dateTime={format("c", post.date_gmt)} className={`${pre}post-date`}>
                      {dateI18n(getSettings().formats.date, post.date_gmt)}
                    </time>
                  )}
                  {displayExcerpt && post.excerpt?.rendered && (
                    <p className={`${pre}post-excerpt`}>{excerpt}</p>
                  )}
                </div>
              );
            })}
        </div>
      </div>
    </>
  );
}

render.php

render.php では、以下のように $attributes['columns'] の値が整数であることを確認し(整数でない場合は 1 にして)、投稿リストのラッパー要素にクラスを追加します。

// $attributes['columns'] のバリデーション
$columns = isset($attributes['columns']) && is_numeric($attributes['columns']) ? (int) $attributes['columns'] : 1;

// $columns の値を使ってクラスを追加
$post_items_markup .= sprintf(
  '<div class="%1$spost-items columns-%2$d">',
  esc_attr($pre),
  esc_attr($columns)
);

以下は render.php 全体です(変更部分は43-50行目)。

<?php

global $post;

$args = array(
  'posts_per_page'      => $attributes['numberOfItems'],
  'post_status'         => 'publish',
  'order'               => $attributes['order'],
  'orderby'             => $attributes['orderBy'],
  'ignore_sticky_posts' => true,
  'no_found_rows'       => true,
);

if (empty($attributes['allCategories']) && !empty($attributes['categories']) && is_array($attributes['categories'])) {
  $args['category__in'] = array_map('intval', $attributes['categories']);
}

$current_post_id = get_the_ID();

if (isset($attributes['excludeCurrentPost']) && $attributes['excludeCurrentPost'] && $current_post_id) {
  $args['post__not_in'] = [(int) $current_post_id];
}

$query = new WP_Query($args);

if (isset($attributes['displayFeaturedImage']) && $attributes['displayFeaturedImage'] && $query->have_posts()) {
  update_post_thumbnail_cache($query);
}

$pre = 'wp-block-wdl-block-my-dynamic-block__';

$post_items_markup = '';

if (!empty($attributes['displayHeading']) && !empty($attributes['headingText']) && !empty($attributes['headingTag'])) {
  $post_items_markup .= sprintf(
    '<%1$s class="%2$sheading">%3$s</%1$s>',
    esc_attr($attributes['headingTag']),
    esc_attr($pre),
    esc_html($attributes['headingText'])
  );
}

// $attributes['columns'] のバリデーション
$columns = isset($attributes['columns']) && is_numeric($attributes['columns']) ? (int) $attributes['columns'] : 1;
// $columns の値を使ってクラスを追加
$post_items_markup .= sprintf(
  '<div class="%1$spost-items columns-%2$d">',
  esc_attr($pre),
  esc_attr($columns)
);

if ($query->have_posts()) {
  while ($query->have_posts()) {
    $query->the_post();
    $post_link = esc_url(get_permalink());
    $title     = wp_strip_all_tags(get_the_title());

    $post_items_markup .= sprintf(
      '<div class="%1$spost-item">',
      esc_attr($pre)
    );

    if (isset($attributes['displayFeaturedImage']) && $attributes['displayFeaturedImage'] && has_post_thumbnail()) {

      $thumbnail_alt_value = get_post_meta(get_post_thumbnail_id(), '_wp_attachment_image_alt', true);

      if (!$thumbnail_alt_value) {
        $thumbnail_alt_value = $title;
      }

      $featured_image = get_the_post_thumbnail(
        get_the_ID(),
        $attributes['featuredImageSizeSlug'],
        array(
          'alt'   => esc_attr($thumbnail_alt_value),
        )
      );

      if (!empty($attributes['addLinkToFeaturedImage'])) {
        $featured_image = sprintf(
          '<a href="%1$s" aria-label="%2$s">%3$s</a>',
          esc_url($post_link),
          esc_attr($title),
          $featured_image
        );
      }

      $post_items_markup .= sprintf(
        '<div class="%1$sfeatured-image">%2$s</div>',
        esc_attr($pre),
        $featured_image
      );
    }

    $post_items_markup .= sprintf(
      '<%1$s class="%2$spost-title"><a href="%3$s">%4$s</a></%1$s>',
      $attributes['titleTag'],
      esc_attr($pre),
      esc_url($post_link),
      $title
    );

    if (isset($attributes['displayDate']) && $attributes['displayDate']) {
      $post_items_markup .= sprintf(
        '<time datetime="%1$s" class="%2$spost-date">%3$s</time>',
        esc_attr(get_the_date('c')),
        esc_attr($pre),
        esc_html(get_the_date())
      );
    }

    if (isset($attributes['displayExcerpt']) && $attributes['displayExcerpt'] && isset($attributes['excerptLength']) && $attributes['excerptLength']) {
      $post_items_markup .= sprintf(
        '<p class="%1$spost-excerpt">%2$s</p>',
        esc_attr($pre),
        wp_trim_words(wp_strip_all_tags(get_the_excerpt()), (int) $attributes['excerptLength'], '...')
      );
    }
    $post_items_markup .= "</div>\n";
  }
} else {
  $post_items_markup .= '<p>' . esc_html__('No posts to display.', 'my-dynamic-block') . '</p>';
}

$post_items_markup .= "</div>\n";

wp_reset_postdata();

$wrapper_attributes = get_block_wrapper_attributes();

$allowed_tags = wp_kses_allowed_html('post');
$allowed_tags['img']['srcset'] = true;
$allowed_tags['img']['sizes'] = true;

printf(
  '<div %1$s>%2$s</div>',
  $wrapper_attributes,
  wp_kses($post_items_markup, $allowed_tags)
);

style.scss

style.scss を以下のように書き換えます。

投稿のラッパー要素(.wp-block-wdl-block-my-dynamic-block__post-items)に display: grid を指定してグリッドレイアウトにし、デフォルトの .columns-1 は常に1カラムにします。

17-22行目の $grid-breakpoints は Sassのマップ(辞書型)で、ブレークポイントがキーで、そのサイズで適用するカラム数が値になっています。

25-29行目の @for で .columns-2 〜 .columns-5 のデフォルトを grid-template-columns: repeat(1, 1fr) で1カラムとしています。

32行目の @each でマップのキーと値を取得してメディアクエリを生成しています(詳細は後述)。

48-57行目では、2カラム以上(カラム表示する場合)の共通スタイルとして、画像の高さが一定になるように、img 要素に aspect-ratio を設定し、object-fit: cover を指定しています。

 .wp-block-wdl-block-my-dynamic-block {
  background-color: #21759b;
  color: #fff;
  padding: 2px;
  box-sizing: border-box;

  .wp-block-wdl-block-my-dynamic-block__post-items {
    display: grid;
    gap: 1rem;

    // columns-1 は常に 1 カラム
    &.columns-1 {
      grid-template-columns: repeat(1, 1fr);
    }

    // ブレークポイント設定のマップ(キー:ブレークポイント、値:カラム数)
    $grid-breakpoints: (
      576px: 2,
      768px: 3,
      992px: 4,
      1200px: 5
    );

    // 2〜5カラムのデフォルトを 1カラムに
    @for $i from 2 through 5 {
      &.columns-#{$i} {
        grid-template-columns: repeat(1, 1fr);
      }
    }

    // メディアクエリでカラム数を調整
    @each $bp, $cols in $grid-breakpoints {
      @media (min-width: $bp) {
        @for $i from $cols through 5 {
          &.columns-#{$i} {
            grid-template-columns: repeat($cols, 1fr);
          }
        }
      }
    }

    // 小さい画面では gap を小さく
    @media (max-width: 768px) {
      gap: 0.5rem;
    }

    // 2カラム以上の共通画像スタイル
    &.columns-2,
    &.columns-3,
    &.columns-4,
    &.columns-5 {
      .wp-block-wdl-block-my-dynamic-block__featured-image img {
        object-fit: cover;
        aspect-ratio: 16 / 10;
        width: 100%;
      }
    }

    // 画像のデフォルトスタイル
    .wp-block-wdl-block-my-dynamic-block__featured-image img {
      max-width: 100%;
      height: auto;
    }
  }
}

@each

@each は Sass のループで、リストやマップから要素を1つずつ取り出して処理するときに使います。

@each $変数 in リスト {
  // 繰り返したい処理
}

@each $キー, $値 in マップ {
  // 繰り返したい処理
}

以下の場合、$bp には $grid-breakpoints のキー(576px, 768px, …)が、$cols には $grid-breakpoints の値(2, 3, 4, 5)が入ります。

2行目の @media (min-width: $bp) で各ブレークポイントごとに @media (min-width: 576px), @media (min-width: 768px), … というスタイルを作成します。

3行目の @for $i from $cols through 5 で現在のカラム数 ($cols) から 5 まで繰り返して、.columns-2 〜 .columns-5 を生成しています。

@each $bp, $cols in $grid-breakpoints {
  @media (min-width: $bp) {
    @for $i from $cols through 5 {
      &.columns-#{$i} {
        grid-template-columns: repeat($cols, 1fr);
      }
    }
  }
}

上記の SASS をコンパイルすると、以下のような CSS が生成されます(実際の style.scss は build の style-index.css にコンパイルされます)。

/* 576px以上で 2カラム適用 ($bp が 576px、$cols が 2) */
@media (min-width: 576px) {
  .columns-2 {
    grid-template-columns: repeat(2, 1fr);
  }
  .columns-3 {
    grid-template-columns: repeat(2, 1fr);
  }
  .columns-4 {
    grid-template-columns: repeat(2, 1fr);
  }
  .columns-5 {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* 768px以上で 3カラム適用 ($bp が 768px、$cols が 3) */
@media (min-width: 768px) {
  .columns-3 {
    grid-template-columns: repeat(3, 1fr);
  }
  .columns-4 {
    grid-template-columns: repeat(3, 1fr);
  }
  .columns-5 {
    grid-template-columns: repeat(3, 1fr);
  }
}

/* 992px以上で 4カラム適用 ($bp が 992px、$cols が 4)*/
@media (min-width: 992px) {
  .columns-4 {
    grid-template-columns: repeat(4, 1fr);
  }
  .columns-5 {
    grid-template-columns: repeat(4, 1fr);
  }
}

/* 1200px以上で 5カラム適用 ($bp が 1200px、$cols が 5) */
@media (min-width: 1200px) {
  .columns-5 {
    grid-template-columns: repeat(5, 1fr);
  }
}

関連ページ:CSS Grid を使ったレスポンシブ マルチカラム レイアウト

QueryControls のカテゴリー

これまでの例では、投稿カテゴリーの選択は SelectControl コンポーネントを使っていますが、QueryControls コンポーネントのカテゴリー選択機能を利用する方法もあります。

以下は QueryControls コンポーネントのカテゴリー選択を使うように書き換える例です。

block.json

block.json では、これまで使用していた全てのカテゴリーの投稿を表示するかどうかの真偽値を保存する属性 allCategories を削除し、属性 categories を以下のように変更します。

items: { "type": "object" } は配列の各要素がオブジェクト(object)であることを意味します。つまり、categories はオブジェクトの配列になります。

これまでの属性 categories はカテゴリーID(整数)の配列でしたが、この categories は id や value をキーに持つカテゴリーを表すオブジェクトの配列になります。

// allCategories を削除し、categories を以下に変更
"categories": {
  "type": "array",
  "items": {
    "type": "object"
  }
},
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl-block/my-dynamic-block",
  "version": "0.1.0",
  "title": "My Dynamic Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false,
    "align": true,
    "color": {
      "gradients": true,
      "link": true
    },
    "spacing": {
      "margin": true,
      "padding": true
    },
    "typography": {
      "fontSize": true,
      "lineHeight": true
    }
  },
  "attributes": {
    "displayHeading": {
      "type": "boolean",
      "default": true
    },
    "headingTag": {
      "type": "string",
      "default": "h2"
    },
    "headingText": {
      "type": "string",
      "default": ""
    },
    "numberOfItems": {
      "type": "number",
      "default": 5
    },
    "excludeCurrentPost": {
      "type": "boolean",
      "default": true
    },
    "displayDate": {
      "type": "boolean",
      "default": true
    },
    "displayExcerpt": {
      "type": "boolean",
      "default": true
    },
    "excerptLength": {
      "type": "number",
      "default": 55
    },
    "displayFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "featuredImageSizeSlug": {
      "type": "string",
      "default": "medium"
    },
    "featuredImageSizeWidth": {
      "type": "number",
      "default": null
    },
    "featuredImageSizeHeight": {
      "type": "number",
      "default": null
    },
    "featuredImageMaxStyleAdded": {
      "type": "boolean",
      "default": false
    },
    "addLinkToFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "titleTag": {
      "type": "string",
      "default": "h3"
    },
    "order": {
      "type": "string",
      "default": "desc"
    },
    "orderBy": {
      "type": "string",
      "default": "date"
    },
    "categories": {
      "type": "array",
      "items": {
        "type": "object"
      }
    },
    "columns": {
      "type": "number",
      "default": 1
    }
  },
  "textdomain": "my-dynamic-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScript": "file:./view.js"
}

edit.js

block.json で削除した属性を格納していた変数や、カテゴリー選択用の SelectControl コンポーネントなどは不要になるので、これまでの edit.js のコードから以下を削除します。

  • allCategories: 属性 allCategories を格納した変数
  • categoryOptions: カテゴリー選択用の SelectControl の options に指定するカテゴリーのリスト
  • カテゴリー選択用の PanelBody、ToggleControl、SelectControl コンポーネント

以下が変更後の edit.js です。

import { __ } from "@wordpress/i18n";
import {
  useBlockProps,
  RichText,
  InspectorControls,
  __experimentalImageSizeControl as ImageSizeControl,
} from "@wordpress/block-editor";
import {
  PanelBody,
  PanelRow,
  QueryControls,
  SelectControl,
  ToggleControl,
  RangeControl,
} from "@wordpress/components";
import { useSelect, useDispatch } from "@wordpress/data";
import { dateI18n, format, getSettings } from "@wordpress/date";
import "./editor.scss";

export default function Edit({ attributes, setAttributes }) {
  const {
    displayHeading,
    headingTag,
    headingText,
    numberOfItems,
    excludeCurrentPost,
    displayDate,
    displayExcerpt,
    excerptLength,
    displayFeaturedImage,
    featuredImageSizeSlug,
    featuredImageSizeWidth,
    featuredImageSizeHeight,
    featuredImageMaxStyleAdded,
    addLinkToFeaturedImage,
    titleTag,
    order,
    orderBy,
    categories,
    columns,
  } = attributes;

  const {
    imageSizes,
    categoriesData,
    posts,
    defaultImageWidth,
    defaultImageHeight,
  } = useSelect(
    (select) => {
      const postId = select("core/editor")?.getCurrentPostId();
      const { getEntityRecords } = select("core");
      const settings = select("core/block-editor").getSettings();

      // カテゴリーIDの配列を作成(categories は現在選択中のカテゴリーオブジェクトの配列)
      const catIds =
        categories && categories.length > 0
          ? categories.map( ( cat ) => cat.id )
          : [];

      return {
        categoriesData: getEntityRecords("taxonomy", "category", {
          per_page: -1,
          hide_empty: true
        }) || [],
        posts: getEntityRecords("postType", "post", {
          per_page: numberOfItems,
          _embed: 'wp:featuredmedia',
          order,
          orderby: orderBy,
          exclude: excludeCurrentPost && postId ? [postId] : [],
          categories: catIds, // 選択されたカテゴリーに属する投稿を取得
        }),
        imageSizes: settings.imageSizes,
        defaultImageWidth: settings.imageDimensions?.[ featuredImageSizeSlug ]?.width ?? 0,
        defaultImageHeight: settings.imageDimensions?.[ featuredImageSizeSlug ]?.height ?? 0,
      };
    },
    [ numberOfItems, excludeCurrentPost, order, orderBy, categories, featuredImageSizeSlug ],
  );

  const pre = "wp-block-wdl-block-my-dynamic-block__";

  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  const headingTagOptions = Array.from({ length: 6 }, (_, i) => ({
    label: `H${i + 1}`,
    value: `h${i + 1}`,
  }));

  const TitleTag = ({ tagName = "h3", children, ...props }) => {
    return React.createElement(tagName, { ...props }, children);
  };

  function getFeaturedImageDetails( post, size ) {
    const image = post._embedded?.['wp:featuredmedia']?.[0];
    return {
      url:
        image?.media_details?.sizes?.[ size ]?.source_url ?? image?.source_url,
      alt: image?.alt_text || (post.title?.rendered ? getTextContent(post.title.rendered) : ""),
    };
  }

  const imageSizeOptions = imageSizes
    .filter( ( { slug } ) => slug !== 'full' )
    .map( ( { name, slug } ) => ( {
      value: slug,
      label: name,
    } ) );

  const { createWarningNotice } = useDispatch( 'core/notices' );

  const showRedirectionPreventedNotice = ( event ) => {
    event.preventDefault();
    createWarningNotice( __( "Links are disabled in the editor.", "my-dynamic-block" ), {
      type: 'snackbar',
    } );
  };

  // categoriesData(カテゴリーのデータ)からカテゴリー名をキーとするオブジェクトを作成
  const categorySuggestions =
    categoriesData?.reduce(
      (accumulator, category) => ({
        ...accumulator,
        [category.name]: category,
      }),
      {},
    ) ?? {};

    // カテゴリーが変更された際に呼び出されるコールバック関数
  const selectCategories = (tokens) => {
    const hasNoSuggestion = tokens.some(
      (token) => typeof token === "string" && !categorySuggestions[token],
    );
    if (hasNoSuggestion) {
      return;
    }
    const selectedAllCategories = tokens.map((token) => {
      return typeof token === "string" ? categorySuggestions[token] : token;
    });
    if (selectedAllCategories.includes(null)) {
      return false;
    }
    setAttributes({ categories: selectedAllCategories });
  };

  return (
    <>
      <InspectorControls>
        <PanelBody title={__("Heading Settings", "my-dynamic-block")}>
          <ToggleControl
            label={__("Display Heading", "my-dynamic-block")}
            checked={displayHeading}
            onChange={() => setAttributes({ displayHeading: !displayHeading })}
          />
          {displayHeading && (
            <SelectControl
              label={__("Heading Tag", "my-dynamic-block")}
              onChange={(value) => setAttributes({ headingTag: value })}
              value={headingTag}
              options={headingTagOptions}
            />
          )}
        </PanelBody>
        <PanelBody title={__("Sorting and filtering", "my-dynamic-block")}>
          <QueryControls
            numberOfItems={numberOfItems}
            onNumberOfItemsChange={(value) =>
              setAttributes({ numberOfItems: value })
            }
            minItems={1}
            maxItems={10}
            {...{ order, orderBy }}
            onOrderChange={(value) => setAttributes({ order: value })}
            onOrderByChange={(value) => setAttributes({ orderBy: value })}
            // カテゴリーを選択するためプロパティを追加
            categorySuggestions={categorySuggestions}
            onCategoryChange={selectCategories}
            selectedCategories={categories}
          />
        </PanelBody>
        <PanelBody title={__("Featured image", "my-dynamic-block")}>
          <ToggleControl
            label={__("Display featured image", "my-dynamic-block")}
            checked={displayFeaturedImage}
            onChange={() =>
              setAttributes({ displayFeaturedImage: !displayFeaturedImage })
            }
          />
          {displayFeaturedImage && (
            <>
              <ImageSizeControl
                onChange={ ( value ) => {
                  const newAttrs = {};
                  if ( value.hasOwnProperty( 'width' ) ) {
                    newAttrs.featuredImageSizeWidth = value.width;
                  }
                  if ( value.hasOwnProperty( 'height' ) ) {
                    newAttrs.featuredImageSizeHeight = value.height;
                  }
                  newAttrs.featuredImageMaxStyleAdded = !!(value.width || value.height);
                  setAttributes( newAttrs );
                } }
                slug={ featuredImageSizeSlug }
                width={ featuredImageSizeWidth }
                height={ featuredImageSizeHeight }
                imageWidth={ defaultImageWidth }
                imageHeight={ defaultImageHeight }
                imageSizeOptions={ imageSizeOptions }
                imageSizeHelp={ __(
                  'Select the size of the source image.'
                ) }
                onChangeImage={ ( value ) => {
                  console.log(value)
                  setAttributes( {
                    featuredImageSizeSlug: value,
                    featuredImageSizeWidth: undefined,
                    featuredImageSizeHeight: undefined,
                    featuredImageMaxStyleAdded: false
                  } );
                }}
              />
              <ToggleControl
                label={ __( 'Add link to featured image', "my-dynamic-block") }
                checked={ addLinkToFeaturedImage }
                onChange={ ( value ) =>
                  setAttributes( { addLinkToFeaturedImage: value } )
                }
              />
            </>
          )}
        </PanelBody>
        <PanelBody title={__("Content Settings", "my-dynamic-block")}>
          <RangeControl
            label={__("Number of columns", "my-dynamic-block")}
            value={columns}
            onChange={(value) => setAttributes({ columns: value })}
            min={1}
            max={5}
          />
          <PanelRow>
            <ToggleControl
              label={__("Display Date", "my-dynamic-block")}
              checked={displayDate}
              onChange={() => setAttributes({ displayDate: !displayDate })}
            />
          </PanelRow>
          <PanelRow>
            <ToggleControl
              label={__("Display Excerpt", "my-dynamic-block")}
              checked={displayExcerpt}
              onChange={() =>
                setAttributes({ displayExcerpt: !displayExcerpt })
              }
            />
          </PanelRow>
          {displayExcerpt && (
            <RangeControl
              label={__("Max number of words", "my-dynamic-block")}
              value={excerptLength}
              onChange={(value) => setAttributes({ excerptLength: value })}
              min={10}
              max={100}
            />
          )}
          <PanelRow>
            <ToggleControl
              label={__("Exclude Current Post", "my-dynamic-block")}
              checked={excludeCurrentPost}
              onChange={() =>
                setAttributes({ excludeCurrentPost: !excludeCurrentPost })
              }
            />
          </PanelRow>
          <SelectControl
            label={__("Title Tag", "my-dynamic-block")}
            onChange={(value) => setAttributes({ titleTag: value })}
            value={titleTag}
            options={headingTagOptions}
          />
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        {displayHeading && (
          <RichText
            tagName={headingTag}
            onChange={(value) => setAttributes({ headingText: value })}
            value={headingText}
            className={`${pre}heading`}
            placeholder={__("Enter heading text", "my-dynamic-block")}
          />
        )}
        {Array.isArray(posts) && posts.length === 0 && (
          <p>No post for the selected category.</p>
        )}

        <div className={`${pre}post-items columns-${columns > 0 ? columns : 1}`}>
          {posts &&
            posts.map((post) => {
              const title = getTextContent(post.title?.rendered);
              const excerpt = getTextContent(post.excerpt?.rendered)?.slice(0, excerptLength) + "...";
              const { url: featuredMedia, alt: altText } = getFeaturedImageDetails( post, featuredImageSizeSlug );
              const renderFeaturedImage = displayFeaturedImage && featuredMedia;
              const featuredImage = renderFeaturedImage && (
                <img
                  src={ featuredMedia }
                  alt={ altText }
                  style={ {
                    maxWidth: featuredImageSizeWidth ? featuredImageSizeWidth : null,
                    maxHeight: featuredImageSizeHeight ? featuredImageSizeHeight : null,
                  } }
                  className={ featuredImageMaxStyleAdded ? "max-style-added" : null}
                />
              );
              return (
                <div key={post.id} className={`${pre}post-item`}>
                  {renderFeaturedImage && (
                    <div className={`${pre}featured-image`}>
                      { addLinkToFeaturedImage ? (
                        <a href={post.link} onClick={ showRedirectionPreventedNotice}>
                          { featuredImage }
                        </a>
                      ) :  (
                        featuredImage
                      )  }
                    </div>
                  )}
                  {title && (
                    <TitleTag tagName={titleTag} className={`${pre}post-title`}>
                      <a href={post.link} onClick={ showRedirectionPreventedNotice}>
                        {title}
                      </a>
                    </TitleTag>
                  )}
                  {displayDate && post.date_gmt && (
                    <time dateTime={format("c", post.date_gmt)} className={`${pre}post-date`}>
                      {dateI18n(getSettings().formats.date, post.date_gmt)}
                    </time>
                  )}
                  {displayExcerpt && post.excerpt?.rendered && (
                    <p className={`${pre}post-excerpt`}>{excerpt}</p>
                  )}
                </div>
              );
            })}
        </div>
      </div>
    </>
  );
}

カテゴリー選択に関する部分のコードは WordPress コアの「最新の投稿」ブロックのコードを利用しています。以下は変更部分の説明です。

カテゴリーIDの配列を作成(56-59行目)

useSelect フック内で、投稿データを取得する際に getEntityRecords の query パラメータの categories(72行目)に指定するカテゴリーIDの配列 catIds を作成します。

const catIds =
  categories && categories.length > 0
    ? categories.map( ( cat ) => cat.id )
    : [];

上記2行目の属性 categories には現在選択されているカテゴリーオブジェクトの配列が入っていて、コールバック関数 selectCategories で更新されます(selectedAllCategories)。

categorySuggestions の作成(126-133行目)

useSelect フックで取得したカテゴリーのデータ categoriesData(62行目)をもとに、カテゴリーのデータを検索しやすい形に変換したオブジェクト categorySuggestions(カテゴリー名をキーとするカテゴリーのオブジェクト)を作成します。

このオブジェクトは onCategoryChange に指定するコールバック関数 selectCategories で使用します。

const categorySuggestions =
  categoriesData?.reduce(
    (accumulator, category) => ({
      ...accumulator,
      [category.name]: category, // カテゴリー名: カテゴリーオブジェクト
    }),
    {},
  ) ?? {}; // ?? {} は categoriesData が null や undefined の場合に、categorySuggestions を空のオブジェクト {} にします

categoriesData には以下のような getEntityRecords で取得したカテゴリーオブジェクトのリストが入っています(実際には id や name 以外にも slug や link などのプロパティが含まれます)。

const categoriesData = [
  { id: 1, name: "News", ...  },
  { id: 2, name: "Tech", ...  },
  { id: 3, name: "Sports", ...  },
];

categoriesData に Array.reduce() メソッドを使って、カテゴリー名をキー、カテゴリーオブジェクトを値とするオブジェクトに変換しています。

結果として、categorySuggestions は、例えば以下のようなデータ構造のオブジェクトになります。

これにより、categorySuggestions[カテゴリー名] のようにカテゴリーの名前を使って簡単にカテゴリーのオブジェクトにアクセスすることができます。

{
  "News": { id: 1, name: "News", ... },
  "Tech": { id: 2, name: "Tech", ... },
  "Sports": { id: 3, name: "Sports", ... }
}

selectCategories(136-150行目)

selectCategories は onCategoryChange に指定するカテゴリー変更時のコールバック関数です。

tokens には現在選択されているカテゴリー(変更された新しいカテゴリー)が入っています。

以下の4〜9行目は、some() を使って、tokens の中に categorySuggestions に存在しない文字列(無効なカテゴリー名)が含まれているかをチェックし、無効なカテゴリー名があれば hasNoSuggestion は true になるので、処理を中断します。

12〜14行目は setAttributes で属性 categories を更新する際に指定する selectedAllCategories を作成しています。

tokens の中には、既存のカテゴリー(オブジェクト)と、新しく追加されたカテゴリー(文字列)の両方が含まれるので、文字列なら categorySuggestions[token] でオブジェクトに変換します。

これにより、selectedAllCategories は {id: 13, value: 'News'} のようなカテゴリーオブジェクトを要素に持つ配列(選択されたすべてのカテゴリーのオブジェクトの配列)になります。

そして、selectedAllCategories に無効な値が含まれていないかをチェックし、カテゴリーオブジェクトの配列(selectedAllCategories)を setAttributes で categories 属性の値として保存しています。

// カテゴリーが変更された際に呼び出されるコールバック関数
const selectCategories = (tokens) => {
  // tokens の中に categorySuggestions に存在しない文字列が含まれていたら処理を中断
  const hasNoSuggestion = tokens.some(
    (token) => typeof token === "string" && !categorySuggestions[token],
  );
  if (hasNoSuggestion) {
    return;
  }

  // 選択されたカテゴリーをオブジェクトの配列に変換
  const selectedAllCategories = tokens.map((token) => {
    return typeof token === "string" ? categorySuggestions[token] : token;
  });

  // 無効なカテゴリーが含まれていないかチェック
  if (selectedAllCategories.includes(null)) {
    // もし null が含まれていたら(無効なカテゴリーが選択されていたら)、処理を中断
    return false;
  }

  // カテゴリーを保存(更新)
  setAttributes({ categories: selectedAllCategories });
};

QueryControls

QueryControls コンポーネントに categorySuggestions、onCategoryChange、selectedCategories プロパティを追加し、それぞれに上記で定義した categorySuggestions(カテゴリー名と ID のマッピング)、selectCategories(カテゴリー変更時のコールバック関数)、categories(選択されたすべてのカテゴリーオブジェクトの配列)を指定します。

<QueryControls
  numberOfItems={numberOfItems}
  onNumberOfItemsChange={(value) =>
  setAttributes({ numberOfItems: value })
  }
  minItems={1}
  maxItems={10}
  {...{ order, orderBy }}
  onOrderChange={(value) => setAttributes({ order: value })}
  onOrderByChange={(value) => setAttributes({ orderBy: value })}
  categorySuggestions={categorySuggestions}
  onCategoryChange={selectCategories}
  selectedCategories={categories}
/>

インスペクターに以下のようなカテゴリーのコントロールが追加され、複数のカテゴリーを指定することができます。カテゴリー名の最初の2文字を入力すると候補が表示され、選択することができます。

render.php

属性 allCategories を削除し、categories を id をキーに持つオブジェクトの配列に変更したので、クエリのパラメータ category__in の設定部分を以下のように書き換えます。

// categories が空でなく配列であれば、categories から id の値の配列を取得して category__in に設定
if (!empty($attributes['categories']) && is_array($attributes['categories'])) {
  $args['category__in'] = array_map('intval', array_column($attributes['categories'], 'id'));
}

!empty() で属性 categories が空でない(未定義や空の配列でない)かをチェックし、is_array()で categories が配列であることを確認しています。

そして、条件を満たす場合、array_column() を使って categories からカテゴリー ID の配列を取得し、array_map('intval', ...) で配列の全ての要素を整数に変換し、型を確実に整数に統一しています。

array_column() は、配列の特定のキー(id)の値を抜き出した配列を作成します。

render.php の変更は上記のみなので、render.php の全体のコードは以下になります。

<?php

global $post;

$args = array(
  'posts_per_page'      => $attributes['numberOfItems'],
  'post_status'         => 'publish',
  'order'               => $attributes['order'],
  'orderby'             => $attributes['orderBy'],
  'ignore_sticky_posts' => true,
  'no_found_rows'       => true,
);

// categories が空でなく配列であれば、categories(オブジェクトの配列)から id の値を取得して category__in に設定
if (!empty($attributes['categories']) && is_array($attributes['categories'])) {
  $args['category__in'] = array_map('intval', array_column($attributes['categories'], 'id'));
}

$current_post_id = get_the_ID();

if (isset($attributes['excludeCurrentPost']) && $attributes['excludeCurrentPost'] && $current_post_id) {
  $args['post__not_in'] = [(int) $current_post_id];
}

$query = new WP_Query($args);

if (isset($attributes['displayFeaturedImage']) && $attributes['displayFeaturedImage'] && $query->have_posts()) {
  update_post_thumbnail_cache($query);
}

$pre = 'wp-block-wdl-block-my-dynamic-block__';

$post_items_markup = '';

if (!empty($attributes['displayHeading']) && !empty($attributes['headingText']) && !empty($attributes['headingTag'])) {
  $post_items_markup .= sprintf(
    '<%1$s class="%2$sheading">%3$s</%1$s>',
    esc_attr($attributes['headingTag']),
    esc_attr($pre),
    esc_html($attributes['headingText'])
  );
}

$columns = isset($attributes['columns']) && is_numeric($attributes['columns']) ? (int) $attributes['columns'] : 1;
$post_items_markup .= sprintf(
  '<div class="%1$spost-items columns-%2$d">',
  esc_attr($pre),
  esc_attr($columns)
);

if ($query->have_posts()) {
  while ($query->have_posts()) {
    $query->the_post();
    $post_link = esc_url(get_permalink());
    $title     = wp_strip_all_tags(get_the_title());

    $post_items_markup .= sprintf(
      '<div class="%1$spost-item">',
      esc_attr($pre)
    );

    if (isset($attributes['displayFeaturedImage']) && $attributes['displayFeaturedImage'] && has_post_thumbnail()) {

      $image_style = '';
      if (isset($attributes['featuredImageSizeWidth'])) {
        $image_style .= sprintf('max-width:%spx;', $attributes['featuredImageSizeWidth']);
      }
      if (isset($attributes['featuredImageSizeHeight'])) {
        $image_style .= sprintf('max-height:%spx;', $attributes['featuredImageSizeHeight']);
      }

      $image_class = !empty($attributes['featuredImageMaxStyleAdded']) ? 'max-style-added' : '';

      $thumbnail_alt_value = get_post_meta(get_post_thumbnail_id(), '_wp_attachment_image_alt', true);

      if (!$thumbnail_alt_value) {
        $thumbnail_alt_value = $title;
      }

      $featured_image = get_the_post_thumbnail(
        get_the_ID(),
        $attributes['featuredImageSizeSlug'],
        array(
          'style' => esc_attr($image_style),
          'class' => esc_attr($image_class),
          'alt'   => esc_attr($thumbnail_alt_value),
        )
      );

      if (!empty($attributes['addLinkToFeaturedImage'])) {
        $featured_image = sprintf(
          '<a href="%1$s" aria-label="%2$s">%3$s</a>',
          esc_url($post_link),
          esc_attr($title),
          $featured_image
        );
      }

      $post_items_markup .= sprintf(
        '<div class="%1$sfeatured-image">%2$s</div>',
        esc_attr($pre),
        $featured_image
      );
    }

    $post_items_markup .= sprintf(
      '<%1$s class="%2$spost-title"><a href="%3$s">%4$s</a></%1$s>',
      $attributes['titleTag'],
      esc_attr($pre),
      esc_url($post_link),
      $title
    );

    if (isset($attributes['displayDate']) && $attributes['displayDate']) {
      $post_items_markup .= sprintf(
        '<time datetime="%1$s" class="%2$spost-date">%3$s</time>',
        esc_attr(get_the_date('c')),
        esc_attr($pre),
        esc_html(get_the_date())
      );
    }

    if (isset($attributes['displayExcerpt']) && $attributes['displayExcerpt'] && isset($attributes['excerptLength']) && $attributes['excerptLength']) {
      $post_items_markup .= sprintf(
        '<p class="%1$spost-excerpt">%2$s</p>',
        esc_attr($pre),
        wp_trim_words(wp_strip_all_tags(get_the_excerpt()), (int) $attributes['excerptLength'], '...')
      );
    }
    $post_items_markup .= "</div>\n";
  }
} else {
  $post_items_markup .= '<p>' . esc_html__('No posts to display.', 'my-dynamic-block') . '</p>';
}

$post_items_markup .= "</div>\n";

wp_reset_postdata();

$wrapper_attributes = get_block_wrapper_attributes();

$allowed_tags = wp_kses_allowed_html('post');
$allowed_tags['img']['srcset'] = true;
$allowed_tags['img']['sizes'] = true;

printf(
  '<div %1$s>%2$s</div>',
  $wrapper_attributes,
  wp_kses($post_items_markup, $allowed_tags)
);

ServerSideRender

ServerSideRender は、サーバーサイドで処理した結果(ブロックのプレビュー)をエディターで表示するために使用するコンポーネントです。

但し、ServerSideRender はあくまで旧来の仕組みであり、新しい機能開発には適していないため、現在は ServerSideRender を新しく開発するブロックに使うのは推奨されていません(非推奨)。

ServerSideRender のデメリットとしては以下があります。

  • エディター上でプレビューしか表示できない
  • レンダリングのたびにサーバーリクエストが発生する

これまでのコードを ServerSideRender を使って書き換える必要(メリット)はありませんが、以下は参考までに、ServerSideRender を使って書き換える例です。

block.json

ServerSideRender に渡す attributes(属性)が undefined や null になっていると「ブロック読み込みエラー: 無効なパラメータ: attributes」というエラーが発生し、以下のようにブロックが表示されません。

そのため、この例の場合、block.json の以下の属性のデフォルト値を変更します。

"featuredImageSizeWidth": {
  "type": "number",
  "default": null
},
"featuredImageSizeHeight": {
  "type": "number",
  "default": null
},

...

"categories": {
  "type": "array",
  "items": {
    "type": "object"
  }
}

以下のように null を 0 に変更し、categories には default として空のオブジェクトを追加します。

"featuredImageSizeWidth": {
  "type": "number",
  "default": 0
},
"featuredImageSizeHeight": {
  "type": "number",
  "default": 0
},

...

"categories": {
  "type": "array",
  "items": {
    "type": "object"
  },
  "default": {}
}
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl-block/my-dynamic-block",
  "version": "0.1.0",
  "title": "My Dynamic Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false,
    "align": true,
    "color": {
      "gradients": true,
      "link": true
    },
    "spacing": {
      "margin": true,
      "padding": true
    },
    "typography": {
      "fontSize": true,
      "lineHeight": true
    }
  },
  "attributes": {
    "displayHeading": {
      "type": "boolean",
      "default": true
    },
    "headingTag": {
      "type": "string",
      "default": "h2"
    },
    "headingText": {
      "type": "string",
      "default": ""
    },
    "numberOfItems": {
      "type": "number",
      "default": 5
    },
    "excludeCurrentPost": {
      "type": "boolean",
      "default": true
    },
    "displayDate": {
      "type": "boolean",
      "default": true
    },
    "displayExcerpt": {
      "type": "boolean",
      "default": true
    },
    "excerptLength": {
      "type": "number",
      "default": 55
    },
    "displayFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "featuredImageSizeSlug": {
      "type": "string",
      "default": "medium"
    },
    "featuredImageSizeWidth": {
      "type": "number",
      "default": 0
    },
    "featuredImageSizeHeight": {
      "type": "number",
      "default": 0
    },
    "featuredImageMaxStyleAdded": {
      "type": "boolean",
      "default": false
    },
    "addLinkToFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "titleTag": {
      "type": "string",
      "default": "h3"
    },
    "order": {
      "type": "string",
      "default": "desc"
    },
    "orderBy": {
      "type": "string",
      "default": "date"
    },
    "categories": {
      "type": "array",
      "items": {
        "type": "object"
      },
      "default": {}
    },
    "columns": {
      "type": "number",
      "default": 1
    }
  },
  "textdomain": "my-dynamic-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScript": "file:./view.js"
}

edit.js

edit.js で ServerSideRender を使うには ServerSideRender コンポーネントをインポートし、コンテンツのレンダリング部分を ServerSideRender コンポーネントに置き換えます。

ServerSideRender の block 属性にはブロック名を指定し、attributes 属性には PHP に渡す属性を指定します。

...
// ServerSideRender コンポーネントをインポート
import ServerSideRender from '@wordpress/server-side-render';

export default function Edit({ attributes, setAttributes }) {
  return (
    <>
      <InspectorControls>
          ...
      </InspectorControls>
      <div {...useBlockProps()}>
        <ServerSideRender
          block="wdl-block/my-dynamic-block" // ブロック名
          attributes={attributes} // PHP に渡す属性→ Edit() で分割代入して取得した attributes
        />
      </div>
    </>
  );
}

ブロック名は block.json の name フィールドに指定されている値になります。

または、以下のように Edit() の引数で分割代入で name プロパティを取得して指定することができます。

// 分割代入で name プロパティを取得
export default function Edit({ attributes, setAttributes, name }) {
  return (
    <>
      <InspectorControls>
          ...
      </InspectorControls>
      <div {...useBlockProps()}>
        <ServerSideRender
          block={name}
          attributes={attributes}
        />
      </div>
    </>
  );
}
            

この例の場合、ブロック全体の見出しを RichText コンポーネントで作成していますが、ServerSideRender はエディター上でプレビューしか表示できないため、代わりに TextControl コンポーネントを使ってインスペクターパネルで見出しのテキストを入力するように変更します。

コンテンツ部分のレンダリングに使用しているコードは不要なので削除し、リンクを誤ってクリックしないように ServerSideRender を Disabled でラップします。

以下は変更後の edit.js です。

import { __ } from "@wordpress/i18n";
import {
  useBlockProps,
  InspectorControls,
  __experimentalImageSizeControl as ImageSizeControl,
} from "@wordpress/block-editor";
import {
  PanelBody,
  PanelRow,
  QueryControls,
  SelectControl,
  ToggleControl,
  RangeControl,
  TextControl, // 追加
} from "@wordpress/components";
// ServerSideRender コンポーネントをインポート
import ServerSideRender from "@wordpress/server-side-render";
import { useSelect } from "@wordpress/data";
// Disabled コンポーネントをインポート
import { Disabled } from "@wordpress/components";
import "./editor.scss";

// name を分割代入で取得
export default function Edit({ attributes, setAttributes, name }) {
  const {
    displayHeading,
    headingTag,
    headingText,
    numberOfItems,
    excludeCurrentPost,
    displayDate,
    displayExcerpt,
    excerptLength,
    displayFeaturedImage,
    featuredImageSizeSlug,
    featuredImageSizeWidth,
    featuredImageSizeHeight,
    addLinkToFeaturedImage,
    titleTag,
    order,
    orderBy,
    categories,
    columns,
  } = attributes;

  const {
    imageSizes,
    categoriesData,
    defaultImageWidth,
    defaultImageHeight,
  } = useSelect(
    (select) => {
      const { getEntityRecords } = select("core");
      const settings = select("core/block-editor").getSettings();

      return {
        categoriesData: getEntityRecords("taxonomy", "category", {
          per_page: -1,
          hide_empty: true
        }) || [],
        imageSizes: settings.imageSizes,
        defaultImageWidth: settings.imageDimensions?.[ featuredImageSizeSlug ]?.width ?? 0,
        defaultImageHeight: settings.imageDimensions?.[ featuredImageSizeSlug ]?.height ?? 0,
      };
    },
    [ numberOfItems, excludeCurrentPost, order, orderBy, categories, featuredImageSizeSlug ],
  );

  const headingTagOptions = Array.from({ length: 6 }, (_, i) => ({
    label: `H${i + 1}`,
    value: `h${i + 1}`,
  }));

  const imageSizeOptions = imageSizes
    .filter( ( { slug } ) => slug !== 'full' )
    .map( ( { name, slug } ) => ( {
      value: slug,
      label: name,
    } ) );

  const categorySuggestions =
    categoriesData?.reduce(
      (accumulator, category) => ({
        ...accumulator,
        [category.name]: category,
      }),
      {},
    ) ?? {};

  const selectCategories = (tokens) => {
    const hasNoSuggestion = tokens.some(
      (token) => typeof token === "string" && !categorySuggestions[token],
    );
    if (hasNoSuggestion) {
      return;
    }
    const selectedAllCategories = tokens.map((token) => {
      return typeof token === "string" ? categorySuggestions[token] : token;
    });
    if (selectedAllCategories.includes(null)) {
      return false;
    }
    setAttributes({ categories: selectedAllCategories });
  };

  return (
    <>
      <InspectorControls>
        <PanelBody title={__("Heading Settings", "my-dynamic-block")}>
          <ToggleControl
            label={__("Display Heading", "my-dynamic-block")}
            checked={displayHeading}
            onChange={() => setAttributes({ displayHeading: !displayHeading })}
          />
          {displayHeading && (
            <>
              <TextControl
                label={ __( 'Heading Text', 'my-dynamic-block' ) }
                value={ headingText }
                onChange={(value) => setAttributes({ headingText: value})}
                help={ __(
                  'Text to display above the posts',
                  'my-dynamic-block'
                ) }
              />
              <SelectControl
                label={__("Heading Tag", "my-dynamic-block")}
                onChange={(value) => setAttributes({ headingTag: value })}
                value={headingTag}
                options={headingTagOptions}
              />
            </>
          )}

        </PanelBody>
        <PanelBody title={__("Sorting and filtering", "my-dynamic-block")}>
          <QueryControls
            numberOfItems={numberOfItems}
            onNumberOfItemsChange={(value) =>
              setAttributes({ numberOfItems: value })
            }
            minItems={1}
            maxItems={10}
            {...{ order, orderBy }}
            onOrderChange={(value) => setAttributes({ order: value })}
            onOrderByChange={(value) => setAttributes({ orderBy: value })}
            categorySuggestions={categorySuggestions}
            onCategoryChange={selectCategories}
            selectedCategories={categories}
          />
        </PanelBody>
        <PanelBody title={__("Featured image", "my-dynamic-block")}>
          <ToggleControl
            label={__("Display featured image", "my-dynamic-block")}
            checked={displayFeaturedImage}
            onChange={() =>
              setAttributes({ displayFeaturedImage: !displayFeaturedImage })
            }
          />
          {displayFeaturedImage && (
            <>
              <ImageSizeControl
                onChange={ ( value ) => {
                  const newAttrs = {};
                  if ( value.hasOwnProperty( 'width' ) ) {
                    newAttrs.featuredImageSizeWidth = value.width;
                  }
                  if ( value.hasOwnProperty( 'height' ) ) {
                    newAttrs.featuredImageSizeHeight = value.height;
                  }
                  newAttrs.featuredImageMaxStyleAdded = !!(value.width || value.height);
                  setAttributes( newAttrs );
                } }
                slug={ featuredImageSizeSlug }
                width={ featuredImageSizeWidth }
                height={ featuredImageSizeHeight }
                imageWidth={ defaultImageWidth }
                imageHeight={ defaultImageHeight }
                imageSizeOptions={ imageSizeOptions }
                imageSizeHelp={ __(
                  'Select the size of the source image.'
                ) }
                onChangeImage={ ( value ) => {
                  console.log(value)
                  setAttributes( {
                    featuredImageSizeSlug: value,
                    featuredImageSizeWidth: undefined,
                    featuredImageSizeHeight: undefined,
                    featuredImageMaxStyleAdded: false
                  } );
                }}
              />
              <ToggleControl
                label={ __( 'Add link to featured image', "my-dynamic-block") }
                checked={ addLinkToFeaturedImage }
                onChange={ ( value ) =>
                  setAttributes( { addLinkToFeaturedImage: value } )
                }
              />
            </>
          )}
        </PanelBody>
        <PanelBody title={__("Content Settings", "my-dynamic-block")}>
          <RangeControl
            label={__("Number of columns", "my-dynamic-block")}
            value={columns}
            onChange={(value) => setAttributes({ columns: value })}
            min={1}
            max={5}
          />
          <PanelRow>
            <ToggleControl
              label={__("Display Date", "my-dynamic-block")}
              checked={displayDate}
              onChange={() => setAttributes({ displayDate: !displayDate })}
            />
          </PanelRow>
          <PanelRow>
            <ToggleControl
              label={__("Display Excerpt", "my-dynamic-block")}
              checked={displayExcerpt}
              onChange={() =>
                setAttributes({ displayExcerpt: !displayExcerpt })
              }
            />
          </PanelRow>
          {displayExcerpt && (
            <RangeControl
              label={__("Max number of words", "my-dynamic-block")}
              value={excerptLength}
              onChange={(value) => setAttributes({ excerptLength: value })}
              min={10}
              max={100}
            />
          )}
          <PanelRow>
            <ToggleControl
              label={__("Exclude Current Post", "my-dynamic-block")}
              checked={excludeCurrentPost}
              onChange={() =>
                setAttributes({ excludeCurrentPost: !excludeCurrentPost })
              }
            />
          </PanelRow>
          <SelectControl
            label={__("Title Tag", "my-dynamic-block")}
            onChange={(value) => setAttributes({ titleTag: value })}
            value={titleTag}
            options={headingTagOptions}
          />
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        <Disabled>
          <ServerSideRender
            block={name}
            attributes={attributes}
          />
        </Disabled>
      </div>
    </>
  );
}

render.php

属性 featuredImageSizeWidth と featuredImageSizeHeight のデフォルト値を 0 に変更したので、$attributes の featuredImageSizeWidth と featuredImageSizeHeight の判定部分を値が 0 でないことを確認するように変更します(65,69行目)。

<?php

global $post;

$args = array(
  'posts_per_page'      => $attributes['numberOfItems'],
  'post_status'         => 'publish',
  'order'               => $attributes['order'],
  'orderby'             => $attributes['orderBy'],
  'ignore_sticky_posts' => true,
  'no_found_rows'       => true,
);

if (!empty($attributes['categories']) && is_array($attributes['categories'])) {
  $args['category__in'] = array_map('intval', array_column($attributes['categories'], 'id'));
}

$current_post_id = get_the_ID();

if (isset($attributes['excludeCurrentPost']) && $attributes['excludeCurrentPost'] && $current_post_id) {
  $args['post__not_in'] = [(int) $current_post_id];
}

$query = new WP_Query($args);

if (isset($attributes['displayFeaturedImage']) && $attributes['displayFeaturedImage'] && $query->have_posts()) {
  update_post_thumbnail_cache($query);
}

$pre = 'wp-block-wdl-block-my-dynamic-block__';

$post_items_markup = '';

if (!empty($attributes['displayHeading']) && !empty($attributes['headingText']) && !empty($attributes['headingTag'])) {
  $post_items_markup .= sprintf(
    '<%1$s class="%2$sheading">%3$s</%1$s>',
    esc_attr($attributes['headingTag']),
    esc_attr($pre),
    esc_html($attributes['headingText'])
  );
}

$columns = isset($attributes['columns']) && is_numeric($attributes['columns']) ? (int) $attributes['columns'] : 1;
$post_items_markup .= sprintf(
  '<div class="%1$spost-items columns-%2$d">',
  esc_attr($pre),
  esc_attr($columns)
);

if ($query->have_posts()) {
  while ($query->have_posts()) {
    $query->the_post();
    $post_link = esc_url(get_permalink());
    $title     = wp_strip_all_tags(get_the_title());

    $post_items_markup .= sprintf(
      '<div class="%1$spost-item">',
      esc_attr($pre)
    );

    if (isset($attributes['displayFeaturedImage']) && $attributes['displayFeaturedImage'] && has_post_thumbnail()) {

      // && $attributes['featuredImageSizeWidth'] を追加
      $image_style = '';
      if (isset($attributes['featuredImageSizeWidth']) && $attributes['featuredImageSizeWidth']) {
        $image_style .= sprintf('max-width:%spx;', $attributes['featuredImageSizeWidth']);
      }
      // && $attributes['featuredImageSizeHeight'] を追加
      if (isset($attributes['featuredImageSizeHeight']) && $attributes['featuredImageSizeHeight']) {
        $image_style .= sprintf('max-height:%spx;', $attributes['featuredImageSizeHeight']);
      }

      $image_class = !empty($attributes['featuredImageMaxStyleAdded']) ? 'max-style-added' : '';

      $thumbnail_alt_value = get_post_meta(get_post_thumbnail_id(), '_wp_attachment_image_alt', true);

      if (!$thumbnail_alt_value) {
        $thumbnail_alt_value = $title;
      }

      $featured_image = get_the_post_thumbnail(
        get_the_ID(),
        $attributes['featuredImageSizeSlug'],
        array(
          'style' => esc_attr($image_style),
          'class' => esc_attr($image_class),
          'alt'   => esc_attr($thumbnail_alt_value),
        )
      );

      if (!empty($attributes['addLinkToFeaturedImage'])) {
        $featured_image = sprintf(
          '<a href="%1$s" aria-label="%2$s">%3$s</a>',
          esc_url($post_link),
          esc_attr($title),
          $featured_image
        );
      }

      $post_items_markup .= sprintf(
        '<div class="%1$sfeatured-image">%2$s</div>',
        esc_attr($pre),
        $featured_image
      );
    }

    $post_items_markup .= sprintf(
      '<%1$s class="%2$spost-title"><a href="%3$s">%4$s</a></%1$s>',
      $attributes['titleTag'],
      esc_attr($pre),
      esc_url($post_link),
      $title
    );

    if (isset($attributes['displayDate']) && $attributes['displayDate']) {
      $post_items_markup .= sprintf(
        '<time datetime="%1$s" class="%2$spost-date">%3$s</time>',
        esc_attr(get_the_date('c')),
        esc_attr($pre),
        esc_html(get_the_date())
      );
    }

    if (isset($attributes['displayExcerpt']) && $attributes['displayExcerpt'] && isset($attributes['excerptLength']) && $attributes['excerptLength']) {
      $post_items_markup .= sprintf(
        '<p class="%1$spost-excerpt">%2$s</p>',
        esc_attr($pre),
        wp_trim_words(wp_strip_all_tags(get_the_excerpt()), (int) $attributes['excerptLength'], '...')
      );
    }
    $post_items_markup .= "</div>\n";
  }
} else {
  $post_items_markup .= '<p>' . esc_html__('No posts to display.', 'my-dynamic-block') . '</p>';
}

$post_items_markup .= "</div>\n";

wp_reset_postdata();

$wrapper_attributes = get_block_wrapper_attributes();

$allowed_tags = wp_kses_allowed_html('post');
$allowed_tags['img']['srcset'] = true;
$allowed_tags['img']['sizes'] = true;

printf(
  '<div %1$s>%2$s</div>',
  $wrapper_attributes,
  wp_kses($post_items_markup, $allowed_tags)
);

edit.js でブロックの見出しの入力を TextControl コンポーネントに変更したので、インスペクターパネルには見出しの入力欄が追加されます。

コントロールを変更すると、自動的に変更が反映されますが、JavaScript によるクライアントサイドレンダリングに比べると反応が遅いです。

クリーンアップ & ビルド

この例では view.js ファイルと editor.scss ファイルは使用しないので削除します。

block.json で関連プロパティを削除

まず、block.json で "editorStyle": "file:./index.css","viewScript": "file:./view.js" の行を削除します(削除する "viewScript": の前の行の最後のカンマも削除)。

edit.js でインポートを削除

edit.js で editor.scss のインポートの行 import "./editor.scss"; を削除します。

ファイルを削除

src ディレクトリの view.js ファイルと editor.scss ファイルを削除します。

ビルド

開発が完了したら、変更がすべて保存されていることを確認して、ターミナルで control + c を押して開発モードを終了し、npm run build を実行して本番環境用にビルドを実行します。

% npm run build

ファイル

以下は最終的なファイルのサンプルです。

block.json

category や icon、description を必要に応じて適宜変更します。

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "wdl-block/my-dynamic-block",
  "version": "0.1.0",
  "title": "My Dynamic Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false,
    "align": true,
    "color": {
      "gradients": true,
      "link": true
    },
    "spacing": {
      "margin": true,
      "padding": true
    },
    "typography": {
      "fontSize": true,
      "lineHeight": true
    }
  },
  "attributes": {
    "displayHeading": {
      "type": "boolean",
      "default": true
    },
    "headingTag": {
      "type": "string",
      "default": "h2"
    },
    "headingText": {
      "type": "string",
      "default": ""
    },
    "numberOfItems": {
      "type": "number",
      "default": 5
    },
    "excludeCurrentPost": {
      "type": "boolean",
      "default": true
    },
    "displayDate": {
      "type": "boolean",
      "default": true
    },
    "displayExcerpt": {
      "type": "boolean",
      "default": true
    },
    "excerptLength": {
      "type": "number",
      "default": 55
    },
    "displayFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "featuredImageSizeSlug": {
      "type": "string",
      "default": "medium"
    },
    "featuredImageSizeWidth": {
      "type": "number",
      "default": null
    },
    "featuredImageSizeHeight": {
      "type": "number",
      "default": null
    },
    "featuredImageMaxStyleAdded": {
      "type": "boolean",
      "default": false
    },
    "addLinkToFeaturedImage": {
      "type": "boolean",
      "default": true
    },
    "titleTag": {
      "type": "string",
      "default": "h3"
    },
    "order": {
      "type": "string",
      "default": "desc"
    },
    "orderBy": {
      "type": "string",
      "default": "date"
    },
    "categories": {
      "type": "array",
      "items": {
        "type": "object"
      }
    },
    "columns": {
      "type": "number",
      "default": 1
    }
  },
  "textdomain": "my-dynamic-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php",
  "viewScript": "file:./view.js"
}

edit.js

import { __ } from "@wordpress/i18n";
import {
  useBlockProps,
  RichText,
  InspectorControls,
  __experimentalImageSizeControl as ImageSizeControl,
} from "@wordpress/block-editor";
import {
  PanelBody,
  PanelRow,
  QueryControls,
  SelectControl,
  ToggleControl,
  RangeControl,
} from "@wordpress/components";
import { useSelect, useDispatch } from "@wordpress/data";
import { dateI18n, format, getSettings } from "@wordpress/date";
import "./editor.scss";

export default function Edit({ attributes, setAttributes }) {
  const {
    displayHeading,
    headingTag,
    headingText,
    numberOfItems,
    excludeCurrentPost,
    displayDate,
    displayExcerpt,
    excerptLength,
    displayFeaturedImage,
    featuredImageSizeSlug,
    featuredImageSizeWidth,
    featuredImageSizeHeight,
    featuredImageMaxStyleAdded,
    addLinkToFeaturedImage,
    titleTag,
    order,
    orderBy,
    categories,
    columns,
  } = attributes;

  const {
    imageSizes,
    categoriesData,
    posts,
    defaultImageWidth,
    defaultImageHeight,
  } = useSelect(
    (select) => {
      const postId = select("core/editor")?.getCurrentPostId();
      const { getEntityRecords } = select("core");
      const settings = select("core/block-editor").getSettings();

      const catIds =
        categories && categories.length > 0
          ? categories.map( ( cat ) => cat.id )
          : [];

      return {
        categoriesData: getEntityRecords("taxonomy", "category", {
          per_page: -1,
          hide_empty: true
        }) || [],
        posts: getEntityRecords("postType", "post", {
          per_page: numberOfItems,
          _embed: 'wp:featuredmedia',
          order,
          orderby: orderBy,
          exclude: excludeCurrentPost && postId ? [postId] : [],
          categories: catIds,
        }),
        imageSizes: settings.imageSizes,
        defaultImageWidth: settings.imageDimensions?.[ featuredImageSizeSlug ]?.width ?? 0,
        defaultImageHeight: settings.imageDimensions?.[ featuredImageSizeSlug ]?.height ?? 0,
      };
    },
    [ numberOfItems, excludeCurrentPost, order, orderBy, categories, featuredImageSizeSlug ],
  );

  const pre = "wp-block-wdl-block-my-dynamic-block__";

  const getTextContent = (htmlRendered) => {
    if (!htmlRendered) return "";
    const elem = document.createElement("div");
    elem.innerHTML = htmlRendered;
    return elem.textContent || elem.innerText || "";
  };

  const headingTagOptions = Array.from({ length: 6 }, (_, i) => ({
    label: `H${i + 1}`,
    value: `h${i + 1}`,
  }));

  const TitleTag = ({ tagName = "h3", children, ...props }) => {
    return React.createElement(tagName, { ...props }, children);
  };

  function getFeaturedImageDetails( post, size ) {
    const image = post._embedded?.['wp:featuredmedia']?.[0];
    return {
      url:
        image?.media_details?.sizes?.[ size ]?.source_url ?? image?.source_url,
      alt: image?.alt_text || (post.title?.rendered ? getTextContent(post.title.rendered) : ""),
    };
  }

  const imageSizeOptions = imageSizes
    .filter( ( { slug } ) => slug !== 'full' )
    .map( ( { name, slug } ) => ( {
      value: slug,
      label: name,
    } ) );

  const { createWarningNotice } = useDispatch( 'core/notices' );

  const showRedirectionPreventedNotice = ( event ) => {
    event.preventDefault();
    createWarningNotice( __( "Links are disabled in the editor.", "my-dynamic-block" ), {
      type: 'snackbar',
    } );
  };

  const categorySuggestions =
    categoriesData?.reduce(
      (accumulator, category) => ({
        ...accumulator,
        [category.name]: category,
      }),
      {},
    ) ?? {};

  const selectCategories = (tokens) => {
    const hasNoSuggestion = tokens.some(
      (token) => typeof token === "string" && !categorySuggestions[token],
    );
    if (hasNoSuggestion) {
      return;
    }
    const selectedAllCategories = tokens.map((token) => {
      return typeof token === "string" ? categorySuggestions[token] : token;
    });
    if (selectedAllCategories.includes(null)) {
      return false;
    }
    setAttributes({ categories: selectedAllCategories });
  };

  return (
    <>
      <InspectorControls>
        <PanelBody title={__("Heading Settings", "my-dynamic-block")}>
          <ToggleControl
            label={__("Display Heading", "my-dynamic-block")}
            checked={displayHeading}
            onChange={() => setAttributes({ displayHeading: !displayHeading })}
          />
          {displayHeading && (
            <SelectControl
              label={__("Heading Tag", "my-dynamic-block")}
              onChange={(value) => setAttributes({ headingTag: value })}
              value={headingTag}
              options={headingTagOptions}
            />
          )}
        </PanelBody>
        <PanelBody title={__("Sorting and filtering", "my-dynamic-block")}>
          <QueryControls
            numberOfItems={numberOfItems}
            onNumberOfItemsChange={(value) =>
              setAttributes({ numberOfItems: value })
            }
            minItems={1}
            maxItems={10}
            {...{ order, orderBy }}
            onOrderChange={(value) => setAttributes({ order: value })}
            onOrderByChange={(value) => setAttributes({ orderBy: value })}
            categorySuggestions={categorySuggestions}
            onCategoryChange={selectCategories}
            selectedCategories={categories}
          />
        </PanelBody>
        <PanelBody title={__("Featured image", "my-dynamic-block")}>
          <ToggleControl
            label={__("Display featured image", "my-dynamic-block")}
            checked={displayFeaturedImage}
            onChange={() =>
              setAttributes({ displayFeaturedImage: !displayFeaturedImage })
            }
          />
          {displayFeaturedImage && (
            <>
              <ImageSizeControl
                onChange={ ( value ) => {
                  const newAttrs = {};
                  if ( value.hasOwnProperty( 'width' ) ) {
                    newAttrs.featuredImageSizeWidth = value.width;
                  }
                  if ( value.hasOwnProperty( 'height' ) ) {
                    newAttrs.featuredImageSizeHeight = value.height;
                  }
                  newAttrs.featuredImageMaxStyleAdded = !!(value.width || value.height);
                  setAttributes( newAttrs );
                } }
                slug={ featuredImageSizeSlug }
                width={ featuredImageSizeWidth }
                height={ featuredImageSizeHeight }
                imageWidth={ defaultImageWidth }
                imageHeight={ defaultImageHeight }
                imageSizeOptions={ imageSizeOptions }
                imageSizeHelp={ __(
                  'Select the size of the source image.'
                ) }
                onChangeImage={ ( value ) => {
                  console.log(value)
                  setAttributes( {
                    featuredImageSizeSlug: value,
                    featuredImageSizeWidth: undefined,
                    featuredImageSizeHeight: undefined,
                    featuredImageMaxStyleAdded: false
                  } );
                }}
              />
              <ToggleControl
                label={ __( 'Add link to featured image', "my-dynamic-block") }
                checked={ addLinkToFeaturedImage }
                onChange={ ( value ) =>
                  setAttributes( { addLinkToFeaturedImage: value } )
                }
              />
            </>
          )}
        </PanelBody>
        <PanelBody title={__("Content Settings", "my-dynamic-block")}>
          <RangeControl
            label={__("Number of columns", "my-dynamic-block")}
            value={columns}
            onChange={(value) => setAttributes({ columns: value })}
            min={1}
            max={5}
          />
          <PanelRow>
            <ToggleControl
              label={__("Display Date", "my-dynamic-block")}
              checked={displayDate}
              onChange={() => setAttributes({ displayDate: !displayDate })}
            />
          </PanelRow>
          <PanelRow>
            <ToggleControl
              label={__("Display Excerpt", "my-dynamic-block")}
              checked={displayExcerpt}
              onChange={() =>
                setAttributes({ displayExcerpt: !displayExcerpt })
              }
            />
          </PanelRow>
          {displayExcerpt && (
            <RangeControl
              label={__("Max number of words", "my-dynamic-block")}
              value={excerptLength}
              onChange={(value) => setAttributes({ excerptLength: value })}
              min={10}
              max={100}
            />
          )}
          <PanelRow>
            <ToggleControl
              label={__("Exclude Current Post", "my-dynamic-block")}
              checked={excludeCurrentPost}
              onChange={() =>
                setAttributes({ excludeCurrentPost: !excludeCurrentPost })
              }
            />
          </PanelRow>
          <SelectControl
            label={__("Title Tag", "my-dynamic-block")}
            onChange={(value) => setAttributes({ titleTag: value })}
            value={titleTag}
            options={headingTagOptions}
          />
        </PanelBody>
      </InspectorControls>
      <div {...useBlockProps()}>
        {displayHeading && (
          <RichText
            tagName={headingTag}
            onChange={(value) => setAttributes({ headingText: value })}
            value={headingText}
            className={`${pre}heading`}
            placeholder={__("Enter heading text", "my-dynamic-block")}
          />
        )}
        {Array.isArray(posts) && posts.length === 0 && (
          <p>No post for the selected category.</p>
        )}

        <div className={`${pre}post-items columns-${columns > 0 ? columns : 1}`}>
          {posts &&
            posts.map((post) => {
              const title = getTextContent(post.title?.rendered);
              const excerpt = getTextContent(post.excerpt?.rendered)?.slice(0, excerptLength) + "...";
              const { url: featuredMedia, alt: altText } = getFeaturedImageDetails( post, featuredImageSizeSlug );
              const renderFeaturedImage = displayFeaturedImage && featuredMedia;
              const featuredImage = renderFeaturedImage && (
                <img
                  src={ featuredMedia }
                  alt={ altText }
                  style={ {
                    maxWidth: featuredImageSizeWidth ? featuredImageSizeWidth : null,
                    maxHeight: featuredImageSizeHeight ? featuredImageSizeHeight : null,
                  } }
                  className={ featuredImageMaxStyleAdded ? "max-style-added" : null}
                />
              );
              return (
                <div key={post.id} className={`${pre}post-item`}>
                  {renderFeaturedImage && (
                    <div className={`${pre}featured-image`}>
                      { addLinkToFeaturedImage ? (
                        <a href={post.link} onClick={ showRedirectionPreventedNotice}>
                          { featuredImage }
                        </a>
                      ) :  (
                        featuredImage
                      )  }
                    </div>
                  )}
                  {title && (
                    <TitleTag tagName={titleTag} className={`${pre}post-title`}>
                      <a href={post.link} onClick={ showRedirectionPreventedNotice}>
                        {title}
                      </a>
                    </TitleTag>
                  )}
                  {displayDate && post.date_gmt && (
                    <time dateTime={format("c", post.date_gmt)} className={`${pre}post-date`}>
                      {dateI18n(getSettings().formats.date, post.date_gmt)}
                    </time>
                  )}
                  {displayExcerpt && post.excerpt?.rendered && (
                    <p className={`${pre}post-excerpt`}>{excerpt}</p>
                  )}
                </div>
              );
            })}
        </div>
      </div>
    </>
  );
}

index.js

create-block コマンドで生成されたまま、変更していません。

import { registerBlockType } from '@wordpress/blocks';
import './style.scss';
import Edit from './edit';
import metadata from './block.json';

registerBlockType( metadata.name, {
  edit: Edit,
} );

render.php

<?php

global $post;

$args = array(
  'posts_per_page'      => $attributes['numberOfItems'],
  'post_status'         => 'publish',
  'order'               => $attributes['order'],
  'orderby'             => $attributes['orderBy'],
  'ignore_sticky_posts' => true,
  'no_found_rows'       => true,
);

if (!empty($attributes['categories']) && is_array($attributes['categories'])) {
  $args['category__in'] = array_map('intval', array_column($attributes['categories'], 'id'));
}

$current_post_id = get_the_ID();

if (isset($attributes['excludeCurrentPost']) && $attributes['excludeCurrentPost'] && $current_post_id) {
  $args['post__not_in'] = [(int) $current_post_id];
}

$query = new WP_Query($args);

if (isset($attributes['displayFeaturedImage']) && $attributes['displayFeaturedImage'] && $query->have_posts()) {
  update_post_thumbnail_cache($query);
}

$pre = 'wp-block-wdl-block-my-dynamic-block__';

$post_items_markup = '';

if (!empty($attributes['displayHeading']) && !empty($attributes['headingText']) && !empty($attributes['headingTag'])) {
  $post_items_markup .= sprintf(
    '<%1$s class="%2$sheading">%3$s</%1$s>',
    esc_attr($attributes['headingTag']),
    esc_attr($pre),
    esc_html($attributes['headingText'])
  );
}

$columns = isset($attributes['columns']) && is_numeric($attributes['columns']) ? (int) $attributes['columns'] : 1;
$post_items_markup .= sprintf(
  '<div class="%1$spost-items columns-%2$d">',
  esc_attr($pre),
  esc_attr($columns)
);

if ($query->have_posts()) {
  while ($query->have_posts()) {
    $query->the_post();
    $post_link = esc_url(get_permalink());
    $title     = wp_strip_all_tags(get_the_title());

    $post_items_markup .= sprintf(
      '<div class="%1$spost-item">',
      esc_attr($pre)
    );

    if (isset($attributes['displayFeaturedImage']) && $attributes['displayFeaturedImage'] && has_post_thumbnail()) {

      $image_style = '';
      if (isset($attributes['featuredImageSizeWidth'])) {
        $image_style .= sprintf('max-width:%spx;', $attributes['featuredImageSizeWidth']);
      }
      if (isset($attributes['featuredImageSizeHeight'])) {
        $image_style .= sprintf('max-height:%spx;', $attributes['featuredImageSizeHeight']);
      }

      $image_class = !empty($attributes['featuredImageMaxStyleAdded']) ? 'max-style-added' : '';

      $thumbnail_alt_value = get_post_meta(get_post_thumbnail_id(), '_wp_attachment_image_alt', true);

      if (!$thumbnail_alt_value) {
        $thumbnail_alt_value = $title;
      }

      $featured_image = get_the_post_thumbnail(
        get_the_ID(),
        $attributes['featuredImageSizeSlug'],
        array(
          'style' => esc_attr($image_style),
          'class' => esc_attr($image_class),
          'alt'   => esc_attr($thumbnail_alt_value),
        )
      );

      if (!empty($attributes['addLinkToFeaturedImage'])) {
        $featured_image = sprintf(
          '<a href="%1$s" aria-label="%2$s">%3$s</a>',
          esc_url($post_link),
          esc_attr($title),
          $featured_image
        );
      }

      $post_items_markup .= sprintf(
        '<div class="%1$sfeatured-image">%2$s</div>',
        esc_attr($pre),
        $featured_image
      );
    }

    $post_items_markup .= sprintf(
      '<%1$s class="%2$spost-title"><a href="%3$s">%4$s</a></%1$s>',
      $attributes['titleTag'],
      esc_attr($pre),
      esc_url($post_link),
      $title
    );

    if (isset($attributes['displayDate']) && $attributes['displayDate']) {
      $post_items_markup .= sprintf(
        '<time datetime="%1$s" class="%2$spost-date">%3$s</time>',
        esc_attr(get_the_date('c')),
        esc_attr($pre),
        esc_html(get_the_date())
      );
    }

    if (isset($attributes['displayExcerpt']) && $attributes['displayExcerpt'] && isset($attributes['excerptLength']) && $attributes['excerptLength']) {
      $post_items_markup .= sprintf(
        '<p class="%1$spost-excerpt">%2$s</p>',
        esc_attr($pre),
        wp_trim_words(wp_strip_all_tags(get_the_excerpt()), (int) $attributes['excerptLength'], '...')
      );
    }
    $post_items_markup .= "</div>\n";
  }
} else {
  $post_items_markup .= '<p>' . esc_html__('No posts to display.', 'my-dynamic-block') . '</p>';
}

$post_items_markup .= "</div>\n";

wp_reset_postdata();

$wrapper_attributes = get_block_wrapper_attributes();

$allowed_tags = wp_kses_allowed_html('post');
$allowed_tags['img']['srcset'] = true;
$allowed_tags['img']['sizes'] = true;

printf(
  '<div %1$s>%2$s</div>',
  $wrapper_attributes,
  wp_kses($post_items_markup, $allowed_tags)
);

style.scss

デフォルトのスタイル(2-4行目のプロパティ)を削除し、必要に応じてスタイルを追加します。

.wp-block-wdl-block-my-dynamic-block {
  background-color: #21759b;
  color: #fff;
  padding: 2px;
  box-sizing: border-box;

  .wp-block-wdl-block-my-dynamic-block__post-items {
    display: grid;
    gap: 1rem;
    &.columns-1 {
      grid-template-columns: repeat(1, 1fr);
    }
    $grid-breakpoints: (
      576px: 2,
      768px: 3,
      992px: 4,
      1200px: 5,
    );
    @for $i from 2 through 5 {
      &.columns-#{$i} {
        grid-template-columns: repeat(1, 1fr);
      }
    }
    @each $bp, $cols in $grid-breakpoints {
      @media (min-width: $bp) {
        @for $i from $cols through 5 {
          &.columns-#{$i} {
            grid-template-columns: repeat($cols, 1fr);
          }
        }
      }
    }
    @media (max-width: 768px) {
      gap: 0.5rem;
    }
    &.columns-2,
    &.columns-3,
    &.columns-4,
    &.columns-5 {
      .wp-block-wdl-block-my-dynamic-block__featured-image img {
        object-fit: cover;
        aspect-ratio: 16 / 10;
        width: 100%;
      }
    }
    .wp-block-wdl-block-my-dynamic-block__featured-image img {
      max-width: 100%;
      height: auto;
      &.max-style-added {
        width: 100%;
      }
    }
  }
}

my-dynamic-block.php(プラグインファイル)

create-block コマンドで生成されたまま変更していないので、Description や Author などを必要に応じて変更します。

<?php
/**
* Plugin Name:       My Dynamic Block
* Description:       Example block scaffolded with Create Block tool.
* Version:           0.1.0
* Requires at least: 6.7
* Requires PHP:      7.4
* Author:            The WordPress Contributors
* License:           GPL-2.0-or-later
* License URI:       https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain:       my-dynamic-block
*
* @package WdlBlock
*/

if ( ! defined( 'ABSPATH' ) ) {
  exit; // Exit if accessed directly.
}

function wdl_block_my_dynamic_block_block_init() {
  register_block_type( __DIR__ . '/build/my-dynamic-block' );
}
add_action( 'init', 'wdl_block_my_dynamic_block_block_init' );

package.json

{
  "name": "my-dynamic-block",
  "version": "0.1.0",
  "description": "Example block scaffolded with Create Block tool.",
  "author": "The WordPress Contributors",
  "license": "GPL-2.0-or-later",
  "main": "build/index.js",
  "scripts": {
    "build": "wp-scripts build --webpack-copy-php",
    "format": "wp-scripts format",
    "lint:css": "wp-scripts lint-style",
    "lint:js": "wp-scripts lint-js",
    "packages-update": "wp-scripts packages-update",
    "plugin-zip": "wp-scripts plugin-zip",
    "start": "wp-scripts start --webpack-copy-php"
  },
  "devDependencies": {
    "@wordpress/scripts": "^30.9.0"
  }
}