入れ子の正規表現は不可能?

入れ子の正規表現は不可能?

前記事「WordPress:クラシックコンテンツを一括でブロック変換」の「03.問題点」を解消しようとして壁にぶち当たりました。記事の中から入れ子になったリストタグを削除しようと正規表現で試みたのですが、どうやらダメなようです。

01記事に埋め込まれたリストタグの目次を削除したい…

やりたいことは次のとおりです。

  • はてブログから移行した記事内には目次がリストタグで書き込まれている
  • その記事が300超ある
  • WordPress に移行後はショートコードで自前の目次を挿入している
    WordPressに目次(プラグインなし、保存型)
  • 記事内埋め込みの目次を削除してショートコードを挿入する

記事内の目次は、たとえば次のように入っています。

<ul id="toc" class="table-of-contents">
  <li><a href="#現在うまくいっている方法">現在うまくいっている方法</a>
    <ul>
      <li><a href="#コンテナを一般ユーザーで稼働させる">コンテナを一般ユーザーで稼働させる</a></li>
    </ul>
  </li>
  <li><a href="#docker-composeyml">docker-compose.yml</a></li>
  <li><a href="#実際にやってみる">実際にやってみる</a>
    <ul>
      <li><a href="#コンテナを立ち上げる">コンテナを立ち上げる</a></li>
      <li><a href="#WordPress-をインストールする">WordPress をインストールする</a></li>
      <li><a href="#ディレクトリの所有者を一般ユーザーに変更する">ディレクトリの所有者を一般ユーザーに変更する</a></li>
      <li><a href="#一般ユーザーで稼働しているか確認する">一般ユーザーで稼働しているか確認する</a></li>
    </ul>
  </li>
</ul>

はてなブログの目次は記事内に [:contents] と入れておきますと、記事内の H3 以下(だったと思う…)の heading要素が <ul id="toc" class="table-of-contents"> という ul要素内の li 要素として括られて自動的に記事内に挿入されます。ですのでパターンはみな同じです。

ただ、この例では1階層ですが、なかには2階層、3階層のものもあります。

これを正規表現を使って一括削除しようとしたのですが、最後の </ul> を特定することがどうしてもできません。入れ子になっていなければ

/(<ul id="toc" class="table-of-contents">[\s\S]*?<\/ul>)/s

で取り出せるのですが、1層でも入れ子になっていますととたんに難しくなります。当然、上の正規表現では入れ子になっている1つ目の </ul> までしか一致しません。じゃあ複数候補にしたらどうかと、

/(<ul id="toc" class="table-of-contents">[\s\S]*?<\/ul>|<ul id="toc" class="table-of-contents">[\s\S]*?<\/ul>[\s\S]*?<\/ul>)/s

としてみても当然先の候補に一致してしまいますので同じことです。さらに候補を逆にしてみますと1階層の目次の場合は目次ではない </ul> を探しにいってしまいます。

こりゃダメだということでググってみましたら再帰的パターンというものがあるようです。ただ今試みていることは恒常的に使うものではなく一回こっきりのものですし、その解説を読んでみても私の理解の範囲を越えているところもありますので今回は断念して他の方法を考えることにしました。

結論としては、深さのわからない入れ子構造のものを正規表現でマッチさせることは一般的な正規表現ではできないようです。

021行ずつ配列に読み込みチェックする

さて、どうしようかということで、XML にしてパースするとか、Javascript でやってみるかとか考えたのですが、こんなことで時間を使っているのもどうかと思い、結局、PHP で各記事を1行ずつ配列に読み込み、1行ずつチェックして目次であれば削除して残りの行を記事テキストに戻すという方法で成功しました。

また、先にブロックコンテンツに変換してから parse_blocks を使って削除する方法もあるかと思います。ただ、その場合でも考え方は次の方法と同じだと思います。

ということで、以下は単なる記録用で汎用性はまるでありません(笑)。

<?php
require_once("./wp-load.php");

$query = new WP_Query( array( 'nopaging' => true )  );
if ( $query->have_posts() ) {
	while ( $query->have_posts() ) {
		$query->the_post();

		if(!has_blocks()){
			$text = $query->post->post_content;
			$lines = preg_split("/\r\n|\r|\n/", $text);
			$new_lines = [];

			$flg = $tocflg = false;
			$search = '/<li|li>|<ul|ul>/';
			foreach($lines as $line){
				if (str_contains($line, '<ul id="toc" class="table-of-contents">')){
					$flg = true;
				}else if($flg){
					if(!(preg_match($search, $line))){
						$new_lines[] = PHP_EOL;
						$new_lines[] = '[toc]';
						$new_lines[] = PHP_EOL;
						$flg = false;
						$tocflg = true;
					}
				}else{
					$new_lines[] = $line;
				}
			}
			if($tocflg){
				$query->post->post_content = implode(PHP_EOL, $new_lines);
				wp_update_post( $query->post );
			}
		}
	}
}
wp_reset_postdata();

やっていることは、

  • ループで各記事を順番に読み込み、該当記事がブロックコンテンツでなければ、記事を配列に取り込み、1行ずつ目次でないかチェックします
  • 該当行に <ul id="toc" class="table-of-contents"> があれば、それは削除したい目次の始まりですので、その後の行に /<li|li>|<ul|ul>/ がないかをチェックしていきます。
  • あれば削除、なければ目次が終了したと判断して次に進みます。
  • ループを完了したら、目次の削除された配列を文字列に戻し、データベースに上書きします。

これをサーバのルートに入れて実行すれば記事に埋め込まれていた目次が削除され、ショートコード[toc]が埋め込まれます。

これで前記事「WordPress:クラシックコンテンツを一括でブロック変換」を実行すれば、すべて同じブロックコンテンツになります。