【WordPressプラグイン】
更新した記事一覧をリストで表示する

関連記事

1. 記事一覧を表示する

Gutenberg ブロックを作ろうとすると、@wordpress/scripts によるビルド環境の構築が最初の壁になります1
Node.js の依存関係を整え、webpack の設定を理解して、それでようやく Hello World が出せます。
機能よりも手順が重い。

今回は「最近更新した記事の一覧を表示するブロック」を、ビルドなし・CSS なし・最小クラス構成で作りました。
設計の判断を中心に書いていきます。

1.1. 作ったもの

プラグイン名は Chiilabo Recent Posts Widget、ブロック名は chiilabo/recent-posts-widget です。

公開済みの post 一覧を ul > li > a で出力します。
エディタのサイドバーで並び順と表示件数を設定でき、フロント表示は PHP が担います。
保存時に静的 HTML は持ちません。

ディレクトリ構成はこうなっています。

.
├── tests/
│   └── unit/ChiilaboRecentPostsWidgetTest.php
└── chiilabo-recent-posts-widget/
    ├── chiilabo-recent-posts-widget.php
    ├── block.json
    ├── assets/js/
    │   ├── index.js
    │   └── index.asset.php
    └── includes/
        └── class-chiilabo-recent-posts-widget.phpCode language: JavaScript (javascript)

配布 ZIP には chiilabo-recent-posts-widget/ だけ含めます。
テストや設計書はリポジトリに残しつつ、WordPress へのアップロード対象を分離できます。

1.2. なぜ動的ブロックにするか

Gutenberg のブロックには、保存時に HTML を書き込む静的ブロックと、表示のたびに PHP でレンダリングする動的ブロックがあります。

記事一覧は、ページが開かれたタイミングの最新データを反映すべきです。
更新日順に並べているなら、投稿を編集するたびに順序が変わるのが自然な動作です。
静的 HTML を保存すると、投稿更新のたびにブロックを再保存しなければ表示が古くなります。

動的ブロックでは save()null を返し、フロント描画は render_callback に渡した PHP 関数が担います2
ブロックエディタで保存されるのは属性だけです。

register_block_type(
    dirname( __DIR__ ),
    array(
        'render_callback' => array( __CLASS__, 'render_block' ),
    )
);Code language: PHP (php)

register_block_type() の第一引数にディレクトリを渡すと、同ディレクトリの block.json をブロック定義として読み込みます3
属性定義やスクリプト登録の情報源を block.json に一本化できるので、PHP とエディタで同じ定義が二重に存在する状況を避けられます。

2. block.json を正本にする理由

WordPress 5.8 以降、block.json はサーバー側とエディタ側の両方が参照するブロック定義の正本として機能します4

{
  "apiVersion": 2,
  "name": "chiilabo/recent-posts-widget",
  "category": "widgets",
  "editorScript": "file:./assets/js/index.js",
  "attributes": {
    "sortBy": { "type": "string", "default": "modified" },
    "postsToShow": { "type": "number", "default": 5 }
  },
  "supports": { "html": false }
}Code language: JSON / JSON with Comments (json)

editorScriptfile: プレフィックスを使うと、register_block_type() がそのパスを wp_register_script() に渡して自動登録します5
supports.htmlfalse にすると、エディタの「HTML として編集」ボタンが消えます。
動的ブロックで HTML を自由に書き換えられても困るので、ここは明示的に塞いでいます。

2.1. ビルドなしで index.js を読み込む

通常の Gutenberg ブロック開発では @wordpress/scripts でビルドし、その副産物として index.asset.php が生成されます。
このファイルには依存スクリプトのリストとバージョンハッシュが書かれていて、WordPress はこれを使ってスクリプトを登録します6

今回はビルドを使わないので、index.asset.php を手で書きます。

return array(
    'dependencies' => array(
        'wp-blocks',
        'wp-block-editor',
        'wp-components',
        'wp-element',
        'wp-server-side-render',
    ),
    'version' => '0.0.2',
);Code language: PHP (php)

index.js 側は JSX を使わず、createElement() を直接呼ぶ形で書きました。

( function( blocks, blockEditor, components, element, serverSideRender ) {
    var registerBlockType = blocks.registerBlockType;
    var InspectorControls = blockEditor.InspectorControls;
    // ...

    registerBlockType( 'chiilabo/recent-posts-widget', {
        edit: function( props ) { /* ... */ },
        save: function() { return null; }
    } );
} )(
    window.wp.blocks,
    window.wp.blockEditor,
    window.wp.components,
    window.wp.element,
    window.wp.serverSideRender
);Code language: JavaScript (javascript)

即時関数に window.wp.* を渡す形にしているのは、WordPress がすでにグローバルとして提供しているスクリプトをそのまま使うためです。
外部モジュールの読み込みも不要で、ファイルは素の JavaScript として動きます。

