WordPress投稿本文を書き換えるためのPHP備忘録(目次をつける)

WordPress5.9にしたら、「見出しタイトル」でアンカーが自動でつく仕様になっています
いつの間にか見出しにアンカーつかなくなってました

そして、わたしのサイトでは更新した記事から目次がなくなっていました😂
link.hash(JavaScript)はURLエンコーディングされる〜

アンカーを消したい!!

「フィルターフックthe_content」をつかい、投稿本文のアンカーを消して、アルファベットと数字のアンカーに置換できました

PHPの関数「preg_match_all ・str_replace」をつかいました
??でしたが便利なので、今後の備忘録としてまとめます

まずは、

目次
  1. フィルターフックthe_contentのおさらい
  2. preg_matchとpreg_match_all
    1. PCRE関数の正規表現について
    2. preg_match
    3. preg_match_all
  3. preg_replaceとstr_replace
    1. preg_replace
    2. str_replace
  4. 最終のコード

フィルターフックthe_contentのおさらい

その前に、「アクションフックとフィルターフックの違い」

アクションフック
フックのタイミングで「なにかを実行」します、その後「なにも変更せず」に通常のフローに戻ります
フィルターフック
フックのタイミングで「なにかを乗っ取っり」「それを変更」「変更したものを返します」
*乗っ取った何かを使用します
フック
どのタイミングで呼ばれ、どのような処理をするか
WordPressには非常にたくさんのフックがあります
「do_actionとapply_filters」で既に作成さてています(または自分で作成します)
カスタムフック
アクションにはdo_actionを使用し、フィルターにはapply_filtersを使用してカスタムフックを作成できます(関数が被らないようにフック名の前にプレフィックスを付けます)
*「do_actionはアクションフック自体を作成」「apply_filtersはフィルターフック自体を作成」
フィルターの除去
remove_filter(’フックの名前’,’関数の名前)

「the_contentフック」はデータベースから取得した投稿コンテンツを画面に出力する前に適用されます
書き換えてしまうと「見出しブロック」に怒られると思いますwが、データベースは書き換えないので安心です
*エディタではアンカーは見出しのタイトルのままです

「the_content」は、重宝するフックの予感がします♪
ちなみに関数自体は「the_content()」は本文を画面に表示するときにつかい、「get_the_content()」は変数に一旦本文を格納してから使います

the_contentコアのコードを見る

wp-includes/post-template.php
the_contentでは本文にフィルターフックが適用される
*(参考)get_the_contentは本文をそのまま取得するだけです

function the_content( $more_link_text = null, $strip_teaser = false ) {
	$content = get_the_content( $more_link_text, $strip_teaser );

	/**
	 * Filters the post content.
	 *
	 * @since 0.71
	 *
	 * @param string $content Content of the current post.
	 */
	$content = apply_filters( 'the_content', $content );
	$content = str_replace( ']]>', ']]>', $content );
	echo $content;
}

