SNSシェアボタンを出力する
WordPress プラグインを作った

  • WordPressプラグイン開発中、get_the_title()で取得したタイトルにtrim()を使ったところ、UTF-8の3バイト文字の先頭バイトを誤って削除し、日本語タイトルが文字化けした。
  • 原因はPHPのtrim()がバイト単位で処理するためで、/uフラグ付きのpreg_replace()に切り替えることで解決した。
  • タイトル取得元をget_post_field('post_title', $post_id, 'raw')に変更し、the_titleフィルターの影響を受けない生データを使うようにした。

関連記事

1. プラグインの概要

SNSシェアボタンを出力する WordPress プラグインを作っていて、日本語タイトルの先頭が文字化けするという問題にぶつかりました。

chiilabo-sns-share-buttons は、投稿ページにSNSシェアボタンを追加するブロックプラグインです。
共有URLの生成、表示データの組み立て、HTMLのレンダリングをそれぞれ関数に分離し、PHP ユニットテストで動作を固定しながら実装を進めました。

テストファイルは tests/unit/ 以下に置き、tests/run.php からまとめて実行する構成です。
実装前にテストを書いて失敗させ、実装後に通過させるサイクルで進めています。

1.1. タイトル正規化の落とし穴

記事タイトルをシェア文に使うとき、get_the_title() を使うと the_title フィルターが適用され、<br><svg> といったHTMLが混入することがあります1
最初は strip_tags() で除去しようとしましたが、<style> タグの中身 .bg{fill:white;} が文字列として残ってしまいました2

SVG を丸ごと除去する正規表現に変更したところ、別の問題が出ます。
タイトル先頭が クラウドAI... ではなく ??ラウドAI... のように化けてしまいます。

取得元を get_post_field('post_title', $post_id, 'raw') に変更し、フィルター適用前の生タイトルを使うようにしました3
the_title フィルターの影響を受けないため、HTMLの除去処理自体が不要になります。

2. デバッグログをプラグイン内に閉じ込める

raw で取得したタイトルが正常かどうか、どの処理段階で壊れているかを確認する必要がありました。

error_log() を使うのが一般的な WordPress のデバッグ手段ですが、サーバーの wp-content/debug.log や web サーバーのエラーログを直接見に行く必要があり、レンタルサーバー環境では手間がかかります。

そこで、プラグイン専用のログファイルへ書き出す方式を採りました4

function chiilabo_sns_share_buttons_write_log( string $message ): void {
    $log_path = WP_CONTENT_DIR . '/chiilabo-sns-share-buttons-debug.log';
    $line = '[' . date( 'Y-m-d H:i:s' ) . '] ' . $message . PHP_EOL;
    file_put_contents( $log_path, $line, FILE_APPEND | LOCK_EX );
}Code language: PHP (php)

設定画面からログのプレビュー、ダウンロード、クリアができるようにもしました。
サーバーにアクセスしなくても WordPress 管理画面だけで調査が完結します。

ログ出力のオン・オフは WP_DEBUG 定数ではなく、プラグイン設定のチェックボックスで制御しています5
本番環境でも必要なときだけ一時的に有効にできるため、wp-config.php を触らずに済みます。

2.1. ログで見えた本当の原因

ログに raw_titleget_the_title() の結果、正規化後のタイトル、それぞれの先頭バイト列を出力するようにしました。

確認すると raw_title の先頭バイトは正常でした。
化けるのは normalize_title() の内部です。
全角スペース(U+3000)を trim するために書いていたコードが原因でした。

// 問題のあったコード
trim( $title, "\xE3\x80\x80" );Code language: PHP (php)

PHP の trim() の第2引数は「除去する文字のリスト」として機能します6
\xE3\x80\x80 は全角スペースの UTF-8 バイト列ですが、trim() はバイト単位で処理するため、先頭バイトが 0xE3 になる文字—— から から 、漢字など、3バイトの UTF-8 文字の多く——が先頭にあると、そのバイトを全角スペースの一部と誤認して削ってしまいます。

