テクニック

WordPressのパンくずリストを自作する|構造化データ(JSON-LD)付き実装

この記事の対象: WordPressテーマを自分で触る制作者。functions.php とテンプレートファイルを編集できる人
この記事でやること: プラグインなしでパンくずリストを出し、BreadcrumbList の JSON-LD まで自前で出力する

パンくずリストのためだけにプラグインを1本入れる。よくある判断ですが、出力される HTML はテーマのデザインに合わず、構造化データの中身も選べず、更新のたびに表示崩れを警戒することになります。パンくずリストは投稿の階層をたどって <ol> を組み立てるだけの処理で、テンプレートの分岐を全部書き切っても1ファイルに収まる規模です。この記事では、投稿・固定ページ・カテゴリアーカイブ・カスタム投稿タイプのすべてで正しく出るパンくずリストを、JSON-LD の構造化データ込みで作ります。コピーしてそのまま動く形で載せるので、作業しながら読み進めてください。


前提環境と、置くファイルの場所

前提環境と、置くファイルの場所

対象となるテーマの種類

この記事のコードは クラシックテーマ(PHPテンプレートで組まれたテーマ) を前提にしています。single.phpheader.php があり、get_header() でヘッダーを読み込む構造のテーマです。自作テーマ、あるいは有料テーマの子テーマがこれにあたります。

ブロックテーマ(templates/ ディレクトリに HTML ファイルが並ぶ、いわゆる FSE テーマ)の場合、テンプレートに直接 PHP を書けません。この記事の後半に、ブロックテーマでショートコード経由で出す方法を書いています。関数本体は共通です。

必要な条件は次の3つです。

項目 条件
WordPress コアに長く存在する関数(get_post_ancestors / get_the_terms / get_post_type_object / get_ancestors)だけで書いているため、現行バージョンであれば追加要件はない。ブロックテーマ側の手順のみサイトエディターを備えたバージョンが前提
テーマ 子テーマまたは自作テーマ。親テーマの functions.php は直接編集しない
権限 FTP/SSH またはファイルマネージャでテーマファイルを編集できること

パンくずを出力するコア標準の関数はありません。テーマ側で用意するか、プラグインに任せるかの二択になります。この記事は前者です。

ファイルの構成

作るファイルは2つです。

子テーマ内でのファイル配置

wp-content/themes/your-child-theme/

  • functions.php(読み込み1行を追記)
  • inc/breadcrumb.php(新規作成・本体)
  • single.php ほか(呼び出し1行を追記)

本体を inc/ に分けておくと、他案件へそのまま持ち出せます。

functions.php に全部書いてしまうと、次の案件で使い回すときに切り出す手間がかかります。最初から分けておいてください。

まず子テーマの functions.php の末尾に、次の1行を追記します。

require_once get_stylesheet_directory() . '/inc/breadcrumb.php';

get_stylesheet_directory() は子テーマのディレクトリを返します。get_template_directory() は親テーマを指すので、子テーマで使うとファイルが見つからず致命的エラーになります。ここは間違えやすいポイントです。


パンくずリストの本体を書く

パンくずリストの本体を書く

階層データを配列で組み立てる

いきなり HTML を出力する関数を書くと、JSON-LD を作るときに同じロジックを二度書くことになります。「階層を配列で返す関数」と「配列を HTML にする関数」を分ける のが正解です。

wp-content/themes/your-child-theme/inc/breadcrumb.php を新規作成し、次のコードを貼り付けてください。

<?php
/**
 * パンくずリスト(プラグイン不要・JSON-LD対応)
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * 現在のページのパンくず階層を配列で返す。
 *
 * @return array [ ['name' => 表示名, 'url' => URL または ''], ... ]
 */