//get_the_contentにはapply_filters関数はありません〜
function get_the_content( $more_link_text = null, $strip_teaser = false, $post = null ) {
	global $page, $more, $preview, $pages, $multipage;

	$_post = get_post( $post );

	if ( ! ( $_post instanceof WP_Post ) ) {
		return '';
	}

	// Use the globals if the $post parameter was not specified,
	// but only after they have been set up in setup_postdata().
	if ( null === $post && did_action( 'the_post' ) ) {
		$elements = compact( 'page', 'more', 'preview', 'pages', 'multipage' );
	} else {
		$elements = generate_postdata( $_post );
	}

	if ( null === $more_link_text ) {
		$more_link_text = sprintf(
			'<span aria-label="%1$s">%2$s</span>',
			sprintf(
				/* translators: %s: Post title. */
				__( 'Continue reading %s' ),
				the_title_attribute(
					array(
						'echo' => false,
						'post' => $_post,
					)
				)
			),
			__( '(more…)' )
		);
	}

	$output     = '';
	$has_teaser = false;

	// If post password required and it doesn't match the cookie.
	if ( post_password_required( $_post ) ) {
		return get_the_password_form( $_post );
	}

	// If the requested page doesn't exist.
	if ( $elements['page'] > count( $elements['pages'] ) ) {
		// Give them the highest numbered page that DOES exist.
		$elements['page'] = count( $elements['pages'] );
	}

	$page_no = $elements['page'];
	$content = $elements['pages'][ $page_no - 1 ];
	if ( preg_match( '/<!--more(.*?)?-->/', $content, $matches ) ) {
		if ( has_block( 'more', $content ) ) {
			// Remove the core/more block delimiters. They will be left over after $content is split up.
			$content = preg_replace( '/<!-- \/?wp:more(.*?) -->/', '', $content );
		}

		$content = explode( $matches[0], $content, 2 );

		if ( ! empty( $matches[1] ) && ! empty( $more_link_text ) ) {
			$more_link_text = strip_tags( wp_kses_no_null( trim( $matches[1] ) ) );
		}

		$has_teaser = true;
	} else {
		$content = array( $content );
	}

	if ( false !== strpos( $_post->post_content, '<!--noteaser-->' ) && ( ! $elements['multipage'] || 1 == $elements['page'] ) ) {
		$strip_teaser = true;
	}

	$teaser = $content[0];

	if ( $elements['more'] && $strip_teaser && $has_teaser ) {
		$teaser = '';
	}

	$output .= $teaser;

	if ( count( $content ) > 1 ) {
		if ( $elements['more'] ) {
			$output .= '<span id="more-' . $_post->ID . '"></span>' . $content[1];
		} else {
			if ( ! empty( $more_link_text ) ) {

				$output .= apply_filters( 'the_content_more_link', ' <a href="' . get_permalink( $_post ) . "#more-{$_post->ID}\" class=\"more-link\">$more_link_text</a>", $more_link_text );
			}
			$output = force_balance_tags( $output );
		}
	}

	return $output;
}

add_filter()の使い方

<?php add_filter( 
 $tag(フックの名前)
 $function_to_add(関数の名前)
 $priority(関数を実行する順序、初期値:10、小さいほど早く実行)
 $accepted_args(関数が受け取る引数の個数、 初期値:1) )
; ?>
* 対応するapply_filters() の実行時に渡される追加の引数を受け取ることができる
function 関数名( $the_content ) {
   //引数で受けっった何かを処理して
  //変更する処理...
  //変更したものを返す
    return $the_content;
}
add_filter( 'the_content', '関数名', 0 );

参考まで
「the_contentフック」の、登録済みの関数

  • add_filter( 'the_content', 'wptexturize' ); :エンティティ変換する関数
  • add_filter( 'the_content', 'convert_smilies' ); :絵文字をコードに変換する関数
  • add_filter( 'the_content', 'convert_chars' ); :メタタグを除去したり、br を適切な文字に変換する関数
  • add_filter( 'the_content', 'wpautop' ); :Pタグを付加する関数
  • add_filter( 'the_content', 'shortcode_unautop' ); :ショートコードにはPタグを付加しない関数
  • add_filter( 'the_content', 'prepend_attachment' );:添付ファイルにPタグを付加する関数

ここからが本題のPHP関数

preg_matchとpreg_match_all

  • 「preg_match」 は 正規表現によるマッチングを行う
  • 「preg_match_all 」は 繰り返し正規表現検索を行う (全て検索したい場合)

PCRE関数の正規表現について

正規表現わからなすぎて泣けてくる〜😂

正規表現とは、文字列のパターンを表現する表記法です
検索対象文字列に対して左から右〔文字列の初めから終わり〕の順にマッチングが行われます
いくつかの記号(メタ文字)でパターンを表現します

メタ文字とは特別な意味や役割を持つ文字のことです
メタ文字の例 \ ^$ .|[] {} ()?*+
*[](文字クラス)内で使えるメタ文字のメタ文字\^-

「\」の使用について

  • メタ文字を「普通の文字」として表現するために(「文字である」ことがわかるようにメタ文字の前につけます)
  • 非表示文字〔制御コードなど〕を パターン中に目に見える形で記述するため
    例: \n(改行)
  • 包括的な文字型を指定する
    例:\s(空白文字)
  • 簡単な言明(位置を条件として指定)するため
    例:\b(単語境界)