修正は Unicode 対応の正規表現に切り替えることです。

// 修正後
preg_replace( '/^[\s\x{3000}]+|[\s\x{3000}]+$/u', '', $title );Code language: PHP (php)

/u フラグで Unicode モードを有効にすると、\x{3000} が全角スペース1文字として扱われ、バイト単位の誤削除が起きません7

3. wp_kses_post() でフォーム要素が消えた

設定画面の <input type="checkbox"> を出力するとき、エスケープとして wp_kses_post() を使っていたため、input 要素ごと除去されていました。

wp_kses_post() は投稿コンテンツ用のフィルターで、フォーム要素を許可していません。
設定画面では wp_kses() に許可タグを明示するか、checked() ヘルパーを使って属性値だけを動的に出力する書き方に直す必要があります8
見落としやすいポイントです。

4. まとめ

デバッグの工夫は3つです。

  1. プラグイン専用ログファイルに書き出し、管理画面から参照できるようにした
  2. 処理の各段階でバイト列をログに残し、どこで値が変わるかを追跡できるようにした
  3. ログ出力を設定のチェックボックスで制御し、本番でも安全に使えるようにした

trim() の UTF-8 問題は PHP を書いている人なら一度は踏む罠です。
文字列処理では mb_* 関数か /u フラグ付き正規表現を使うのが安全です。

  1. get_the_title() はパスワード保護投稿や非公開投稿に対して “Protected: %s” / “Private: %s” などのプレフィックスを自動付与します。また the_title フィルターを通るため、テーマやプラグインが加工した値が返ります。 – get_the_title() – Function | WordPress Developer Resources
  2. PHP の strip_tags() はタグを除去しますが、タグの内容(テキストノード)は保持します。<style><script> の中身も同様に残るため、CSS・JSを含むHTMLを渡すと想定外の文字列が残ります。 – PHP: strip_tags – Manual
  3. get_post_field() の第3引数(コンテキスト)に 'raw' を渡すと、sanitize_post_field() 内で即座に値を返すため、the_title フィルターを含むあらゆるフィルターが適用されません。デフォルト値は 'display' です。 – get_post_field() – Function | WordPress Developer Resources
  4. WP_CONTENT_DIR は WordPress が定義する定数で、wp-content ディレクトリのフルパスを返します。file_put_contents()LOCK_EX フラグは書き込み中にファイルの排他ロックを取得し、複数リクエストが同時にログを書き込んでも内容が壊れないようにします。 – Determining Plugin and Content Directories | WordPress Codex
  5. WordPress 標準のデバッグログは WP_DEBUGtrue にしたうえで WP_DEBUG_LOGtrue にすることで wp-content/debug.log に出力されます。いずれも wp-config.php の編集が必要なため、本番環境での一時的な有効化には向きません。 – Debugging in WordPress | WordPress Developer Resources
  6. PHP 公式マニュアルには「trim() は Unicode のホワイトスペース(U+3000 など)を認識しない」と明記されています。また第2引数を指定すると ASCII 範囲外のバイト列を文字単位ではなくバイト単位で扱うため、マルチバイト文字の先頭バイトが誤って除去されることがあります。 – PHP: trim – Manual
  7. PHP の preg_* 関数は PCRE ライブラリを使います。/u フラグ(PCRE_UTF8)を付けると、パターンと対象文字列の両方を UTF-8 として処理し、\x{XXXX} 構文で Unicode コードポイントを直接指定できるようになります。 – PHP: Possible modifiers in regex patterns – Manual
  8. checked() は2つの値を比較して等しければ checked='checked' を出力する WordPress のヘルパー関数です。<input> タグ全体を文字列として組み立てて wp_kses_post() に渡すのではなく、<input> のHTMLを直接 echo し、checked() で属性のみを動的に埋め込む書き方だとエスケープの問題が起きません。 – checked() – Function | WordPress Developer Resources