function my_get_breadcrumb_items() {
    $items = array();

    // 先頭は必ずホーム
    $items[] = array(
        'name' => 'ホーム',
        'url'  => home_url( '/' ),
    );

    // トップページでは以降を追加しない
    if ( is_front_page() ) {
        return $items;
    }

    if ( is_home() ) {
        // 投稿一覧を固定ページに割り当てている場合
        $blog_id = (int) get_option( 'page_for_posts' );
        if ( $blog_id ) {
            $items[] = array(
                'name' => get_the_title( $blog_id ),
                'url'  => '',
            );
        } else {
            $items[] = array( 'name' => 'ブログ', 'url' => '' );
        }

    } elseif ( is_singular() ) {
        $items = array_merge( $items, my_breadcrumb_singular() );

    } elseif ( is_category() || is_tag() || is_tax() ) {
        $items = array_merge( $items, my_breadcrumb_term() );

    } elseif ( is_post_type_archive() ) {
        $obj = get_queried_object();
        if ( $obj instanceof WP_Post_Type ) {
            $items[] = array( 'name' => $obj->labels->name, 'url' => '' );
        }

    } elseif ( is_author() ) {
        $items[] = array(
            'name' => get_the_author_meta( 'display_name', get_query_var( 'author' ) ),
            'url'  => '',
        );

    } elseif ( is_date() ) {
        $items = array_merge( $items, my_breadcrumb_date() );

    } elseif ( is_search() ) {
        $items[] = array(
            'name' => '「' . get_search_query() . '」の検索結果',
            'url'  => '',
        );

    } elseif ( is_404() ) {
        $items[] = array( 'name' => 'ページが見つかりません', 'url' => '' );
    }

    return apply_filters( 'my_breadcrumb_items', $items );
}

apply_filters を末尾に置いてあるのは、案件ごとに「この投稿タイプだけ間に1階層挟みたい」という要望が出るためです。本体を書き換えずに functions.php 側で差し込めるようにしておきます。

投稿・固定ページ・カスタム投稿タイプの分岐

続けて、同じファイルに以下を追記します。ここが実装の中心です。

/**
 * 個別ページ(投稿・固定ページ・カスタム投稿タイプ)の階層。
 */
function my_breadcrumb_singular() {
    $items   = array();
    $post_id = get_queried_object_id();
    $type    = get_post_type( $post_id );

    if ( 'page' === $type ) {
        // 固定ページ:親をたどる
        $ancestors = array_reverse( get_post_ancestors( $post_id ) );
        foreach ( $ancestors as $ancestor_id ) {
            $items[] = array(
                'name' => get_the_title( $ancestor_id ),
                'url'  => get_permalink( $ancestor_id ),
            );
        }

    } elseif ( 'post' === $type ) {
        // 投稿:投稿一覧ページ → カテゴリ階層
        $blog_id = (int) get_option( 'page_for_posts' );
        if ( $blog_id ) {
            $items[] = array(
                'name' => get_the_title( $blog_id ),
                'url'  => get_permalink( $blog_id ),
            );
        }

        $primary = my_breadcrumb_primary_term( $post_id, 'category' );
        if ( $primary ) {
            $items = array_merge( $items, my_breadcrumb_term_ancestors( $primary ) );
        }

    } else {
        // カスタム投稿タイプ:アーカイブ → タクソノミー階層
        $pt_obj = get_post_type_object( $type );
        if ( $pt_obj && $pt_obj->has_archive ) {
            $archive = get_post_type_archive_link( $type );
            if ( $archive ) {
                $items[] = array(
                    'name' => $pt_obj->labels->name,
                    'url'  => $archive,
                );
            }
        }

        // 階層型タクソノミーのうち最初のものを使う
        $taxonomies = get_object_taxonomies( $type, 'objects' );
        foreach ( $taxonomies as $tax ) {
            if ( ! $tax->hierarchical || ! $tax->public ) {
                continue;
            }
            $primary = my_breadcrumb_primary_term( $post_id, $tax->name );
            if ( $primary ) {
                $items = array_merge( $items, my_breadcrumb_term_ancestors( $primary ) );
                break;
            }
        }

        // 階層型の固定ページ的CPTなら親もたどる
        if ( is_post_type_hierarchical( $type ) ) {
            $ancestors = array_reverse( get_post_ancestors( $post_id ) );
            foreach ( $ancestors as $ancestor_id ) {
                $items[] = array(
                    'name' => get_the_title( $ancestor_id ),
                    'url'  => get_permalink( $ancestor_id ),
                );
            }
        }
    }

    // 自分自身(リンクなし)
    $items[] = array(
        'name' => get_the_title( $post_id ),
        'url'  => '',
    );

    return $items;
}