マッチする文字を指定する

1
1だけマッチする
ab
abだけマッチする
.
任意の1文字
*デフォルトでは改行文字とはマッチしません
hoge.は、hoge1でもhogeAでもマッチする
hogeはマッチしない
[ ]
文字クラスにまとめます
カッコ内の任意の文字
[ab]_123であれば「a_123」でも「b_123」でもマッチする
[^ ]
カッコ内以外の任意の文字
[^a]  a以外
[0-9]
任意の数字1文字
[a-z]
任意のアルファベット1文字(小文字)

文字型
文字型( \d, \D, \s, \S, \w, \W )は文字クラス内([])でも使えてマッチする文字を追加できます

\w
[a-zA-Z0-9]と同じ、任意の単語
\W
[^a-zA-Z0-9]と同じ、単語文字以外
\d
[0-9]と同じ、任意の数字
\D
[^0-9]と同じ、数字以外
\s
任意の空白文字
\S
空白文字以外

マッチする位置を指定(アンカー)

^
行の先頭
^a
abcはマッチする
cbaはマッチしない
$
行の末尾
a$
cbaはマッチする
abcはマッチしない

繰り返しの回数

*
直前の項目の「0回以上」の繰り返し
?
直前の項目が「0回か、1回だけ」ある
+
直前の項目の「1回以上」の繰り返し
{n}
直前の項目が「n回」繰り返す
{n, }
直前の項目が「n回以上」で繰り返す
{ , m}
直前の項目が「m回以下」で繰り返す
{n,m }
直前の項目が「n回以上 、m回以下」で繰り返す

正規表現は、貪欲で残りのパターンが失敗しない限り、上限まで出来るだけ多くマッチします
* + を使うときに最短でマッチ(できるだけ少ない回数だけマッチします)させるには、* + の後に ? を付けます

グループ「()」と選択肢 「|」

() は、正規表現をいくつかのグループ(サブパターン)に分けます
()範囲の指定ができます
|は、またはを意味します
複数のパターンのどちらかにマッチさせる場合は
() でグループを作り、| で複数のアイテムのいずれ、()|()にする

