長いまとめ記事では、読者が「自分の知りたい項目まで飛べる」目次があると読みやすくなります。また、検索エンジンに「これは記事で、著者は誰で、サイトのどの位置にあるページか」を機械が読める形で伝えるのが構造化データです。どちらも「WordPress 構造化データ」「目次 自動生成」などで調べると多くのプラグインが見つかりますが、仕組みを理解しておかないと、表示と中身がずれたまま気づけません。
「目次と構造化データを入れたい。構造化データを入れれば検索結果で目立つようになるの?」
結論から言うと、目次は見出しの id と目次のリンクを「同じ HTML」から作り、構造化データは画面に表示している内容と同じデータ源から作るのが、ずれを防ぐいちばん確実な方法です。そして、構造化データは内容を正確に伝えるための実装で、リッチリザルト(=検索結果での特別な表示)が出ることは保証されません。これは Google 自身がドキュメントで明記しています。
前回の第7回では、人気記事ランキングと関連記事を作りました。第8回の今回は、目次ブロック、パンくずリスト、JSON-LD の出力をプラグインに追加します。
今回のゴールと、構造化データでできること・できないこと
完成すると、記事ページは次のようになります。
- タイトルの上にパンくずリスト(ホーム / カテゴリ / 記事タイトル)が表示される
- 本文に「目次」ブロックを置くと、h2・h3 の見出しへのリンク一覧が表示される(h3 は直前の h2 の下に入れ子)
<head>に JSON-LD(=構造化データを JSON で書く形式)が出力される。記事は Article と BreadcrumbList、まとめ・ランキング記事はそれに加えて ItemList
3種類の構造化データと Google の扱い(2025年9月時点)
Google 検索セントラルのドキュメント(2025年9月時点の版を確認)での扱いを整理すると、次のとおりです。
| 種類 | 何を表すか | Google のドキュメントでの位置付け |
|---|---|---|
| Article | 記事のタイトル・著者・公開日・更新日・画像 | 必須プロパティはなく、該当する推奨プロパティ(author・datePublished・dateModified・headline・image)をできるだけ追加する |
| BreadcrumbList | サイト内でのページの位置 | ListItem を2つ以上含め、各項目に position・name・item(URL)を入れる。最後の項目の item は省略可 |
| ItemList | ページ内の項目の並び | カルーセル(横にスワイプする表示)の対象は、コースリスト・映画・レシピ・レストランと組み合わせた場合のみ |
ここで大事なのは ItemList です。まとめ記事に ItemList を付けても、Google のカルーセル表示の対象にはなりません。今回 ItemList を出すのは、「このページは順番のある項目の一覧である」という構造を正確に表すためで、特定の表示を狙ったものではありません。
📰 出典:Google 検索セントラル「カルーセル(ItemList)の構造化データ」
また、構造化データの一般的なガイドラインには、正しくマークアップしても検索結果に表示されるとは限らないこと、ページの読者に表示されない内容をマークアップしてはいけないことが書かれています。今回、パンくずリストを画面にも表示するようにしたのはこのためです。
📰 出典:Google 検索セントラル「構造化データに関する一般的なガイドライン」
今回追加・変更したファイル
| ファイル | 内容 |
|---|---|
プラグイン includes/toc.php | 見出しへの id 付与と目次の差し込み(新規) |
プラグイン blocks/toc/ | 目次ブロック(新規。表示は差し込み位置の目印だけ) |
プラグイン includes/schema.php | パンくずの項目作成と JSON-LD の出力(新規) |
プラグイン blocks/breadcrumb/ | パンくずリストブロック(新規) |
プラグイン curation-tools.php | 読み込み・ブロック登録、バージョン 0.8.0 |
テーマ templates/single*.html・style.css | 3つの記事テンプレートの先頭にパンくずを追加、バージョン 0.8.0 |
企画の段階では「目次と JSON-LD」だけの予定でしたが、上に書いたガイドラインに合わせるため、パンくずリストの表示ブロックを追加しました。
目次:見出しの id とリンクを同じ HTML から作る
目次で最もよく起きる不具合は、「目次のリンク先の id」と「見出しに付いた id」がずれて、クリックしても移動しないことです。目次を作る処理と見出しに id を付ける処理を別々に書くと、見出しの数え方が少し違うだけでずれてしまいます。
そこで今回は、本文の HTML が組み立て終わった後に1回だけ処理し、見出しへの id 付与と目次の作成を同時に行うようにしました。目次ブロック自体は「ここに目次を入れる」という目印を出すだけです。
| 順番 | 処理 |
|---|---|
| 1 | 本文のブロックが HTML に変換される(目次ブロックは目印のコメントを含む <nav> を出力) |
| 2 | the_content フィルター(優先度 20)で、本文の h2・h3 に id を付けながら見出しの文字を集める |
| 3 | 目印の位置に、集めた見出しへのリンク一覧を差し込む。見出しが2つ未満なら目次ごと取り除く |
wp-content/plugins/curation-tools/includes/toc.php(curation_tools_toc_add_anchors() の抜粋。既存の id を集める1回目の処理と、見出し終了時の処理を省略)
$processor = new WP_HTML_Tag_Processor( $html );
while ( $processor->next_token() ) {
$name = $processor->get_token_name();
if ( 'H2' === $name || 'H3' === $name ) {
// (閉じタグのときは、集めた文字を $items に追加して continue)
$id = $processor->get_attribute( 'id' );
if ( ! is_string( $id ) || '' === $id ) {
do {
++$number;
$id = 'toc-' . $number;
} while ( isset( $used[ $id ] ) );
$used[ $id ] = true;
$processor->set_attribute( 'id', $id );
}
$current = array(
'id' => $id,
'level' => 'H2' === $name ? 2 : 3,
'text' => '',
);
continue;
}
// 見出しの中の文字だけを集める(<strong> などのタグは無視し、文字参照はデコード済みの値になる)。
if ( null !== $current && '#text' === $processor->get_token_type() ) {
$current['text'] .= $processor->get_modifiable_text();
}
}
WP_HTML_Tag_Processor は WordPress に組み込まれている HTML の読み取り・書き換え用のクラスです。正規表現で HTML を書き換えると、属性の書き方の違いや入れ子で壊れやすいのですが、このクラスを使うとタグ単位で安全に属性を追加できます。
📰 出典:WordPress Developer Resources「WP_HTML_Tag_Processor」
id の決め方は次のとおりです。
- 編集者がブロックの「HTML アンカー」で id を指定した見出しは、その id をそのまま使う
- それ以外の見出しには
toc-1、toc-2… を順に付ける。本文中で既に使われている id とは重ならないようにする - 目次には見出しの文字だけを使い、
<strong>などの装飾は外す。出力時はesc_html()とesc_attr()でエスケープする
目次ブロックの render.php は、差し込み位置の目印(HTML コメント)を含む <nav aria-label="目次"> だけを出力します。見出し用のタグ(h2)を使わず「目次」を <p> にしているのは、目次の見出し自体が目次に載ってしまわないようにするためです。
blocks/toc/render.php(冒頭のコメントと phpcs コメントを省略)
if ( ! is_singular( 'post' ) ) {
return;
}
?>
<nav <?php echo get_block_wrapper_attributes(); ?> aria-label="目次" data-curation-toc>
<p class="curation-toc__title">目次</p>
<?php echo CURATION_TOOLS_TOC_MARKER; ?>
</nav>
block.json では "multiple": false を指定し、1つの記事に目次ブロックを1つしか置けないようにしました。また、目次のリンクで移動したときに見出しが画面の端に貼り付かないよう、CSS の scroll-margin-top で少し余白を取っています。
パンくずと JSON-LD を同じ関数から作る
パンくずリストは「ホーム → カテゴリ → 記事」の順です。この項目を作る関数を1つだけ用意し、画面のパンくずブロックと JSON-LD の両方から呼び出します。こうすれば、カテゴリ名を変えても表示と構造化データが必ず一致します。
wp-content/plugins/curation-tools/includes/schema.php(curation_tools_get_breadcrumb_items() の抜粋)
$categories = get_the_category( $post_id );
if ( ! empty( $categories ) ) {
$category = $categories[0];
$term_ids = array_reverse( get_ancestors( $category->term_id, 'category', 'taxonomy' ) );
$term_ids[] = $category->term_id;
foreach ( $term_ids as $term_id ) {
$term = get_term( $term_id, 'category' );
$link = $term instanceof WP_Term ? get_term_link( $term ) : '';
if ( is_string( $link ) && '' !== $link ) {
$items[] = array(
'name' => $term->name,
'url' => $link,
);
}
}
}
$items[] = array(
'name' => curation_tools_plain_text( get_the_title( $post_id ) ),
'url' => get_permalink( $post_id ),
);
カテゴリが複数ある記事は、WordPress が返す順(名前順)の最初のカテゴリを使います。第2回で「カテゴリは少数固定、1記事1カテゴリを基本にする」と決めたので、通常はこれで困りません。
Article:著者は画面の著者ボックスと同じ人を載せる
Article には、Google のドキュメントで推奨されているプロパティのうち、このサイトで実際に表示している情報だけを入れます。
includes/schema.php(curation_tools_build_schema() の抜粋)
$article = array(
'@type' => 'Article',
'@id' => $permalink . '#article',
'mainEntityOfPage' => $permalink,
'headline' => curation_tools_plain_text( get_the_title( $post ) ),
'datePublished' => get_the_date( DATE_W3C, $post ),
'dateModified' => get_the_modified_date( DATE_W3C, $post ),
);
if ( $author ) {
$person = array(
'@type' => 'Person',
'name' => $author->display_name,
'url' => get_author_posts_url( $author->ID ),
);
$profile_url = (string) get_user_meta( $author->ID, 'curation_profile_url', true );
if ( '' !== $profile_url ) {
$person['sameAs'] = array( $profile_url );
}
$article['author'] = $person;
}
- 日付は
DATE_W3C(ISO 8601 形式)で、+09:00のようにタイムゾーンを含めます。Google のドキュメントでもタイムゾーンを含めることが推奨されています。 - 著者は
Person型で、urlに第6回で作った著者アーカイブ、sameAsにプロフィールの外部 URL(設定がある場合のみ)を入れます。Google のドキュメントでは、著者を一意に特定できるページへの URL をurlかsameAsで示すことが強く推奨されています。 - 画像はアイキャッチがある記事だけ入れます。
📰 出典:Google 検索セントラル「記事(Article、NewsArticle、BlogPosting)の構造化データ」
監修者は構造化データに入れていません。Google の Article のドキュメントには監修者を表すプロパティがなく、schema.org にある近いプロパティ(reviewedBy)は Article ではなく WebPage に対するものだからです。監修者の情報は、第6回の著者ボックスで画面に表示するだけにしています。
ItemList:まとめリストの見出しを項目にする
まとめ・ランキングのテンプレート(第3回)を使った記事では、第5回の「まとめリスト」パターン(class に curation-matome-list を持つグループ)の中にある見出しを集め、ItemList の項目名にします。本文のブロックを parse_blocks() で読み、グループの中を順にたどって見出しの文字を取り出しています。項目が2つ未満なら ItemList は出しません。
JSON-LD を安全に出力する
includes/schema.php(curation_tools_print_schema() の抜粋)
$data = array(
'@context' => 'https://schema.org',
'@graph' => curation_tools_build_schema( $post ),
);
$json = wp_json_encode( $data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
if ( false === $json ) {
return;
}
wp_print_inline_script_tag( $json, array( 'type' => 'application/ld+json' ) );
JSON を文字列の連結で組み立てると、記事タイトルに " が入っただけで壊れます。配列を作って wp_json_encode() で変換すれば、引用符などは正しくエスケープされます。
さらに注意したいのが </script> です。JSON-LD は <script> タグの中に書くため、タイトルなどに「</script>」という文字列が含まれると、そこでタグが閉じてしまいます。JSON_HEX_TAG を指定すると < と > が Unicode のエスケープ表記(JSON として読み込むと元の文字に戻る書き方)に変換されるので、この問題を防げます。
📰 出典:WordPress Developer Resources「wp_json_encode()」
動作確認の方法
第0回・第2回の手順の後、プラグインをビルドしてから確認しました(筆者の環境で確認済みです)。テスト用に、目次ブロック・h2/h3 の見出し・まとめリストを含む「まとめ」テンプレートの記事を作っています。
# JSON-LD を取り出して、JSON として正しいか確認する
curl -s http://localhost:8088/<記事のスラッグ>/ \
| sed -n '/application\/ld+json/,/<\/script>/p' | sed '1d;$d' \
| python3 -m json.tool
wp_print_inline_script_tag() は <script> タグと JSON の間で改行するため、1行で抜き出すのではなく、開始タグから終了タグまでの行を取り出しています。
- JSON-LD が
python3 -m json.toolで正しく読み込め、Article・BreadcrumbList・ItemList の3つが入っている - 著者の
url(著者アーカイブ)とsameAs(プロフィール URL)、日付の+09:00、アイキャッチの画像 URL が入っている - タイトルに「
</script>」の文字列を含む記事でも、JSON-LD の中では<と>がエスケープ表記に変換され、スクリプトタグが途中で閉じない - Schema Markup Validator(validator.schema.org)に JSON-LD を貼り付けて検証し、3種類ともエラー・警告が0件
- 目次のリンクが見出しの id と一致し、ページ内の id に重複がない。ヘッドレスブラウザでリンクをクリックすると該当の見出しまで移動する
- 見出しが1つだけの記事では、目次ブロックを置いても何も表示されない。一覧ページやフィードには目次・JSON-LD が出ない
Google のリッチリザルトテストは、ローカル環境の URL を読み込めないため今回は実施していません。公開環境で、URL またはコードを貼り付けて確認してください。
つまずきやすい点・セキュリティ上の注意
- 表示と構造化データのずれ:構造化データだけに情報を書き、画面に出さないのはガイドライン違反になり得ます。今回のように、表示と構造化データを同じ関数・同じ本文から作る設計にしておくと、ずれが起きにくくなります。
- プラグインの重複:SEO 系のプラグインを後から入れると、同じ種類の構造化データが二重に出力されることがあります。導入前に、どの機能がどの構造化データを出しているかを確認してください。
- 見出しの番号:第5回のまとめリストの「1.」「2.」は CSS で表示しているため、目次や ItemList の項目名には番号が入りません。目次は番号付きリスト(
<ol>)で表示しているので、読者から見た順番は保たれます。 - パスワード保護の記事:本文を見られない記事では、JSON-LD を出力しないようにしています。
発注者向けメモ:構造化データは「正確さ」を保証する仕組みとして頼む
構造化データは、正しく実装しても検索順位や特別な表示を保証するものではありません。発注の際は「リッチリザルトを出す」ではなく「ページの内容を正確に伝える構造化データを、表示とずれない仕組みで出す」ことを要件にしましょう。
- 何を出すか:Article・パンくず・一覧など、どの種類を、どの種類のページに出すか
- 何から作るか:画面の表示と同じデータ源から作っているか。手入力の項目がある場合、誰が更新するか
- プラグインとの役割分担:SEO プラグインを使う場合、自作の構造化データと重複しないか
- 確認方法:公開前にリッチリザルトテストや Schema Markup Validator で確認する工程を、受け入れテストに含めるか
工数が増えるのは、商品・レビュー・イベントなど種類ごとに細かなルールがある構造化データを扱う場合や、複数のカテゴリ・複数の経路のパンくずを出し分ける場合です。
打ち合わせでは、次のように聞いてみてください。
- 「構造化データの内容は、画面に表示している内容とどうやって一致させていますか?」
- 「タイトルに記号や
</script>のような文字列が入っても、JSON-LD が壊れない作りになっていますか?」 - 「リッチリザルトテストでの確認は、どの工程で誰が行いますか?」
まとめと次回予告
第8回では、目次と構造化データを作りました。
- 目次は、本文の HTML が組み立て終わった後に見出しへの id 付与と目次の作成を同時に行い、ずれを防ぐ
- パンくずは画面の表示と JSON-LD を同じ関数から作る
- Article には画面に表示している著者・日付・画像を入れ、日付はタイムゾーン付きの形式にする
- まとめ記事の ItemList は構造を正確に表すためのもので、Google のカルーセル表示の対象ではない
- JSON-LD は
wp_json_encode()とJSON_HEX_TAGで安全に出力する。リッチリザルトの表示は保証されない
次回は「OGP・SNS カードと「広告・PR表記」― ステマ規制への対応」です。SNS で共有されたときのカード表示と、広告を含む記事の PR 表記を自動で入れる仕組みを作ります。
この連載の記事一覧
この記事は連載「WordPressで作るキュレーションメディア」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【WordPressで作るキュレーションメディア 第0回】キュレーションメディアの要件と「低品質まとめ」の教訓 ― Docker で WordPress 6.8 の土台を作る
- 【WordPressで作るキュレーションメディア 第1回】theme.json でデザイントークンを定義する(子テーマの基本)
- 【WordPressで作るキュレーションメディア 第2回】カテゴリ・タグ・「特集」― タクソノミー設計
- 【WordPressで作るキュレーションメディア 第3回】記事・まとめ・ランキングのテンプレートとパターン
- 【WordPressで作るキュレーションメディア 第4回】「引用元カード」ブロック ― 著作権法第32条の引用を仕組みで守る
- 【WordPressで作るキュレーションメディア 第5回】「まとめリスト」と「比較表」をパターンとブロックスタイルで作る
- 【WordPressで作るキュレーションメディア 第6回】著者・監修者プロフィールを表示する(E-E-A-T の考え方)
- 【WordPressで作るキュレーションメディア 第7回】人気記事ランキングと関連記事を小さなプラグインで作る
- 【WordPressで作るキュレーションメディア 第8回】目次と構造化データ(JSON-LD: Article / BreadcrumbList / ItemList)(この記事)
- 【WordPressで作るキュレーションメディア 第9回】OGP・SNS カードと「広告・PR表記」― ステマ規制への対応
- 【WordPressで作るキュレーションメディア 第10回】表示速度の改善 ― 画像サイズ・キャッシュ・計測
- 【WordPressで作るキュレーションメディア 第11回】権限・編集フロー・運用 ― 下書きレビュー、バックアップ、更新










コメント
コメント一覧 (1件)
[…] 前回の第8回では、目次と構造化データを作りました。第9回の今回は、OGP・X カードの出力と、広告・PR表記の仕組みをプラグインに追加します。 […]