投稿タイプごとに処理が違うので、分岐の意図を表にしておきます。

投稿タイプ 中間にはさむもの 判定に使う関数
固定ページ(page) 親ページの連なり get_post_ancestors()
投稿(post) 投稿一覧ページ → カテゴリ階層 get_option('page_for_posts')
カスタム投稿タイプ CPTアーカイブ → タクソノミー階層 has_archive / hierarchical

CPT の分岐で $tax->hierarchical を条件にしているのは、タグのような非階層タクソノミーをパンくずに使うと「ホーム > 制作事例 > レスポンシブ > 記事名」のように意味の通らない階層ができるためです。階層を持つタクソノミーだけをパンくずの材料にする のが安全な判断になります。

複数カテゴリがついているときにどれを選ぶか

投稿に3つのカテゴリがついていたら、パンくずはどれを表示すべきか。ここを雑に [0] で取ると、記事ごとに違う階層が出て一貫性が崩れます。次の関数で優先順位を決めます。

/**
 * パンくずに使うタームを1つ決める。
 * Yoast SEO の primary category 機能が保存する
 * _yoast_wpseo_primary_{tax} があればそれを優先し、
 * 無ければ「最も階層が深いターム」を選ぶ。
 */
function my_breadcrumb_primary_term( $post_id, $taxonomy ) {
    $primary_id = (int) get_post_meta( $post_id, '_yoast_wpseo_primary_' . $taxonomy, true );
    if ( $primary_id ) {
        $term = get_term( $primary_id, $taxonomy );
        if ( $term && ! is_wp_error( $term ) ) {
            return $term;
        }
    }

    $terms = get_the_terms( $post_id, $taxonomy );
    if ( ! $terms || is_wp_error( $terms ) ) {
        return null;
    }

    // 「未分類」を除外(スラッグで判定)
    $terms = array_filter( $terms, function ( $t ) {
        return 'uncategorized' !== $t->slug;
    } );
    if ( empty( $terms ) ) {
        return null;
    }

    // 階層が最も深いタームを採用
    $deepest = null;
    $max     = -1;
    foreach ( $terms as $term ) {
        $depth = count( get_ancestors( $term->term_id, $taxonomy, 'taxonomy' ) );
        if ( $depth > $max ) {
            $max     = $depth;
            $deepest = $term;
        }
    }

    return $deepest;
}

/**
 * タームとその祖先を、上位から順に配列で返す。
 */
function my_breadcrumb_term_ancestors( $term ) {
    $items     = array();
    $ancestors = array_reverse( get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) );

    foreach ( $ancestors as $ancestor_id ) {
        $ancestor = get_term( $ancestor_id, $term->taxonomy );
        if ( $ancestor && ! is_wp_error( $ancestor ) ) {
            $items[] = array(
                'name' => $ancestor->name,
                'url'  => get_term_link( $ancestor ),
            );
        }
    }

    $items[] = array(
        'name' => $term->name,
        'url'  => get_term_link( $term ),
    );

    return $items;
}

「最も階層が深いタームを選ぶ」というルールにしているのは、子カテゴリを選ぶときに親カテゴリにもチェックが入っている運用があり、深いほうが記事の内容を具体的に表すからです。Yoast SEO を入れていない場合はこの深さ判定だけで動きます。他の SEO プラグインで主カテゴリを持っている場合は、先頭のメタキーをそのプラグインのものに差し替えてください。

uncategorized の除外スラッグは、日本語環境で「未分類」のスラッグを変更している場合があるので、実サイトの値に合わせて書き換えてください。

アーカイブと日付ページ

残りの分岐を追記します。

/**
 * カテゴリ・タグ・カスタムタクソノミーのアーカイブ。
 */