()値の取得(キャプチャ)ができます
パターン全体としてマッチに成功した場合、サブパターンにマッチした部分の値が返され、 開きカッコの数が(1から始まって)左から右に数えられ、 キャプチャ用サブパターン番号が指定されます。
(?Pパターン) という記法を用いて サブパターンに名前をつけることができ、マッチ時に返される配列内で名前でもアクセスできます
通常マッチしないときでも個別の後方参照番号が振られますが(?| 構文で重複した参照番号にできます
(?|(Sat)ur|(Sun))dayで、SunとSat の両方が後方参照1に格納されます

後方参照について
()にはサブパターンを後で参照するという意味もあります
「\に続いて1以上の数値」を記述したものは、()の中より左にあるキャプチャ用サブパターンに対する後方参照です(数値はグループの番号です)
*数値以上の個数のキャプチャ用サブパターンの開きカッコが必要です

  (sens|respons)e and \1ibility

"sense and sensibility" と "response and responsibility" にマッチする
"sense and responsibility" にはマッチしない

(?: 」で指定すると、後方参照されないグループの指定となります

パターン修飾子でオプションを指定できます
通常指定する位置は2番目のスラッシュの後に指定します「”/abc.*/i”」

修飾子説明
i大文字と小文字を区別しない
m^ または $があるとき有効、各改行の直前と直後にそれぞれマッチさせる
*オプションをつかない場合は、複数行からなるときでも単一の行からなるとして処理されます
sドットメタ文字(.任意の1文字)に改行も含めます
xパターンの空白文字は完全に無視されます
uパターンと対象文字列はUTF-8として処理されます

オプションの設定はパターン中でも変更できます(パターンオプション)
サブパターンのカッコの外で行われたとき:パターンの後ろの部分に適用されます
/ab(?i)c/ は abcとabCにマッチ
サブパターンの内部のときはそのサブパターンの設定が行われた場所以降の部分にのみ影響します
(a(?i)b)cはabcとaBc にマッチ

行頭「^」と行末「$」以外の位置の指定について
「言明」は、マッチがある特定の位置でだけ可能だという条件を指定するもので、 検索対象文字列から文字自体にマッチしません

\b
単語境界
backgroundからgroundだけを探すには、 /\bground\b/
(注意)[\b]文字クラスの中だとバックスペースの意味になります
\B
単語境界ではない位置
(?=パターン)
後に続く文字がパターンに一致することが条件
\w+(?=;)」はセミコロンが後に続く単語にマッチ
(?!パターン)
後に続く文字がパターンに一致しないことが条件
(?!foo)bar」は”bar” が後ろに続かない “foo” にマッチ

preg_match

「preg_match」のつかい方
patternで指定した正規表現によりsubject を検索します
マッチした場合に「1」 マッチしなかった場合は「0」を返しますが
matchesを指定すると検索結果が代入されます
$matches[0] にはパターン全体にマッチしたテキストが代入され、
$matches[1] には 1 番目のキャプチャ用サブパターンにマッチした 文字列が代入されます
サブパターンとは、()括られたパターンのこと(1つの単位として扱える)
キャプチャとは、()内の文字列を記憶し、参照出来るようにすること
$flagsに設定できるフラグは「PREG_OFFSET_CAPTURE」「PREG_UNMATCHED_AS_NULL」
$offset を使用して 検索の開始位置を (バイト単位で) 指定できます

preg_match(
    string $pattern,
    string $subject,
    array &$matches = null,
    int $flags = 0,
    int $offset = 0
): int|false

$flagsに設定できるフラグについて

  • PREG_OFFSET_CAPTURE:各マッチに対応する文字列のオフセットも(バイト単位で)返されmatchesの値は配列になります(バイト単位で先頭から何文字目)
  • PREG_UNMATCHED_AS_NULL:マッチしなかったサブパターンはnullとして通知されます(PHP7.2.0以降)

ちなみに、ある文字列が他の文字列内に含まれているかどうかを調べるだけなら「strpos() 」をつかいます

PCRE関数を使うときには、パターンをデリミタ(要素の区切りを表す記号や特殊な文字)で囲みます
デリミタ文字で囲った場合はパターン内でメタ文字を使うときはエスケープが必要
()、 {}、[] <> はどれも、角括弧形式のデリミタとして有効(パターン内でメタ文字を使うときのエスケープは不要)

第一引数はシングルクォートで囲み、パターンは通常「/」で囲みます
'/正規表現パターン/'

PHP関数リファレンスからの引用
第三引数を指定すると検索結果が代入されます
$matches[0] にはパターン全体にマッチしたテキストが代入
$matches[1] には 1 番目のキャプチャ用サブパターンにマッチした 文字列が代入
$matches[2] には 2 番目のキャプチャ用サブパターンにマッチした 文字列が代入
(?Ppattern) で サブパターンに名前をつけているのでマッチ時に返される配列内で名前アクセスできる

<?php

$str = 'foobar: 2008';

preg_match('/(?P<name>\w+): (?P<digit>\d+)/', $str, $matches);

print_r($matches);

?>

//出力
Array
(
    [0] => foobar: 2008
    [name] => foobar
    [1] => foobar
    [digit] => 2008
    [2] => 2008
)

preg_match_all

「preg_match_all」はsubjectを検索し、 patternに指定した正規表現にマッチした すべての文字列を、flagsで指定した順番で、matchesに代入します

正規表現にマッチすると、そのマッチした文字列の後から検索が続行されます

$flagsに設定できるフラグについて

  • 「PREG_PATTERN_ORDER」:デフォルトの設定で、matches[0] はパターン全体にマッチした文字列の配列、 $matches[1] は第1のキャプチャ用サブパターンにマッチした文字列の配列といった順番
  • PREG_SET_ORDER:(パターン全体にマッチした文字列は除きたいとき)$matches[0] は 1 回目のマッチングでキャプチャした値の配列、 $matches[1] は 2回目のマッチングでキャプチャした値の配列といった順序となります
  • 「PREG_OFFSET_CAPTURE」:各マッチに対応する文字列のオフセットも(バイト単位で)返されます
  • 「PREG_UNMATCHED_AS_NULL」:マッチしなかったサブパターンはnullとして通知されます

PHP関数リファレンスからの引用で、HTMLタグにマッチするものを見付ける
1回目<b>は2回目は<a>の結果がそれぞれサブパターンごとに格納された多次元配列になる

<?php
$html = "<b>bold text</b><a href=howdy.html>click me</a>";
/*
/(<([\w]+)[^>]*>)(.*?)(<\/\\2>)/が全体
サブパターン1 (<([\w]+)[^>]*>) タグ
サブパターン2 ([\w]+) タグの文字
サブパターン3 (.*?) タグではさまれた部分
サブパターン4 (<\/\\2>) 閉じタグ \2はサブパターン2([\w]+)の後方参照
*/
preg_match_all("/(<([\w]+)[^>]*>)(.*?)(<\/\\2>)/", $html, $matches, PREG_SET_ORDER);

foreach ($matches as $val) {
    echo "matched: " . $val[0] . "\n";
    echo "part 1: " . $val[1] . "\n";
    echo "part 2: " . $val[2] . "\n";
    echo "part 3: " . $val[3] . "\n";
    echo "part 4: " . $val[4] . "\n\n";
}
?>

//出力
matched: <b>bold text</b>
part 1: <b>
part 2: b
part 3: bold text
part 4: </b>

matched: <a href=howdy.html>click me</a>
part 1: <a href=howdy.html>
part 2: a
part 3: click me
part 4: </a>

preg_replaceとstr_replace

置換します

preg_replace

正規表現検索および置換を行います
*正規表現が必要ないときはstr_replaceで置換します

「preg_replace」のつかい方
subjectに関してpatternを用いて検索してreplacementに置換します
subject引数が配列の場合は配列、その他の場合は文字列を返します
パターンがマッチして置換が行われたときは「新しいsubject」
マッチしなかったときは「subjectをそのまま」
エラーが発生したときは「null」を返します
limit:各パターンによる 置換を行う最大回数で、デフォルトは -1 (制限無し)
count:置換回数

preg_replace(
    string|array $pattern,
    string|array $replacement,
    string|array $subject,
    int $limit = -1,
    int &$count = null
): string|array|null

replacementについて

  • 「replacementが文字列」で「patternが配列」のとき
    すべてのパターンが「replacementの文字列」に置換されます
  • 「patternとreplacementが配列」のとき
    各patternは「対応するreplacement」に置換されます
  • 「replacementの要素数がpatternよりも少ない」とき
    余った patternは「空文字」に置換されます

配列の添字を使って、どの pattern が、どの replacement に置換されるかを指定しようとする場合は、 preg_replace() をコールする前に、各配列に対し ksort() を実行しておくべきです。

https://www.php.net/
<?php
$string = 'The quick brown fox jumps over the lazy dog.';
$patterns = array();
$patterns[0] = '/quick/';
$patterns[1] = '/brown/';
$patterns[2] = '/fox/';
$replacements = array();
//index
$replacements[2] = 'bear';
$replacements[1] = 'black';
$replacements[0] = 'slow';
ksort($patterns);
ksort($replacements);
echo preg_replace($patterns, $replacements, $string);
?>

//出力
The slow black bear jumps over the lazy dog.

replacementでは、サブパターンにマッチした内容は「$0~$99」で参照を指定できます
「$0」 は パターン全体にマッチするテキストを参照します
{}をつけることで数字リテラルがつづく場合の対応ができます「${n}」

<?php
/*
'abc def ghi' という文字列から
'/(w+) (w+) (w+)/i' で正規表現でマッチングさせた場合、
${1}が'abc'、${2} が'def'、${3} が'ghi' 
*/
echo preg_replace('/(w+) (w+) (w+)/i', '${2}', 'abc def ghi') ;
?>

//出力
def

str_replace

正規表現検索を使わないときは「str_replace 」で検索文字列に一致したすべての文字列を置換できます

「str_replace」のつかい方
subjectの中のsearchを全てreplaceに置換して「置換後の文字列あるいは配列」を返します
count:置換が行われた箇所の個数が格納されます
*大文字小文字を区別します(区別せずに置換するには str_ireplace() )

str_replace(
    array|string $search,
    array|string $replace,
    string|array $subject,
    int &$count = null
): string|array
  • replaceの値がsearchよりも少ないときは残りの部分には「空の文字列」が使用されます
  • searchが配列でreplaceが文字列のときは「この置換文字列」が 「searchの各値」に使用されます
  • subject が配列のときは subjectの各エントリについて検索と置換が行われ、 戻り値は同様に配列となります

最終のコード

「見出しタイトル」のアンカーをつけかえて目次を設置

コードを見る

<?php
//見出しのidを付け替える
function modify_headings_id($the_content)
{
    $pattern = '/^<h([2-6]).*?.+?<\/h[2-6]>$/im';
    /*
        $headings[0]:見出し文字列の配列
        $headings[1]:h2なら"2"が格納された配列
    */
    if (!preg_match_all($pattern, $the_content, $headings)) {
        //見出しがないときは終了
        return $the_content;
    }
    // $headings[0]の要素数だけループ
    $count = count($headings[0]);
    for ($i = 0; $i < $count; $i++) {
        //置き換える値を$id_strに代入
        $id_str = '<h' . $headings[1][$i] . ' id="chapter-' . $i . '"';
        //idを置き換える値に置換して$replaced_headingに代入
        $replaced_heading =
            str_replace('<h' . $headings[1][$i], $id_str, $headings[0][$i]);
        //本文の見出しを$replaced_headingで置換
        $the_content =
            str_replace($headings[0][$i], $replaced_heading, $the_content);
    }
    return $the_content;
}
add_filter('the_content', 'modify_headings_id', 0);

//目次の挿入
//目次を設置

function add_my_toc($the_content){
    if (!is_single()) {
        return $the_content;
    }
    // パターン
    $pattern = '/^<h([2-6]).+?id\s*=\s*"(.+?)".*>(.+?)<\/h[2-6]>$/im';
    /*
        $toc_headings[0]:マッチした文字列
        $toc_headings[1]:見出しレベルの数字
        $toc_headings[2]:id設定値
        $toc_headings[3]:見出し文
    */
    if (!preg_match_all($pattern, $the_content, $toc_headings)) {
        return $the_content;
    }

    $level = '';
    $toc = '<dl class="toc" id="toc"><dt class="toc__title">目次</dt><dd><ol>';
    $hierarchy = 0;
    $count = count($toc_headings[0]);

    for ($i = 0; $i < $count; $i++) {
         //h2~h6の階層をlevel(0~4)で管理
        if ($toc_headings[1][$i] === '2') {
            $level = 0;
        }
        if ($toc_headings[1][$i] === '3') {
            $level = 1;
        }
        if ($toc_headings[1][$i] === '4') {
            $level = 2;
        }
        if ($toc_headings[1][$i] === '5') {
            $level = 3;
        }
        if ($toc_headings[1][$i] === '6') {
            $level = 4;
        }
        //階層がネストされるとき
        while ($hierarchy < $level) {
            $toc .= '<ol>';
            $hierarchy++;
        }
        //閉じるとき
        while ($hierarchy > $level) {
            $toc .= '</li></ol></li>';
            $hierarchy--;
        }
        //aタグでタイトルを囲む 
        $toc .= '<li><a href="#' . $toc_headings[2][$i] . '">' . $toc_headings[3][$i] . '</a>';
    }//for文終了

    $toc = $toc . '</ol></dd></dl>';
    //最初のh2の前に$tocをつけた本文に置換する
    $the_content = str_replace($toc_headings[0][0], $toc . $toc_headings[0][0], $the_content);

    return $the_content;
}

add_filter('the_content', 'add_my_toc', 10);

今後のWordPressアップデートが少しこわかったりします💦

ついていけるだろうか・・・

,