2.2. エディタプレビューを ServerSideRender に寄せる

エディタ側のプレビューには @wordpress/server-side-render を使います。
エディタ上で属性を変えるたびに PHP の render_callback を呼び、その結果をそのまま表示します。

createElement( ServerSideRender, {
    block: 'chiilabo/recent-posts-widget',
    attributes: attributes
} )Code language: JavaScript (javascript)

公式ドキュメントには新規ブロックでの常用は推奨しないと書かれています7
エディタがサーバーへリクエストを投げるため編集中に遅延が生じる点と、プレビューが実際のフロント表示と完全に一致しない場合がある点が理由です。

それでもここで採用したのは、表示ロジックを PHP に集約することでエディタとフロントの表示が確実に一致するからです。
一覧ロジックを JavaScript 側に複製すると、PHP と JS で別々にクエリを組み立てることになり、表示が食い違うリスクが生まれます。
初期実装の複雑さを抑えることも判断材料でした。

3. PHP のロジック設計

Chiilabo_Recent_Posts_Widget クラスに責務を 4 つに絞っています。
ブロック登録、属性の正規化、クエリ引数の生成、HTML の描画です。

3.1. 属性の正規化

normalize_attributes() はエディタから渡される値を先に整理します8
sortBydate でも modified でもない値だった場合はデフォルトの modified に戻し、postsToShow は 1 未満なら 5、100 を超えたら 100 に丸めます。

この処理を先に済ませると、後続のクエリ組み立てで値の検証を繰り返さなくて済みます。

if ( isset( $attributes['sortBy'] ) && in_array( $attributes['sortBy'], array( 'date', 'modified' ), true ) ) {
    $sort_by = $attributes['sortBy'];
}Code language: PHP (php)

3.2. クエリ引数の組み立て

return array(
    'post_type'      => 'post',
    'post_status'    => 'publish',
    'has_password'   => false,
    'orderby'        => $attributes['sortBy'],
    'order'          => 'DESC',
    'posts_per_page' => $attributes['postsToShow'],
    'no_found_rows'  => true,
);Code language: PHP (php)

no_found_rowstrue にすると SQL_CALC_FOUND_ROWS が走らなくなり、ページネーションが不要な一覧では余分なクエリコストを避けられます9
has_passwordfalse に固定しているのは、パスワード保護された投稿がタイトルだけ露出するのを防ぐためです10

3.3. タイトルの HTML 除去

開発途中で、投稿タイトルに <svg> が含まれているケースに気づきました。
アイキャッチ画像をタイトルに埋め込む運用をしているサイトで起きる問題です。
get_the_title() はそのまま HTML を返すので、一覧に <svg>...</svg> の文字列が混入します。

wp_strip_all_tags() で HTML を除去してから trim() し、空文字なら (no title) を返す関数を用意しました11

public static function get_display_title( string $title ): string {
    $title = wp_strip_all_tags( $title );
    $title = trim( $title );
    return '' === $title ? '(no title)' : $title;
}Code language: PHP (php)

ここを独立したメソッドにしているのは、PHPUnit でテストしやすくするためでもあります。

4. WordPress なしで PHPUnit を動かす

WordPress コアへの依存が薄いロジックは、WordPress 本体をインストールせずにテストできます12
tests/bootstrap.phpwp_strip_all_tags() だけ最小スタブを定義して PHPUnit を動かします。

if ( ! function_exists( 'wp_strip_all_tags' ) ) {
    function wp_strip_all_tags( string $text ): string {
        return strip_tags( $text );
    }
}Code language: PHP (php)

テスト対象は属性の正規化と build_query_args() の返り値、タイトル処理の 3 種類で、7 テスト 12 アサーションが通ります。

get_posts()get_permalink() を使う描画ロジックは WordPress が必要になるので、今回はテスト対象から外しています。
テストできる範囲に絞ったことで、CI の設定が単純に保てています。

4.1. CSS を持たない判断

このプラグインは CSS を一切持ちません。
ul > li > a の素の HTML だけを出力し、見た目はテーマに任せます。

プラグインが独自の CSS を持つと、テーマのスタイルと競合する可能性が出ます。
特定のテーマで意図した見た目にするために、プラグイン CSS の詳細度を上げる必要が生じることもあります。
一覧表示の見た目はそれぞれのサイトのデザインに従うべきで、プラグインが抱える理由はありません。

4.2. 投稿タイプを post に固定した理由

カスタム投稿タイプに対応しようとすると、SelectControl で対象タイプを選ばせる設定項目が増え、クエリ条件も分岐します。
今回の要件は通常投稿の一覧だけです。
今必要でない機能を入れないことで、コードとテストの量を小さく保てます。

4.3. 設計の判断まとめ