function my_breadcrumb_term() {
    $items = array();
    $term  = get_queried_object();

    if ( ! $term instanceof WP_Term ) {
        return $items;
    }

    // カスタムタクソノミーなら、紐づくCPTアーカイブを先に挟む
    $tax_obj = get_taxonomy( $term->taxonomy );
    if ( $tax_obj && ! empty( $tax_obj->object_type ) ) {
        $type   = reset( $tax_obj->object_type );
        $pt_obj = get_post_type_object( $type );
        if ( $pt_obj && 'post' !== $type && $pt_obj->has_archive ) {
            $archive = get_post_type_archive_link( $type );
            if ( $archive ) {
                $items[] = array(
                    'name' => $pt_obj->labels->name,
                    'url'  => $archive,
                );
            }
        }
    }

    $ancestors = array_reverse( get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) );
    foreach ( $ancestors as $ancestor_id ) {
        $ancestor = get_term( $ancestor_id, $term->taxonomy );
        if ( $ancestor && ! is_wp_error( $ancestor ) ) {
            $items[] = array(
                'name' => $ancestor->name,
                'url'  => get_term_link( $ancestor ),
            );
        }
    }

    // 現在地はリンクなし
    $items[] = array( 'name' => $term->name, 'url' => '' );

    return $items;
}

/**
 * 日付アーカイブ(年 > 月 > 日)。
 */
function my_breadcrumb_date() {
    $items = array();
    $year  = get_query_var( 'year' );
    $month = get_query_var( 'monthnum' );
    $day   = get_query_var( 'day' );

    if ( is_year() ) {
        $items[] = array( 'name' => $year . '年', 'url' => '' );
        return $items;
    }

    $items[] = array( 'name' => $year . '年', 'url' => get_year_link( $year ) );

    if ( is_month() ) {
        $items[] = array( 'name' => $month . '月', 'url' => '' );
        return $items;
    }

    $items[] = array( 'name' => $month . '月', 'url' => get_month_link( $year, $month ) );
    $items[] = array( 'name' => $day . '日', 'url' => '' );

    return $items;
}

HTMLとJSON-LDを同時に出力する

HTMLとJSON-LDを同時に出力する

表示用のHTMLを組む

配列ができたので、あとは2つの出力を作るだけです。同じファイルの末尾に追記します。

/**
 * パンくずリストのHTMLを出力する。
 */
function my_the_breadcrumb() {
    $items = my_get_breadcrumb_items();

    // ホームだけなら出さない
    if ( count( $items ) < 2 ) {
        return;
    }

    $last = count( $items ) - 1;

    echo '<nav class="c-breadcrumb" aria-label="パンくずリスト">';
    echo '<ol class="c-breadcrumb__list">';

    foreach ( $items as $i => $item ) {
        $is_current = ( $i === $last );
        echo '<li class="c-breadcrumb__item">';

        if ( ! $is_current && ! empty( $item['url'] ) ) {
            printf(
                '<a class="c-breadcrumb__link" href="%s">%s</a>',
                esc_url( $item['url'] ),
                esc_html( $item['name'] )
            );
        } else {
            printf(
                '<span class="c-breadcrumb__current" aria-current="page">%s</span>',
                esc_html( $item['name'] )
            );
        }

        echo '</li>';
    }

    echo '</ol>';
    echo '</nav>';
}

変更する箇所: c-breadcrumb から始まるクラス名は、テーマの命名規則に合わせて書き換えてください。「ホーム」という表記も、サイトによっては「TOP」「HOME」にします(my_get_breadcrumb_items() の先頭)。

aria-label="パンくずリスト"aria-current="page" は残してください。スクリーンリーダーがナビゲーション領域を識別し、現在地を読み上げるために必要です。区切り記号(>/)を HTML に直接書かず CSS の疑似要素で入れているのは、記号まで読み上げられるのを避けるためです。

JSON-LDで構造化データを出す

BreadcrumbList<nav>itemprop を書き込む Microdata でも出せますが、HTML が読みにくくなるうえ、デザイン変更のたびに構造化データが壊れるリスクを抱えます。JSON-LD として独立させる ほうが管理しやすい構成です。

/**
 * BreadcrumbList の JSON-LD を wp_head に出力する。
 */
