ブログ用タグ: WordPress

  • WordPress の – 問題

    現在のページをメールやX(Twitter)で共有するとき、サイト名の
    「投稿タイトル – *ListFreak」が
    「投稿タイトル – *ListFreak」
    になってしまう問題とその解決についての覚え書き。

    問題

    たとえば現在のページのタイトルを件名に入れてメーラーを立ち上げる処理のため、次のような文字列を作りたいとする。

    mailto:?subject=件名

    実際のコードは次のようになる。

    $subject = wp_get_document_title(); // 投稿タイトル – *ListFreak
    $mailto  = 'mailto:?subject='.rawurlencode( $subject );

    このURLにアクセスすると、件名が
    「投稿タイトル – *ListFreak」
    になってしまう。

    原因

    WordPress はページタイトルとサイトタイトルをハイフン( – )でつないでHTMLのタイトルを生成する。

    それを出力する際に、テキストのあれこれを読みやすくする wptextualize() という関数が、ハイフンをエンダッシュ( – )に変換する。エンダッシュはハイフンよりも少し長く、読みやすい。

    wptextualize() はHTMLタグを指定して、あるいはまったくオフにすることができる。ハイフンであれば文字化けも起きないのだが、せっかく読みやすく変換してくれたので、これを生かしたい。

    文字化けの原因はエンダッシュそのものでなく、エンダッシュがHTMLエンティティとして出力されること。実際、表示されているページのソースを見ると次のようになっている。– がエンダッシュのHTMLエンティティ。

    <title>*ListFreak &#8211; 美しい箇条書き</title>

    Webページに確実に表示させるという観点からは適切な処理なのかもしれない。しかしこのせいで面倒なことになる。

    例えば下の2つの文字列は同じように見えるが、$s1のエンダッシュがUTF-8であるのに対して、$s2のエンダッシュはHTMLエンティティである。興味のある方はこのページのソースで確認されたし。

    // 投稿A - サイトB
    $s1 = 'A – B';
    $s2 = 'A – B';

    $s1はURLエンコードしてデコードすると元に戻る。

    $s1 = 'A – B';
    
    echo $s1_enc     = rawurlencode( $s1 )."\r\n";
    echo $s1_enc_dec = rawurldecode( $s1_enc )."\r\n";
    
    実行結果↓
    A%20%E2%80%93%20B
    A – B

    しかし$s2はURLエンコードしてデコードしても元に戻らない。

    $s2 = 'A – B';
    
    echo $s2_enc     = rawurlencode( $s2 )."\r\n";
    echo $s2_enc_dec = rawurldecode( $s2_enc )."\r\n";
    
    実行結果↓
    A%20%26%238211%3B%20B
    A &#8211; B

    なぜこうなるか。rawurlencode() が準拠している標準 (RFC 3986) では渡される文字列が UTF-8 であることを前提としているから、らしい。

    rawurlencode() は &#8211; をHTMLエンティティでなく普通の(UTF-8の)文字列とみなしてエンコードする。つまり & => %26, # => %23, 8211, ;=>%3B と変換されてしまう。

    解決

    HTMLエンティティ混じりの文字列を、UTF-8に直してあげればOK。

    $s2 = 'A – B';
    $s2 = html_entity_decode( $s2 );
    
    echo $s2_enc     = rawurlencode( $s2 )."\r\n";
    echo $s2_enc_dec = rawurldecode( $s2_enc )."\r\n";
    
    実行結果↓
    A%20%E2%80%93%20B
    A – B

    最初のコード例はこうなる。

    $subject = wp_get_document_title();
    $subject = html_entity_decode( $subject );
    $mailto  = 'mailto:?subject='.rawurlencode( $subject );
  • カテゴリか投稿タイプか

    当サイトのメインコンテンツである「リスト投稿」はこれまで「投稿タイプがpost、かつカテゴリがlist」である投稿だと定義してきた。これを今回「投稿タイプがlist」である投稿に切り替えた。

    もともとXOOPSからWordPressに引っ越してきたときにリスト投稿の定義を「投稿タイプがpost、かつカテゴリがlist」としたのは、できるだけカスタマイズを減らしたかったからである。投稿タイプの初期値は post と page の2種類であり、新しいタイプを作るには register_post_type() を呼ぶか CPT UI のようなプラグインを使う必要がある。一方、カテゴリはインストールした時点で使えるようになっている。「投稿タイプがpost、かつカテゴリがlist」はノンカスタマイズでスタートできる自然な選択だった。

    しかししばらくすると、やりづらいことが増えてきた。例えば少々複雑な処理をSQLで書こうとすると

    しかし、WordPressに引っ越して何年かすると、投稿タイプだけでニーズをまかなうのが難しくなってきた。

    まず感じたのは、投稿とカテゴリの「遠さ」。ちょっと複雑な処理をするためにSQLでリスト投稿を取り出そうとすると、「投稿タイプがpost、かつカテゴリがlist」という定義のもとではこんな感じになる(たぶん)。4つものテーブルをまたぐ必要があるのだ。

    SELECT p.*
    FROM wp_posts AS p
    JOIN wp_term_relationships AS tr ON p.ID = tr.object_id 
    JOIN wp_term_taxonomy AS tt ON tr.term_taxonomy_id = tt.term_taxonomy_id AND tt.taxonomy = 'category'
    JOIN wp_terms AS t ON tt.term_id = t.term_id AND t.name='list'
    WHERE p.post_status='publish' AND p.post_type='post'

    こうやって取り出したレコードに対して二次・三次の処理をしていこうと思うと、とても複雑になる。

    一方、投稿タイプはpostsテーブルのフィールドである。なので「投稿タイプがlist」という定義の元でリスト投稿を取り出すのは、これだけでよい。

    SELECT p.*
    FROM wp_posts AS p
    WHERE p.post_status='publish' AND p.post_type='list'

    もう一つの難所は、タクソノミーとの関連付け。最初はリスト投稿とブログの2種類のコンテンツに対して、タグという一つのタクソノミーでなんとか運用していた。なるべくカスタマイズしない方針から、ブログ用のタグには blog- という接頭辞を付けたり、リスト投稿からはそれが見えないようにしたり。しかし逆に手間がかかってしまう。ブログという投稿タイプとブログ用タグというタクソノミーを作って運用したほうが楽なのだということがようやくわかってきた。

    リスト投稿も2000を超えているので移行は面倒だったが、やってよかった。ノートという第三の投稿タイプを作るなど、拡張の方向性が見えてきた。

  • Block Bindings API を試す

    概要

    Block Bindings APIを試してみた。レイアウトが決まっているページの情報だけを書き換えるなら使えそう。

    試すに至った背景

    そもそも Block Bindings API とは

    ブロックの内容や属性を動的に書き換えるAPI。

    Bindings – Japanese Team – WordPress.org 日本語

    試したかった理由

    当サイトではブロックの内容を書き換えたり、動的に表示させたりしている。現在はブロックの名前をキーにして、ブロックを描画するときに書き換えているが、より公式なやり方があればそれに準拠したい。

    試したこと

    準備

    段落ブロックで試してみた。まずはビジュアルエディターで段落を作った後コードエディターに切り替えてバインド情報を書き込む。

    <!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"listfreak/blockbinings-test","args":{"test_key":"test_value"}}}}} -->
    <p>(ブロックバインディング)</p>
    <!-- /wp:paragraph -->

    バインドする関数とバインドされる関数、つまり表示内容を実際に作る関数を定義する。以下のようなファイルを作って自作プラグインから呼び出してみた。

    <?php
    class LF_BlockBinings_Test {
    
    	/**
    	 * 初期設定をする
    	 */
    	public static function init() {
    
    		// ブロックと表示ロジックを結び付ける関数を呼び出す
    		add_action( 'init', [ __CLASS__, 'bind' ] );
    	}
    
    	/**
    	 * ブロックと表示ロジックを結び付ける
    	 */
    	public static function bind() {
    		register_block_bindings_source( 'listfreak/blockbinings-test', [
    			'label'              => __METHOD__,
    			'get_value_callback' => [ __CLASS__, 'get_value' ],
    		] );
    	}
    
    	/**
    	 * 表示する内容を作る
    	 */
    	public static function get_value( $source_args, $block_instance, $attribute_name ) {
    
    		$value = __METHOD__.' ('.__LINE__.') '.print_r( $source_args, true );
    		return $value;
    	}
    }
    LF_BlockBinings_Test::init();

    実施

    バインドが成功すると、ブロックには「(ブロックバインディング)」でなくregister_block_bindings_source()で指定したラベルである「LF_BlockBinings_Test::bind」が表示された。これによってバインドされていることが示される。

    エディタで定義するソース名 (listfreak/blockbinings-test) と、それを使ってバインドする関数の対応関係を管理するのが面倒そうなので、メソッド名をラベルに使うアイディアは悪くないと感じた。

    このブロックを含むページを表示させると、ブロック部分には下記が表示された。

    LF_BlockBinings_Test::get_value (28) Array ( [test_key] => test_value )

    試して学んだこと

    • 思ったより簡単。バインド情報をコードエディターで書き込むので内容を忘れるのではないかと不安だったが、ちゃんとビジュアルエディターでも識別できるようになっている。
    • 対象ブロックの値だけを書き換えるので、get_value()が空文字を出力しても<p></p>は出力される。マージンなどの書式設定を施していればそれも画面に表示される。出力する内容がなければ<p></p>ごと表示させないでおこうと思うと、レンダー時点で書き換える現行方式の方が柔軟。
    • 特定ページでしか発生しないBindingでも、バインド関数をinitフックに登録しなければならない。バインディングをいくつも登録していくにつれてメモリの容量も増えることになる。まあ大きなサイズではないだろうが……。
  • X(Twitter)への投稿字数を調整する

    概要

    LOTD(今日のリスト)を X(Twitter) に投稿する際、140字に切り詰める処理を実装した。

    背景

    これまで外部サービスを使っていたLOTDのXへの自動投稿を、お手製のプログラムに切り替えました。下記のような内容を、メール配信と同じタイミングで投稿します。

    [今日のリスト] 【タイトル】: 【抜粋】
    【パーマリンク】
    【ハッシュタグ化したタグ】

    たとえば「4つの元型(ユング)」であれば次のような感じです。

    [今日のリスト] 4つの元型(ユング): 『元型は、カール・グスタフ・ユングが提唱した概念。世界中の神話や夢や芸術などに普遍的に見られるパターンのこと。元型は、個人的な無意識ではなく、人類に共通する「集合的無意識」から発生するとされる。』
    https://listfreak.com/list/20747
    #ユング #人格 #元型 #無意識 #脳科学

    しかし、これをTwitter APIに送ると403エラーが返ってきます。不親切なことに「字数が長いよ」ではなく「投稿は許されない」という抽象的なエラーメッセージだったので、字数オーバーであること自体に気づくのに時間がかかりました。

    投稿が長い場合には、抜粋で調整しようと思います。たとえば下記のように。

    [今日のリスト] 4つの元型(ユング): 『元型は、カール・グスタフ・ユングが提唱した概念。世界中の神話や夢や芸術などに普遍的に見られるパターンのこと。元型は、個人的な無意識ではなく、人類に共通する「集合的無意識」から発生す…
    https://listfreak.com/list/20747
    #ユング #人格 #元型 #無意識 #脳科学

    内容

    「字数」と「重み付き字数」

    Xでは日本語140字(characters)、英語280字まで投稿できると言われます。これは日本語1字に2字ぶんの「重み」(weight)が付けられているからです。

    なので「あいuえお」の字数(length)は5字ですが、Xの数え方では9字です。ややこしいので、Xの数え方に従った字数を「重み付き字数」と称します。「あいuえお」の字数は5字、「重み付き字数」は9字です。

    全角=2字、半角=1字というわけでもない

    PHPには半角文字を 1、全角文字を 2 として字幅を数える mb_strwidth() や、同じ数え方で指定した字幅で文字列を丸める mb_strimwidth() があります。しかしPHPでは字数 1 と判定される「…」が重み付き字数では2になるなど、字の数え方が X とすこし異なります。

    twitter-text がルールブック

    X の公式サイトによると、ポストの字数制限は twitter-text というオープンソースライブラリで提供しているとのこと。しかし残念ながらPHPのライブラリはありません。そこで構成ファイルだけを借りて軽量なクラスを作りました。

    class LF_Twitter_Text {
    
      // twitter-text configuration
      private static $tt = null;
    
      // 初期化
      public static function init() {}
    
      // 構成内容の取得
      public static function get() {}
    
      // 文字列の重み付き字数を数える
      public static function count_str( $string ) {}
    
      // 文字列の後ろを指定された重み付き字数ぶん切り落とす
      public static function truncate( $string, $length ) {}
    
      // 渡された1文字の重み付き字数を返す
      private static function count_char( $char ) {}
    }

    初期化メソッドは次のようにしました。構成情報をファイルに置くと置き場所を考えるのが面倒だしめったに変わる情報ではないのでハードコード。versionが上がったらJSON部分を入れ替えます。

    public static function init() {
    
      // 重複して呼ばれ得るので、すでに値が入っていればそれを返す
      if ( self::$tt ) {
        return self::$tt;
      }
    
      self::$tt = json_decode( '
    {
      "version": 3,
      "maxWeightedTweetLength": 280,
      "scale": 100,
      "defaultWeight": 200,
      "emojiParsingEnabled": true,
      "transformedURLLength": 23,
      "ranges": [
        {
          "start": 0,
          "end": 4351,
          "weight": 100
        },
        {
          "start": 8192,
          "end": 8205,
          "weight": 100
        },
        {
          "start": 8208,
          "end": 8223,
          "weight": 100
        },
        {
          "start": 8242,
          "end": 8247,
          "weight": 100
        }
      ]
    }'
      );
    }

    字数カウントの核となる、重み付き字数のカウント部分はこのような感じにしました。大部分が日本語、次が英数字などいわゆる半角文字なので、その順番にヒットして処理を終了できるようにちょっと工夫をしました。

    private static function count_char( $char ) {
    
      // UTF-8のコードポイントを取得
      if ( false === $code = mb_ord( $char, 'UTF-8' ) ) {
        return false;
      }
    
      // 大部分を占める日本語をまず判定
      // ranges 配列の最後のメンバーのendとまず比較する
      if ( end( self::$tt->ranges )->end < $code ) {
        return self::$tt->defaultWeight / self::$tt->scale;
      }
    
      // 次に多いと思われるメジャーな半角文字から判定する
      foreach ( self::$tt->ranges as $range ) {
        if ( $range->start <= $code && $code <= $range->end ) {
          return $range->weight / self::$tt->scale;
        }
      }
    
      // 残りは全て2字幅
      return self::$tt->defaultWeight / self::$tt->scale;
    }

    これらのメソッドを使って下記のような感じで投稿する文字列を作成しました。

    // Twitter-Text configuration の内容を取得する
    $tt = LF_Twitter_Text::get();
    
    // パート1: [今日のリスト] タイトル: 抜粋
    $xpost_part1       = '[今日のリスト] '.$post->post_title.': '.$post->post_excerpt;
    $width_part1       = LF_Twitter_Text::count_str( $xpost_part1 );
    
    // パート2: (改行)permalink(改行)ハッシュタグ
    $xpost_part2       = PHP_EOL.$post_url.PHP_EOL.$hash_tags_str;
    // パート2の文字幅を計算する。URLは固定長文字列をダミーとしてあてがう
    $xpost_part2_dummy = PHP_EOL.str_repeat( '-', $tt->transformedURLLength ).PHP_EOL.$hash_tags_str;
    $width_part2       = LF_Twitter_Text::count_str( $xpost_part2_dummy );
    
    // 必要ならパート1の文字数を削る
    if ( 0 < $over_width = $width_part1 + $width_part2 - $tt->maxWeightedTweetLength ) {
      $xpost_part1 = LF_Twitter_Text::truncate( $xpost_part1, $over_width );
    }
    
    // X投稿
    $xpost = $xpost_part1.$xpost_part2;
  • Xに投稿する機能を作る

    概要

    LOTDをX(Twitter)に自動ポストするために使っていたサービスが停止されたので代替機能を作りました。

    経緯

    LOTD(今日のリスト)は、システム的には「lotd というカテゴリに入っている(唯一の)投稿」であり、毎日 0:00 に入れ替えています。このような仕様にしたのは、WordPressがカテゴリごとに RSS フィードを作ってくれるからでした。

    当サイトをWordPressに乗せ換えた当時はフィードをツイートしてくれる無料サービスの選択肢が多かったため、LOTDだけのフィードさえ作れれば、追加の開発なしで LOTD の tweet bot が実現するのは容易でした。

    dlvr.it はそのような配信サービスの一つでした。毎日いい感じに巡回してきてくれて、フィードが更新されていたらツイートしてくれました。Twitter が X になり、ツイートがポストと呼ばれるようになっても、ちゃんと動いてくれました。しかし残念ながら運営者から「1週間後に free plan はおしまいにするよ」というメールが来ました。ずいぶん急な話です。

    いくつかプラグインを検討したのですが、特定のカテゴリの投稿だけを毎日ポストしてくれる軽量プラグインが見あたらず、自作することにしました。

    Twitter APIライブラリのインストール

    PHPでXに投稿するためのライブラリとしては TwitterOAuth が超定番のようです。公式ページによれば composer という依存関係管理ツールで次のようにインストールするのがおすすめとのこと。

    composer require abraham/twitteroauth

    しかしこの方法では 0.5.4 という、かなり古いバージョンの TwitterOAuth がインストールされてしまいます。どうも composer が参照するPHPのバージョンが古く、そのバージョンのPHPで動くバージョンの TwitterOAuth がインストールされている模様。さすが依存関係管理ツール。

    当サイトはレンタルサーバー上で動いているので、/usr/bin の下にはいろいろなバージョンのPHPが混在しています。古いバージョンでも機能すればよいのですが、このバージョンは Twitter API v2 に対応していないようなので、新しい TwitterOAuth をインストールしなければならないようです。

    あれこれ調べた結果、構成ファイル composer.json を以下のように作り、composer install することで、新しいバージョンの TwitterOAuth をインストールできました。

    {
        "config": {
            "platform": {
                "php": "8.2"
            }
        },
        "require": {
            "abraham/twitteroauth": "^7.0"
        }
    }

    実のところもっとも時間がかかったのは、この新しいバージョンの TwitterOAuth をインストールする作業でした。いったん環境が整えば、次のようにかなり少ない手数でポストできます。

    // TwitterOAuthインスタンスの作成
    $X = new TwitterOAuth(
      '(API Key)', 
      '(API Key Secret)',
      '(Access Token)',
      '(Access Token Secret)'
    );
    
    // API v2 を指定
    $X->setApiVersion( '2' );
    
    // 投稿内容
    $post = '[今日のリスト] ';
    
    // ツイートを投稿
    $response = $X->post( 'tweets', [ 'text' => $post ] );
    
    // 成功なら 201 (Created) が返ってくる
    if ($connection->getLastHttpCode() !== 201) {
    	// エラー処理
    }

    この処理を、LOTDのメール配信の次に実施することにしました。投稿内容もRSSフィードの転送でなくゼロから組み立てるので、リストに付けたタグをXのハッシュタグにするなど、工夫もしやすくなりました。

  • 広告 (Google AdSense) を動的に非表示にする

    概要

    当サイトのリストURLに文字列を付けると Google AdSense が表示されないようにする。

    https://listfreak.com/list/NNN   → 広告あり
    https://listfreak.com/list/NNN/x → 広告なし

    背景

    当サイトはいくつかのページを除いて Google AdSense が配信する広告を表示しています。しかし、リストを誰かに紹介するときには広告を外したい。そこでURLの末尾に /x のような文字列を付けることで Google AdSense の表示を抑制できないかどうか調べました。

    内容

    Google AdSense の表示を動的に抑制する

    広告を表示させているのは、<head> 部の <!– Google AdSense スニペット (Site Kit が追加) –> の次の行にある JavaScript です。この行が挿入される行を調べてみると、’template_redirect’ フックに登録されている ‘Google\S\M\AdSense->register_tag()’ というコールバックらしいことがわかりました。

    そこで AdSense::register_tag() を見てみると、スニペット書き出し前にこんなif分がありました。

    if ( $tag->is_tag_blocked() ) {
    return;
    }

    is_tag_blocked() の実体は Module_Web_Tag::is_tag_blocked() です。その中身は次のようにフィルターフックで出力の有無を切り替えられるようになっています。

    何のためにブロックしているのかわかりませんが、ありがたく使わせていただくことに。

    return (bool) apply_filters( "googlesitekit_{$this->module_slug}_tag_blocked", false );

    URLへの追加文字列を認識する

    list/NNN/x の x を認識させるためにはリライトルールの追加が必要です。

    add_rewrite_rule(
    '^list/([0-9]{1,})/x$',
    'index.php?category_name=list&p=$matches[1]&x=yes',
    'top'
    );

    この x をWP_Queryで認識させます

    add_filter( 'query_vars', function($qvars){
    $qvars[] = 'x';
    return $qvars;
    }, 10, 1 );

    これらは ‘init’ アクションフックに登録しなければなりません。

    まとめるとこんな感じで動きました。

    add_action( 'init', function(){

    add_rewrite_rule(
    '^list/([0-9]{1,})/x$',
    'index.php?category_name=list&p=$matches[1]&x=yes',
    'top'
    );

    add_filter( 'query_vars', function($qvars){
    $qvars[] = 'x';
    return $qvars;
    }, 10, 1 );
    }, 10, 0 );

    add_action( 'wp', function( $wp ){

    if ( 'yes' === get_query_var( 'x' ) ) {
    add_filter( 'googlesitekit_adsense_tag_blocked', '__return_true', 10, 1 );
    }
    }, 10, 1 );
  • WordPress: ブロックパターンを簡単に作る

    定義ファイルの追加によってブロックパターンを作成する

    ブロックパターンはこれまで次のように作成していました。

    add_action( 'init', 'lf_register_bp', 10, 0 );
    function lf_register_bp() {
    
        $content = <<<'BP'
    <!-- wp:shortcode -->
    [lf_load_syntax_highlighter]
    <!-- /wp:shortcode -->
    BP;
    
        register_block_pattern( 'listfreak/syntax_highlighter', [
            'title'      => 'シンタックスハイライター',
            'content'    => str_replace( "\r\n", "", $content ),
            'categories' => [ 'listfreak' ],
            'keywords'   => [ 'syntax highlighter' ],
        ] );
    }
    

    WordPress 6.0 からは、テーマディレクトリの下に作成しておいた /patterns ディレクトリに次のような PHP ファイルを置くことによってもブロックパターンを作れるようになりました。

    <?php
    /**
     * Title: シンタックスハイライター
     * Slug: listfreak/syntax-highlighter
     * Categories: listfreak
     * Keywords: syntax highlighter
     */
    ?>
    <!-- wp:shortcode -->
    [lf_load_syntax_highlighter]
    <!-- /wp:shortcode -->
    

    できることはどちらでも同じようです。ただ従来の方法では関数の中にブロックパターンの実体であるHTMLを埋め込まざるを得ず、メンテナンス性があまりよくありませんでした。新しく作るなら後者のやり方のほうがよさそうです。

    参考:”Place Patterns in the subfolder /patterns of your theme” (New features for working with patterns and themes in WordPress 6.0 – Make WordPress Core)

    新しい固定ページを作成する際に「ページ作成パターン」選択ダイアログを表示させる

    当サイトではリスト投稿に「まえがき」「リスト」「あとがき」といった統一的な見出しを付けているため、新規ページ用のブロックパターンを作成しています。

    WordPress 6.0 からは、このようなページ単位でのパターン(ページ作成パターン)を登録しておくと、新規作成時に選択ダイアログが表示されるようになりました。

    あるブロックパターンがページ作成パターンであるかどうかの識別は、ブロックタイプが core/post-content かどうかによって行われます。具体的には、先に紹介した後者のやり方でいえば

     Block types: core/post-content
    

    という行をコメント欄に書いておけばOK。

    ただし現在のところこの機能が適用されるのは固定ページのみ。

    参考:Page creation patterns in WordPress 6.0 – Make WordPress Core

  • ショートコード内で出力するHTMLをブロックエディタでデザインするために再利用ブロックを活用する

    投稿の中にHTMLをダイナミックに挿入するショートコードをいくつか作っています。たとえば [ lf_bookinfo asin=XXX] というショートコードは ASIN(Amazon.co.jp における書籍ID)をパラメータとし、書影や書籍情報、関連するリストなどを出力します。

    これまではショートコードに登録したコールバックの中で素朴にHTMLを書いていました。しかしブロックエディタには「縦積み」「横並び」のようなデザインブロックがあって、複雑なレイアウトを簡単に作れます。

    この恩恵に与りたいと思い、ブロックエディタが生成したコードをショートコードのコールバック内にコピーしたりしてみました。が、ブロックエディタが生成する複雑なクラスなどに追随するのが大変で断念。新しく専用ブロックを作成して既存の投稿を全置換するのが正道。しかしブロックの作成もまた面倒でした。

    試行錯誤の末、表示したいHTMLを再利用ブロックとして登録し、ショートコードの中から呼び出す方法に落ち着きました。

    表示レイアウトをデザインして再利用ブロックとして保存

    表示させたい情報をブロックエディタでレイアウトし、ダミーのテキストや画像を挿入しておきます。

    下部の見出しとリストはデータがあれば表示し、なかったら表示しないのですが、とりあえず表示させる前提でデザインしておきます。これを再利用ブロックとして保存すると、 <!-- wp:block {"ref":NNN} /--> のようなコードが挿入されます。

    ショートコード内から再利用ブロックを呼び出す

    WordPress はブロックを解析(パース)して表示用のHTMLを描画(レンダー)し、その後でショートコードをコールバックの出力で置き換えます。ショートコードが処理される時点では、本体のブロックパース&レンダリングは終わってしまっています。よって再利用ブロックのパースとレンダリングは手動で行わねばなりません。

    再利用ブロックは 投稿タイプ wp_block の投稿なので、それを呼び出してブロックにパースします。パースも標準関数があるので簡単です。

    // 再利用ブロックを取得
    $q = new WP_Query( [
        'post_type' => 'wp_block',
        'p'         => NNN,
    ] );
    
    // HTMLをブロックにパースする
    $parsed_blocks = parse_blocks( $q->posts[0]->post_content );

    ブロックの内容を置き換える

    内容を置き換えるブロックには、再利用ブロックを作るときに固有のCSSクラス名を振っておきます。意味合い的にはIDがふさわしいところですが、IDはブロックの属性情報としては保持されていない(出力HTMLにのみ書き込まれている)ので扱いづらい。

    次のようなイメージで、ブロックの内容を置き換えていきます。

    // 特定のクラスのブロックの内容を置き換える
    block_replace( $parsed_blocks[0], [
        'class' => 'lf-attribute-as-id',
        'from'  => '<li>(attribute)</li>',
        'to'    => '<li>1行目</li><li>2行目</li>',
    ] );

    ブロックの内容を置き換える処理はこんな感じ。ブロックおよびブロック内ブロックを再帰的に探索し、クラス名が一致したブロックの innerContentを置き換えます。パースして得られたブロックを直接置き換えていくために参照渡しにしています。

    function block_replace( &$block, $args ) {
    
        // 渡されたブロックを直接書き換えるのでリファレンス渡し
        foreach ( $block as &$b ) {
    
            // 枝葉
            if( ! is_array( $b ) ) {
                continue;
            }
    
            // 置換対象のブロックなら
            $cn = $b['attrs']['className'] ?? '';
            if ( $args['class'] === $cn ) {
    
                // このブロックの innerContent を置き換える
                $b['innerContent'][0] = str_replace(
                    $args['from'],
                    $args['to'],
                    $b['innerContent'][0]
                );
    
                break;
            }
    
            // 直下に innerBlocks か添字配列があれば再帰呼び出し
            if ( array_key_exists( 'innerBlocks', $b )
                || array_key_exists( 0, $b ) ) {
    
                block_replace( $b, $args );
            }
        }
    }

    再帰ってすばらしい。

    array_walk_recursive() でも

    置き換える元となる文字列(この例では ‘<li>(attribute)</li>’)を一意にしておけば、array_walk_recursive() で置換する方法もあります。クラス指定も要らないし楽かも。

    array_walk_recursive( $parsed_blocks, 'block_replace', [
        'from'  => [
            '<li>(attribute)</li>',
            '(more-heading)',
        ],
        'to'    => [
            '<li>1行目</li><li>2行目</li>',
            $more_heading_to,
        ],
    ] );
    
    function block_replace( &$item, $key, $args ) {
    
        $item_replaced = str_replace( $args['from'], $args['to'], $item, $count );
        if ( 0 !== $count ) {
            $item = ( '' !== $args['to'] ) ? $item_replaced : '';
        }
    }

    array_walk_recursive() のコールバック関数の中で、置換先の文字列が空のときにブロック要素を丸ごと消している($item に ’’ を代入している)のは、<h>や<ul>などの外側のHTMLを含めてすべて削除するためです。

    HTML出力

    そのような処理を置き換えたいブロックについて実施した後、HTMLにレンダーしてコールバック終了。

    return render_block( $parsed_blocks[0] );

    少々面倒ではありましたが、既存の投稿を変更しなくてよい点、ブロックエディタでデザインできる点、そして再利用ブロックなので変更を一斉に及ぼせる点はメリットです。

  • WordPress: ショートコードのブロック化

    例えば下記のページは、固定ページにショートコードを埋め込んでコンテンツを動的に生成している。

    その編集画面は下図(Before)。ショートコードの中で見出しやリストなどすべてのコンテンツの書き出しを行っているのでブロックエディタの恩恵を被れない。

    カスタムブロックを作ってみたが、開発環境が複雑で維持できる自信がない。そこでカスタムブロックを作ることなく、ブロックエディタでレイアウトする自由を享受する方法を考えた。

    具体的には、下図(After)のようにページのデザインをブロックエディタで行い、表示時にブロックの内容の一部を置き換えることにした。

    Before
    After

    これをするためには、下記の処理が必要。

    • 置換したいブロックに一意なクラス名を指定する
    • render_block フィルターフックでそのクラス名を拾ってブロックの内容を置き換える

    ブロックを一意に特定するならば意味合い的にはID(HTMLアンカー)が適切だが、IDはブロックの属性として定義されていないため、キーとして使いづらい。

    コードは次のような感じで準備して、プラグインファイルから LF_Page_Taginfo::init() を呼んでおく。

    class LF_Page_Taginfo {
    
        // ブロックのクラス名 => 置換情報テーブル
        private static array $blocks = [];
    
        /**
         * 初期化
         */
        public static function init() {
            add_action( 'wp', [ __CLASS__, 'wp' ] );
        }
    
        /**
         * ディスパッチャー
         */
        public static function wp() {
    
            // 対象ページでなければリターン
            if ( ! is_page() || 'taginfo' !== get_query_var( 'pagename' ) ) {
                return;
            }
    
            // パラメーターが無ければリターン
            if ( '' === $tag_slug = get_query_var( 'tag' ) ) {
                return;
            }
    
            // タームオブジェクトが作れなければリダイレクト
            if ( false === $term = get_term_by( 'slug', $tag_slug , 'post_tag' ) ) {
                wp_safe_redirect( home_url(), 302 );
                exit;
            }
    
            // ブロックのクラス名 => 置換情報テーブル
            // クラス名はブロックエディタで一意に指定しておく
            self::$blocks = [
                'lf-page-taginfo-header-cotagged' => [
                    'from' => '(タグ)',
                    'to'   => $term->name,
                ],
                'lf-page-taginfo-header-tagcloud' => [
                    'from' => '(共起タグ)',
                    'to'   => LF_Page_Tag_CoTagged::get_for_taginfo_page( $term, 5 ),
                ],
                'lf-page-taginfo-header-list' => [
                    'from' => '(タグ)',
                    'to'   => $term->name,
                ],
                'lf-page-taginfo-list' => [
                    'from' => '<li>(リスト)</li>',
                    'to'   => LF_Tax_Tag::get_for_taginfo_page( $term, 5 ),
                ],
                'lf-page-taginfo-header-book' => [
                    'from' => '(タグ)',
                    'to'   => $term->name,
                ],
                'lf-page-taginfo-book' => [
                    'from' => '<li>(書籍)</li>',
                    'to'   => LF_Page_Tag_Book::get_for_taginfo_page( $term, 5 ),
                ],
                'lf-page-taginfo-misc' => [
                    'from' => [ 'TAG', '(タグ)' ],
                    'to'   => [ urlencode( $term->name ), $term->name ],
                ],
            ];
    
            // ページタイトル <title> の変更(コールバック関数は省略)
            add_filter( 'document_title_parts', [ __CLASS__, 'document_title' ], 10, 1 );
    
            // ページ説明の追加(コールバック関数は省略)
            add_action( 'wp_head', [ __CLASS__, 'wp_head' ] );
    
            // 投稿タイトルの変更(コールバック関数は省略)
            add_filter( 'the_title', [ __CLASS__, 'the_title' ], 10, 2 );
    
            // 投稿抜粋欄に説明を表示する(コールバック関数は省略)
            add_filter( 'get_the_excerpt', [ __CLASS__, 'excerpt' ], 10, 2 );
    
            // ブロックを置換する
            add_filter( 'render_block', [ __CLASS__, 'render_block' ], 10, 3 );
        }
    
        /**
         * ブロックを置換する
         */
        public static function render_block( $block_content, $parsed_block, $instance ) {
    
            $class = $parsed_block['attrs']['className'] ?? '';
    
            // 置換対象のクラスなら置換
            if ( '' !== $class && array_key_exists( $class, self::$blocks ) ) {
                $block_content = str_replace(
                    self::$blocks[$class]['from'],
                    self::$blocks[$class]['to'],
                    $block_content
                );
            }
    
            return $block_content;
        }
    }
    
    • 表示しようとしているページが処理対象かどうかを知るには ‘init’ アクションフックではタイミングが早すぎるので、URLパラメータの処理が終わった ‘wp’ で行っている。
    • wp() の最初の3つの if 文が対象ページかどうかの判定とパラメータ検証。
    • self::$blocks の定義が肝。ブロック名をキー、置換元先情報を値とする連想配列を定義しておく。置換されない部分についてはできるだけブロックエディタのほうに記述する。置換する部分がエディタ上でわかりやすいようにカッコでくくってみた。
    • 置換は str_replace() で行うので 複数要素の置換も可能。いちばん最後の要素ではリンク先のURLとリンク文字列の置換を行っている。
    • ‘render_block’ フィルターフックのコールバックに置換処理を登録。このフックはブロック描画の最後に設置されており、ヘッダーやフッターも含めて全ブロックで発火する。よってブロック名ごとに発火できる ‘render_block_{$this->name}’ を使うほうが少し軽いのかも。
    • render_block() で置換。クラス名が存在し、かつ置換情報テーブルのキーと一致したら置換。ブロックエディタでクラス名が1つだけ登録されていることを前提としているので、もしカスタムクラス名を2つ登録したくなった場合には改変が必要。
    • $block_content には表示するHTMLがそのまま入っている。置換元のコードはコードエディターに切り替えるなどして確認する。

    ちょっとスペーサーを入れてみたりするような見栄えの調整がずいぶん楽になった。

  • WordPress: WP_Queryはまとめて取得した投稿を個別にキャッシュしている

    ちょっと嬉しい発見があったのでメモ。

    余分にSQLを発行することでトータルのDBコール回数を劇的に減らせることがある

    たとえば次のように10通の投稿へのリンクを作ると、SQLは20回発行される。

    // 表示したい投稿ID
    $post_ids = array( 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 );
    
    // 各投稿へのリンクを作成
    foreach ( $post_ids as $post_id ) {
        $out[] = sprintf(
            '<li><a href="%s">%s</a></li>',
            get_permalink( $post_id ),
            get_the_title( $post_id ),
        );
    }

    これは get_permalink() が内部的に get_post() を発行するため。get_post() は投稿と関連タームを取得するためにDBを2回コールする。get_the_title() はオブジェクトキャッシュからデータを取得するのでDBアクセスは発生しない。

    このとき次のように get_permalink() の前にまとめて投稿を取得しておくと、発行される回数は2回で済む。投稿を取得するための1回と、関連タームを取得するための1回。

    $post_ids = array( 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 );
    
    $q = new WP_Query( array(
        'post_status'            => 'publish',
        'post__in'               => $post_ids,
        'posts_per_page'         => -1,
        'update_post_meta_cache' => false,
    ) );
    unset( $q );
    
    foreach ( $post_ids as $post_id ) {
        $out[] = sprintf(
            '<li><a href="%s">%s</a></li>',
            get_permalink( $post_id ),
            get_the_title( $post_id ),
        );
    }

    取得してすぐ unset() しているので無駄なようだが、DBコールの数を大きく削減する効果がある。

    これは、WP_Queryが取得した投稿を個別にキャッシュしてくれているため。取得するだけで効果があることを示すために unset() を入れたが、放置でもOK。

    ソースをたどってみると、次のような流れで投稿を個別にキャッシュに格納していた。なんとなく、まとめて取得したデータはまとめてキャッシュされるかと思っていたけれど、当然ながらこのほうがヒット率が高い。

    new WP_Query()
       → __construct()
          → get_post()
             → update_post_caches()
                → update_post_cache()
                   → 投稿を個別に wp_cache_add()

    なお 'update_post_meta_cache' => false は、ついでに投稿メタを取得しておくためのSQL発行を抑制する。SQL1回だけだが節約にはなる。

    'update_post_term_cache' => false を加えてしまうと逆効果。get_the_category() のなかでタームを取得しているため。ただしパーマリンクの構造によっては

    なんにせよget_permalink()にかぎらず、このテクニックは覚えておきたい。