block.json を正本にしてサーバー登録することで、PHP とエディタのブロック定義が一か所に収まります。
render_callback に描画を集約して save()null にすることで、投稿データの変化が自動的にフロント表示に反映されます。
index.asset.php を手書きすることでビルド環境なしで依存を宣言でき、index.js は素の JavaScript として動きます。
ServerSideRender を使うとエディタプレビューのロジックを PHP に寄せられるので、表示の二重管理が不要になります。
なお、動的ブロックの render_callback 内で get_block_wrapper_attributes() を呼ぶと、Block Supports で設定されたクラスやスタイルをラッパー要素に適用できます13

ビルドや大量の依存関係なしに動く Gutenberg ブロックは、機能が小さければ十分現実的な選択肢です。

  1. @wordpress/scripts は WordPress が公式に提供するビルドツールパッケージで、webpack と Babel の設定があらかじめ含まれています。ブロック開発では一般的にこのパッケージを前提とした手順が紹介されます。 – Get started with wp-scripts
  2. 動的ブロックでは save()null を返すため、投稿コンテンツ内に静的 HTML は保存されません。表示のたびに render_callback が実行されます。静的ブロックとの違いや使い分けの指針は公式ドキュメントにまとめられています。 – Creating dynamic blocks
  3. register_block_type() にファイルパスを渡すと内部で register_block_type_from_metadata() が呼ばれます。これにより block.json が正本として機能し、PHP とエディタの両方でブロック定義が共有されます。サーバー側での登録は、Dynamic Rendering や Block Supports、Style Variations などの機能が正しく動作するために必要です。 – register_block_type()
  4. block.json によるブロック登録は WordPress 5.8 で導入されました。なお、記事中のコードでは apiVersion: 2 を使用していますが、現在の推奨値は 3 です。WordPress 6.3 で導入された apiVersion 3 は iframe エディタへの対応を意味し、WordPress 7.0 以降では iframe エディタが標準になる予定のため、新規ブロックでは 3 を指定することが推奨されます。 – API Versions
  5. file: プレフィックスは block.json 内でスクリプトやスタイルのパスを相対指定するための記法です。WordPress 5.8 で導入されました。このプレフィックスを使うことで、スクリプトの登録とエンキューを手動で行う必要がなくなります。 – Metadata in block.json: editorScript
  6. index.asset.php@wordpress/scripts のビルドプロセスが自動生成するファイルです。WordPress 6.5 以降ではこのアセットファイルがオプション扱いになりましたが、依存スクリプトを正しく宣言するためには手書きする場合でも用意するのが安全です。 – block.json: editorScript
  7. 公式ドキュメントでは ServerSideRender は「フォールバックまたはレガシーな仕組み」と位置づけられています。エディタ上でプレビューを取得する際に使うエンドポイントは /wp/v2/block-renderer/:block で、属性を変えるたびにこの REST API リクエストが発生します。 – @wordpress/server-side-render
  8. block.json で属性の型やデフォルト値を定義していても、エディタからの入力値が仕様外の場合があります。PHP 側で正規化処理を先に通すことで、後続のクエリ組み立てでの検証を省けます。WordPress のブロック属性設計については公式ドキュメントに詳しく解説されています。 – Block Attributes
  9. SQL_CALC_FOUND_ROWS は MySQL がクエリ全体の件数を計算するための命令で、投稿数が多いサイトでは大きなコストになります。no_found_rows: true を指定すると、この全件数カウント処理をスキップできます。なお get_posts() はデフォルトで no_found_rowstrue に設定しますが、明示的に書くことで意図が明確になります。 – WP_Query: no_found_rows
  10. has_password: false を指定すると、パスワードが設定されている投稿をクエリ結果から除外できます。指定しない場合、パスワード保護投稿のタイトルは一覧に表示されますが、リンク先でパスワード入力を求められます。 – WP_Query: has_password
  11. wp_strip_all_tags() は PHP 標準の strip_tags() と異なり、<script><style> タグの中身も除去します。例えば strip_tags('<script>something</script>')'something' を返しますが、wp_strip_all_tags() は空文字を返します。タイトルに埋め込まれた SVG や JS を完全に除去するにはこちらが適しています。 – wp_strip_all_tags()
  12. WordPress 本体なしで PHPUnit を動かす手法は、WP_Mock や Brain Monkey などのモックライブラリを使う方法もありますが、今回のようにテスト対象を WordPress API に依存しない純粋なロジックに絞る設計にすれば、スタブ定義だけで済みます。 – PHPUnit
  13. get_block_wrapper_attributes() は、動的ブロックの PHP レンダリング関数の中で使う関数で、Block Supports によって生成された class や style などの属性文字列を返します。静的ブロックの useBlockProps.save() に相当する PHP 側の関数です。 – get_block_wrapper_attributes()