function my_breadcrumb_jsonld() {
    if ( is_front_page() || is_404() || is_search() ) {
        return;
    }

    $items = my_get_breadcrumb_items();
    if ( count( $items ) < 2 ) {
        return;
    }

    $list = array();
    foreach ( $items as $i => $item ) {
        $element = array(
            '@type'    => 'ListItem',
            'position' => $i + 1,
            'name'     => wp_strip_all_tags( $item['name'] ),
        );

        // 最後の要素(現在地)は item を省略できる
        if ( ! empty( $item['url'] ) ) {
            $element['item'] = esc_url_raw( $item['url'] );
        }

        $list[] = $element;
    }

    $data = array(
        '@context'        => 'https://schema.org',
        '@type'           => 'BreadcrumbList',
        'itemListElement' => $list,
    );

    echo '<script type="application/ld+json">' .
        wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) .
        '</script>' . "\n";
}
add_action( 'wp_head', 'my_breadcrumb_jsonld', 20 );

構造化データを書くときに詰まりやすい点を整理します。

注意点 理由
position は1から始め、欠番を作らない 0始まりや飛び番だと検証ツールで警告・エラーになる
最後の要素の item は省略可 現在ページのURLを入れても通るが、省略が公式ドキュメントの例に沿う
JSON_UNESCAPED_UNICODE を付ける 付けないと日本語が \u30db\u30fc\u30e0 になり、目視確認できなくなる
JSON_UNESCAPED_SLASHES を付ける URLの /\/ にエスケープされるのを防ぐ
検索結果ページは出力しない 検索結果ページ自体が noindex 対象であることが多いため

SEOプラグインとの重複に注意してください。 Yoast SEO や Rank Math、SEO SIMPLE PACK は、設定によって BreadcrumbList を自分で出力します。同じページに2つ出ると、Google Search Console の「パンくずリスト」レポートに警告が並ぶことがあります。ブラウザで Ctrl + U(ページのソースを表示)を開き、BreadcrumbList で検索して1つだけであることを確認してください。2つあれば、プラグイン側の設定でパンくずの構造化データをオフにします。

表記を後から差し替えられるようにする

先頭の「ホーム」や検索結果の文言をコードに直書きすると、多言語サイトや、クライアントが呼び名を変えたいときに本体を触ることになります。テーマの翻訳ドメインを使うか、フィルターで受けるかのどちらかにしておきます。

// functions.php 側に書く例:先頭の表記だけ差し替える
add_filter( 'my_breadcrumb_items', function ( $items ) {
    if ( isset( $items[0] ) ) {
        $items[0]['name'] = 'TOP';
    }
    return $items;
} );

このフィルターは HTML と JSON-LD の両方に効きます。表示だけ変えたいのか、構造化データの name まで変えたいのかを意識して使い分けてください。検索エンジンに伝わる階層名を変えるつもりがないなら、後述の表示側の関数で処理します。

なお、my_get_breadcrumb_items() は1リクエストにつき2回呼ばれます(HTML と JSON-LD)。ターム取得が重いサイトでは、静的変数でキャッシュしておくと無駄なクエリが減ります。

function my_get_breadcrumb_items() {
    static $cache = null;
    if ( null !== $cache ) {
        return $cache;
    }
    // …(本体の処理)…
    $cache = apply_filters( 'my_breadcrumb_items', $items );
    return $cache;
}

CSSを当てる

素の CSS だけで済ませます。ライブラリは不要です。子テーマの style.css に追記してください。

.c-breadcrumb {
  padding: 12px 0;
  font-size: 13px;
  line-height: 1.6;
}

.c-breadcrumb__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.c-breadcrumb__item {
  display: flex;
  align-items: center;
  color: #666;
}

/* 区切り記号は疑似要素で入れる(読み上げ対象にしないため) */
.c-breadcrumb__item + .c-breadcrumb__item::before {
  content: "";
  display: inline-block;
  width: 6px;
  height: 6px;
  margin-right: 8px;
  border-top: 1px solid #b0b0b0;
  border-right: 1px solid #b0b0b0;
  transform: rotate(45deg);
}

.c-breadcrumb__link {
  color: #666;
  text-decoration: none;
}

.c-breadcrumb__link:hover {
  text-decoration: underline;
}

.c-breadcrumb__current {
  color: #111;
}

/* スマホでは長いタイトルを省略して1行に収める */
@media (max-width: 599px) {
  .c-breadcrumb__list {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
  }
  .c-breadcrumb__list::-webkit-scrollbar {
    display: none;
  }
  .c-breadcrumb__item {
    white-space: nowrap;
  }
}

変更する箇所: 色(#666 #111 #b0b0b0)とフォントサイズ、区切り記号のスタイルです。矢印ではなくスラッシュにするなら、::before の中身を content: "/"; に置き換えて bordertransform を削除します。

スマホで横スクロールにしているのは、階層が深い場合に折り返してファーストビューを圧迫するためです。flex-wrap: wrap のまま折り返す方針もあり、どちらが良いかはサイトの階層の深さで決めてください。スクロールバーを隠す指定は、scrollbar-width が Firefox 系、::-webkit-scrollbar が Chrome / Safari / Edge 系に効きます。両方書いておけば主要ブラウザで隠れます。


テンプレートに設置する

テンプレートに設置する

クラシックテーマの場合

出力する位置を決めて、次の1行を書きます。

<?php if ( function_exists( 'my_the_breadcrumb' ) ) my_the_breadcrumb(); ?>

function_exists で囲んでいるのは、テーマを切り替えたときやファイルの読み込みが外れたときに、サイト全体が致命的エラーで落ちるのを防ぐためです。

置く場所は、テーマの構造によって次のいずれかになります。

置く場所 適した状況
header.php の閉じタグ直前 全ページに同じ位置で出す。もっとも管理が楽
single.php / page.php / archive.php の先頭 ページ種別ごとに位置を変えたい
ページタイトル(<h1>)の直上 見た目としては最も一般的な位置

header.php に置くと、トップページでも呼ばれます。ただし my_the_breadcrumb() の中で「ホームだけなら出さない」という判定を入れてあるので、トップページでは何も出力されません。

設置から確認までの手順
1. 本体を配置inc/breadcrumb.php を新規作成
2. 読み込むfunctions.php に require_once を追記
3. 呼び出すテンプレートに1行足す
4. 検証リッチリザルトテストでJSON-LDを確認

3まで終えたら、必ず4を実行してください。見た目が出ていても JSON-LD が壊れているケースはあります。

ブロックテーマの場合

ブロックテーマではテンプレートに PHP を書けないため、ショートコードにしてから「ショートコード」ブロックで置きます。inc/breadcrumb.php の末尾に追記してください。

/**
 * ショートコード [my_breadcrumb] としても使えるようにする。
 */
function my_breadcrumb_shortcode() {
    ob_start();
    my_the_breadcrumb();
    return ob_get_clean();
}
add_shortcode( 'my_breadcrumb', 'my_breadcrumb_shortcode' );

サイトエディター(外観 → エディター)でテンプレートを開き、パンくずを出したい位置に「ショートコード」ブロックを挿入して [my_breadcrumb] と入力します。単一投稿・固定ページ・アーカイブそれぞれのテンプレートに入れる必要があります。

JSON-LD 側は wp_head にフックしているので、ブロックテーマでも追加の作業なしで出力されます。

ここまでの作業量からわかるとおり、テンプレートに1行足せば済むか、ブロックごとに置き直すかはテーマの作りで変わります。テーマ選定の段階から関われる案件なら、この「改修のしやすさ」も選定条件に入れておくと後が楽になります。


階層が深いサイトでの扱い

階層が深いサイトでの扱い

何階層まで出すか

階層が深いサイトでは、5階層6階層のパンくずがそのまま出ると読みにくくなります。中間を省略する方式を入れておくと安全です。inc/breadcrumb.phpmy_the_breadcrumb() の直前に、次のフィルターを追加します。

/**
 * 階層が深いときに中間を省略する(先頭2つ + 末尾2つ)。
 * 省略した箇所には '…' を入れる。JSON-LD には適用しない。
 */
function my_breadcrumb_truncate( $items, $max = 5 ) {
    if ( count( $items ) <= $max ) {
        return $items;
    }

    $head = array_slice( $items, 0, 2 );
    $tail = array_slice( $items, -2 );

    return array_merge(
        $head,
        array( array( 'name' => '…', 'url' => '' ) ),
        $tail
    );
}

そして my_the_breadcrumb() の中の $items = my_get_breadcrumb_items(); を、次の2行に差し替えます。

    $items = my_get_breadcrumb_items();
    $items = my_breadcrumb_truncate( $items, 5 );

変更する箇所: 5 の部分が「省略を始める階層数」です。4にすればより早く省略されます。

JSON-LD 側には適用しないでください。 構造化データは実際の階層をすべて含めるのが正しく、 のような表示上の都合を入れると検証で警告が出ます。my_breadcrumb_jsonld()my_get_breadcrumb_items() を直接呼んでいるので、上の差し替えは表示側にしか効きません。

表示と構造化データで扱いを分ける

JSON-LD(省略しない)

  • 実際の階層をすべて出力
  • position は連番のまま
  • Google が階層を正しく理解する

画面表示(省略する)

  • 先頭2つ + … + 末尾2つ
  • スマホで1行に収まる
  • 読み手の負担が減る

長いタイトルを切り詰める

記事タイトルが長いと、パンくずの最後の要素だけで行を折り返してしまいます。表示だけ切り詰めます。

/**
 * 表示名を指定文字数で切る(末尾要素のみ)。
 */
function my_breadcrumb_shorten( $name, $length = 30 ) {
    if ( mb_strlen( $name ) <= $length ) {
        return $name;
    }
    return mb_substr( $name, 0, $length ) . '…';
}

my_the_breadcrumb() の中の esc_html( $item['name'] ) を、現在地の分岐だけ esc_html( my_breadcrumb_shorten( $item['name'] ) ) に置き換えます。JSON-LD 側は元のタイトルのままにしてください。name を切り詰めるとページタイトルと一致しなくなります。

CSS の text-overflow: ellipsis で済ませる方法もありますが、display: flex の子要素では min-width: 0 の指定が必要になり、階層ごとに幅の配分を考えることになります。PHP 側で切るほうが確実です。


うまくいかないとき

うまくいかないとき

パンくずが出ない・「ホーム」しか出ない

まず count( $items ) < 2 の早期 return を疑ってください。 トップページ以外で「ホーム」しか出ない場合、分岐のどれにも入っていません。一時的に my_the_breadcrumb() の先頭に次を入れて、配列の中身を見ます。

    echo '<pre style="background:#ffe;padding:10px;font-size:12px;">';
    print_r( my_get_breadcrumb_items() );
    echo '</pre>';

確認できたら必ず削除してください。配列が「ホーム」1件だけなら、そのページで is_singular()is_tax() も真になっていません。get_queried_object() の中身を確認します。

致命的エラーで画面が真っ白になる

functions.phprequire_once のパスを確認します。以下のどちらかである可能性が高いです。

  • get_template_directory() を使っている(子テーマでは親を指す)
  • inc/ ディレクトリを作っていない、ファイル名が違う

FTP で inc/breadcrumb.php の中身を空にすれば、エラーは止まって画面が戻ります。そこから1関数ずつ戻して原因を特定してください。

JSON-LDが「無効なアイテム」と判定される

Google のリッチリザルトテストで URL を検証します。よくある原因は3つです。

position が飛んでいる。 途中で continue している箇所があると、1・2・4のような連番になります。上のコードは $i + 1 で通し番号を振っているので発生しませんが、独自に条件を足したときは注意してください。

item に相対パスが入っている。 get_permalink()get_term_link() は絶対URLを返しますが、フィルターで書き換えている場合は相対パスになることがあります。http から始まっているか、ソースで確認してください。

JSON が壊れている。 記事タイトルにダブルクォートや改行が入っていると起きます。wp_json_encode() はエスケープしますが、name に HTML タグが混ざると意図しない表示になるので wp_strip_all_tags() を通しています。

検索結果にパンくずが表示されない

構造化データが正しくても、検索結果への反映は Google の判断です。反映されるかどうかも、いつ反映されるかも保証されません。Search Console の「拡張」→「パンくずリスト」でエラー0件・有効なアイテムが検出されていれば、実装側でやることは終わっています。

反映の有無を含め、こうした施策の効果を測る土台としては GA4 と Search Console の併用が前提になります。

カスタム投稿タイプで階層が出ない

チェックする順に並べます。

  1. register_post_type()has_archivetrue になっているか(false だとアーカイブへのリンクが出ない)
  2. タクソノミーの hierarchicaltrue か(false だとパンくずの材料にならない)
  3. タクソノミーの publictrue
  4. その投稿に、対象タクソノミーのタームが1つ以上ついているか

register_taxonomy() の引数で 'hierarchical' => true を後から追加した場合、管理画面でタームの親子関係を設定し直す必要があります。既存のタームは親が未設定のままです。

プラグインとパンくずが二重に出る

テーマに元からパンくず機能がある、または SEO プラグインが wp_head に出している場合です。ページのソースで BreadcrumbList の数と <nav の数を数えてください。

  • 表示が二重: テーマ側の呼び出しを1つ消す
  • JSON-LDが二重: プラグイン設定でパンくずの構造化データをオフにする。Yoast SEO なら「設定」→「サイトの表示」→「パンくずリスト」、SEO SIMPLE PACK なら「一般設定」の構造化データ項目

保守運用でプラグインを増やすほど、こうした重複の切り分けに時間を取られます。自作で完結させる判断は、この点でも効いてきます。


よくある質問

よくある質問

パンくずリストはSEOに効果がありますか

パンくずリスト単体で順位が上がるわけではありませんが、サイトの階層構造をクローラーに伝える手段として機能します。検索結果にパンくずが表示されればURLの代わりに階層が見えるため、クリック率に影響する可能性があります。ただし表示されるかどうかは Google の判断で、実装すれば必ず出るものではありません。

プラグインを使うのと自作、どちらが良いですか

テーマのデザインに合わせた HTML を出したい、構造化データの中身を制御したい、プラグインの数を減らしたいという場合は自作が向きます。逆に、テーマを頻繁に切り替える運用や、クライアントが自分で設定を変えたい場合はプラグインのほうが管理しやすくなります。判断軸は「パンくずの出し方をコードで固定したいか、画面から変えられるようにしたいか」です。

JSON-LDとMicrodataはどちらで書くべきですか

Google は JSON-LD を推奨しており、この記事も JSON-LD で書いています。Microdata は HTML に属性を書き込む方式のため、デザイン変更で構造化データが壊れやすく、HTML も読みにくくなります。既存サイトが Microdata で書かれている場合、両方を同時に出すと重複扱いになるので、どちらかに寄せてください。

最後の項目にリンクを付けても大丈夫ですか

現在表示しているページ自身へのリンクになるため、通常は付けません。HTML では <span> にして aria-current="page" を付けるのが一般的です。JSON-LD 側も最後の item は省略できます。付けても構造化データの検証エラーにはなりませんが、ユーザー体験としては意味のないリンクになります。

カスタムタクソノミーが複数ついている投稿ではどうなりますか

この記事のコードでは、階層型かつ公開されているタクソノミーのうち、最初に見つかったものを使います。特定のタクソノミーを優先したい場合は、my_breadcrumb_singular() のループの前に対象タクソノミー名を直接指定するか、my_breadcrumb_items フィルターで後から差し替えてください。


まとめ

パンくずリストの自作は、次の3つを分けて考えると迷いません。

役割 関数 責任範囲
階層を決める my_get_breadcrumb_items() ページ種別ごとの分岐。ここに全ロジックを集約
画面に出す my_the_breadcrumb() HTML の組み立て。省略・切り詰めはここだけ
検索エンジンに伝える my_breadcrumb_jsonld() JSON-LD。省略せず実際の階層を出す

この分離があるおかげで、「スマホでは省略したいが構造化データは省略したくない」という要件が素直に書けます。プラグインで同じことをやろうとすると、フィルターフックを探すところから始まります。

作業が終わったら、次の3点だけ確認してください。

  • 投稿・固定ページ・カテゴリアーカイブ・CPT の4種類すべてで表示されるか
  • ページのソースに BreadcrumbList1つだけ あるか
  • リッチリザルトテストでエラー0件か

CMS の選定段階から関わる案件では、こうしたテンプレート改修の自由度そのものが判断材料になります。