PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / breadcrumbs / class-metasync-breadcrumbs.php

class-metasync-breadcrumbs.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at breadcrumbs/class-metasync-breadcrumbs.php

670 lines 25.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MetaSync Breadcrumbs — core breadcrumb path resolver and HTML output.
4 *
5 * @package Metasync
6 * @subpackage Metasync/breadcrumbs
7 */
8
9 if (!defined('ABSPATH')) {
10 exit;
11 }
12
13 class Metasync_Breadcrumbs {
14
15 /**
16 * Plugin name.
17 *
18 * @var string
19 */
20 private $plugin_name;
21
22 /**
23 * Plugin version.
24 *
25 * @var string
26 */
27 private $version;
28
29 /**
30 * Breadcrumb settings (cached per request).
31 *
32 * @var array
33 */
34 private $settings;
35
36 /**
37 * Constructor.
38 *
39 * @param string $plugin_name Plugin slug.
40 * @param string $version Plugin version.
41 */
42 public function __construct($plugin_name = 'metasync', $version = '1.0.0') {
43 $this->plugin_name = $plugin_name;
44 $this->version = $version;
45 $this->settings = $this->load_settings();
46
47 add_action('init', array($this, 'register_meta_fields'));
48 add_action('init', array($this, 'register_shortcode'));
49 add_action('wp', array($this, 'maybe_override_woocommerce_breadcrumb'));
50
51 // Cross-write the per-post breadcrumb label override to Yoast / Rank Math
52 // so sites running those plugins stay in sync when editors use MetaSync.
53 add_action('updated_post_meta', array($this, 'sync_breadcrumb_title_to_plugins'), 10, 4);
54 add_action('added_post_meta', array($this, 'sync_breadcrumb_title_to_plugins'), 10, 4);
55 }
56
57 /**
58 * Replace WooCommerce breadcrumb with MetaSync's if the filter is enabled.
59 *
60 * Off by default — enable with:
61 * add_filter( 'metasync_override_woocommerce_breadcrumb', '__return_true' );
62 */
63 public function maybe_override_woocommerce_breadcrumb() {
64 if (!class_exists('WooCommerce')) {
65 return;
66 }
67
68 if (!apply_filters('metasync_override_woocommerce_breadcrumb', false)) {
69 return;
70 }
71
72 // Remove from the standard hook (classic themes via woocommerce_before_main_content).
73 remove_action('woocommerce_before_main_content', 'woocommerce_breadcrumb', 20);
74
75 // Suppress via WooCommerce's own output filter — catches themes that call
76 // woocommerce_breadcrumb() directly outside the hook (Storefront, OceanWP, etc.).
77 add_filter('woocommerce_breadcrumb_defaults', array($this, 'suppress_woocommerce_breadcrumb_output'));
78
79 add_action('woocommerce_before_main_content', array($this, 'render_breadcrumb_html_echo'), 20);
80 }
81
82 /**
83 * Suppress WooCommerce's native breadcrumb output by setting an empty wrap format.
84 *
85 * @param array $defaults WooCommerce breadcrumb defaults.
86 * @return array
87 */
88 public function suppress_woocommerce_breadcrumb_output($defaults) {
89 $defaults['wrap_before'] = '<nav class="woocommerce-breadcrumb" style="display:none">';
90 return $defaults;
91 }
92
93 /**
94 * Echo wrapper for render_breadcrumb_html — used as an action callback.
95 */
96 public function render_breadcrumb_html_echo() {
97 echo $this->render_breadcrumb_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is escaped inside render method
98 }
99
100 /**
101 * Load breadcrumb settings from the shared option.
102 *
103 * @return array
104 */
105 private function load_settings() {
106 $defaults = array(
107 'enabled' => true,
108 'separator' => '»',
109 'home_label' => 'Home',
110 'home_url' => '',
111 'show_current_page' => true,
112 'prefix_text' => '',
113 'archive_label_format' => '{name}',
114 'disable_schema' => false,
115 );
116
117 $saved = Metasync::get_option('breadcrumbs', array());
118 if (!is_array($saved)) {
119 $saved = array();
120 }
121
122 $settings = wp_parse_args($saved, $defaults);
123
124 // Legacy accordion-era separators may be stored as HTML entities
125 // ('&raquo;'). CSS and attribute contexts never decode entities, so
126 // the trail would render the entity text literally — normalize here.
127 $settings['separator'] = html_entity_decode((string) $settings['separator'], ENT_QUOTES, 'UTF-8');
128
129 return $settings;
130 }
131
132 /**
133 * Cross-write `_metasync_breadcrumb_title` to Yoast / Rank Math meta keys.
134 *
135 * Fires on `added_post_meta` / `updated_post_meta`. Guards against recursion
136 * with a static flag so the cross-written updates don't re-trigger this
137 * handler for the other plugin keys.
138 *
139 * @param int $meta_id Meta ID (unused).
140 * @param int $post_id Post ID.
141 * @param string $meta_key Meta key being written.
142 * @param mixed $meta_value Meta value being written.
143 */
144 public function sync_breadcrumb_title_to_plugins($meta_id, $post_id, $meta_key, $meta_value) {
145 if ($meta_key !== '_metasync_breadcrumb_title') {
146 return;
147 }
148
149 static $is_cross_writing = false;
150 if ($is_cross_writing) {
151 return;
152 }
153
154 if (!function_exists('is_plugin_active')) {
155 require_once ABSPATH . 'wp-admin/includes/plugin.php';
156 }
157
158 $is_cross_writing = true;
159
160 // Consent gate and write-once backup — a breadcrumb title copied into
161 // Yoast or Rank Math is a permanent write into another plugin's storage
162 // like any other, so it asks the same permission and preserves whatever
163 // the field held before.
164 $may_write = class_exists('Metasync_Seo_Backup');
165
166 if ($may_write && (is_plugin_active('wordpress-seo/wp-seo.php') ||
167 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'))) {
168 Metasync_Seo_Backup::write_post_meta($post_id, '_yoast_wpseo_bctitle', $meta_value);
169 }
170
171 if ($may_write && (is_plugin_active('seo-by-rank-math/rank-math.php') ||
172 is_plugin_active('seo-by-rankmath/rank-math.php'))) {
173 Metasync_Seo_Backup::write_post_meta($post_id, 'rank_math_breadcrumb_title', $meta_value);
174 }
175
176 $is_cross_writing = false;
177 }
178
179 /**
180 * Register post meta fields for breadcrumb title override and primary category.
181 */
182 public function register_meta_fields() {
183 $post_types = get_post_types(array('public' => true), 'names');
184
185 foreach ($post_types as $post_type) {
186 register_post_meta($post_type, '_metasync_breadcrumb_title', array(
187 'show_in_rest' => true,
188 'single' => true,
189 'type' => 'string',
190 'sanitize_callback' => 'sanitize_text_field',
191 'auth_callback' => function () {
192 return current_user_can('edit_posts');
193 },
194 ));
195
196 register_post_meta($post_type, '_metasync_primary_category', array(
197 'show_in_rest' => true,
198 'single' => true,
199 'type' => 'integer',
200 'sanitize_callback' => 'absint',
201 'auth_callback' => function () {
202 return current_user_can('edit_posts');
203 },
204 ));
205 }
206 }
207
208 /**
209 * Register the [metasync_breadcrumb] shortcode.
210 */
211 public function register_shortcode() {
212 add_shortcode('metasync_breadcrumb', array($this, 'render_breadcrumb_html'));
213 }
214
215 /**
216 * Resolve the breadcrumb trail for the current page context.
217 *
218 * Returns an ordered array of [ 'label' => string, 'url' => string|'' ].
219 * The last item may have an empty URL (current page).
220 *
221 * @param int $post_id Optional. Explicit post ID (used by MCP tool).
222 * @return array
223 */
224 public function resolve_breadcrumb_trail($post_id = 0) {
225 $trail = array();
226
227 // Home item.
228 $home_url = !empty($this->settings['home_url']) ? $this->settings['home_url'] : home_url('/');
229 $home_label = !empty($this->settings['home_label']) ? $this->settings['home_label'] : 'Home';
230 $trail[] = array('label' => $home_label, 'url' => $home_url);
231
232 // --- Front page ---
233 if (is_front_page()) {
234 return $trail;
235 }
236
237 // --- WooCommerce pages (must be checked before generic singular) ---
238 if (class_exists('WooCommerce')) {
239 $woo_trail = $this->resolve_woocommerce_trail($post_id);
240 if ($woo_trail !== null) {
241 return array_merge($trail, $woo_trail);
242 }
243 }
244
245 // --- Single post / page / CPT ---
246 if (is_singular()) {
247 $current_post = $post_id ? get_post($post_id) : get_queried_object();
248 if (!$current_post) {
249 return $trail;
250 }
251
252 if (is_page()) {
253 // Build parent chain.
254 $ancestors = get_post_ancestors($current_post);
255 $ancestors = array_reverse($ancestors);
256 foreach ($ancestors as $ancestor_id) {
257 $ancestor = get_post($ancestor_id);
258 if ($ancestor) {
259 $trail[] = array(
260 'label' => get_the_title($ancestor),
261 'url' => get_permalink($ancestor),
262 );
263 }
264 }
265 } elseif ($current_post->post_type === 'post') {
266 // Category in trail — respect primary category.
267 $category = $this->get_primary_category($current_post->ID);
268 if ($category) {
269 // Build category ancestor chain.
270 $cat_ancestors = get_ancestors($category->term_id, 'category');
271 $cat_ancestors = array_reverse($cat_ancestors);
272 foreach ($cat_ancestors as $cat_ancestor_id) {
273 $cat_ancestor = get_term($cat_ancestor_id, 'category');
274 if ($cat_ancestor && !is_wp_error($cat_ancestor)) {
275 $trail[] = array(
276 'label' => $this->format_archive_label($cat_ancestor->name),
277 'url' => get_term_link($cat_ancestor),
278 );
279 }
280 }
281 $trail[] = array(
282 'label' => $this->format_archive_label($category->name),
283 'url' => get_term_link($category),
284 );
285 }
286 } else {
287 // Custom post type — add post type archive link if available.
288 $post_type_obj = get_post_type_object($current_post->post_type);
289 if ($post_type_obj && $post_type_obj->has_archive) {
290 $trail[] = array(
291 'label' => $post_type_obj->labels->name,
292 'url' => get_post_type_archive_link($current_post->post_type),
293 );
294 }
295 }
296
297 // Final item — the current post.
298 $label = $this->get_breadcrumb_title($current_post->ID);
299 $trail[] = array('label' => $label, 'url' => '');
300
301 return $trail;
302 }
303
304
305 // --- Category archive ---
306 if (is_category()) {
307 $category = get_queried_object();
308 if ($category) {
309 $cat_ancestors = get_ancestors($category->term_id, 'category');
310 $cat_ancestors = array_reverse($cat_ancestors);
311 foreach ($cat_ancestors as $cat_ancestor_id) {
312 $cat_ancestor = get_term($cat_ancestor_id, 'category');
313 if ($cat_ancestor && !is_wp_error($cat_ancestor)) {
314 $trail[] = array(
315 'label' => $this->format_archive_label($cat_ancestor->name),
316 'url' => get_term_link($cat_ancestor),
317 );
318 }
319 }
320 $trail[] = array('label' => $this->format_archive_label($category->name), 'url' => '');
321 }
322 return $trail;
323 }
324
325 // --- Tag archive ---
326 if (is_tag()) {
327 $tag = get_queried_object();
328 if ($tag) {
329 $trail[] = array('label' => $this->format_archive_label($tag->name), 'url' => '');
330 }
331 return $trail;
332 }
333
334 // --- Author archive ---
335 if (is_author()) {
336 $author = get_queried_object();
337 if ($author) {
338 $trail[] = array('label' => $author->display_name, 'url' => '');
339 }
340 return $trail;
341 }
342
343 // --- Date archive ---
344 if (is_date()) {
345 if (is_year()) {
346 $trail[] = array('label' => get_the_date('Y'), 'url' => '');
347 } elseif (is_month()) {
348 $trail[] = array('label' => get_the_date('Y'), 'url' => get_year_link(get_the_date('Y')));
349 $trail[] = array('label' => get_the_date('F'), 'url' => '');
350 } elseif (is_day()) {
351 $trail[] = array('label' => get_the_date('Y'), 'url' => get_year_link(get_the_date('Y')));
352 $trail[] = array('label' => get_the_date('F'), 'url' => get_month_link(get_the_date('Y'), get_the_date('m')));
353 $trail[] = array('label' => get_the_date('j'), 'url' => '');
354 }
355 return $trail;
356 }
357
358 // --- Search results ---
359 if (is_search()) {
360 $trail[] = array(
361 /* translators: %s: search query. */
362 'label' => sprintf(__('Search results for "%s"', 'metasync'), get_search_query()),
363 'url' => '',
364 );
365 return $trail;
366 }
367
368 // --- 404 ---
369 if (is_404()) {
370 $trail[] = array('label' => __('404 Not Found', 'metasync'), 'url' => '');
371 return $trail;
372 }
373
374 // --- Post type archive (generic) ---
375 if (is_post_type_archive()) {
376 $post_type_obj = get_queried_object();
377 if ($post_type_obj) {
378 $trail[] = array('label' => $post_type_obj->labels->name, 'url' => '');
379 }
380 return $trail;
381 }
382
383 // --- Taxonomy archive (generic) ---
384 if (is_tax()) {
385 $term = get_queried_object();
386 if ($term) {
387 // Walk term ancestors so nested terms keep their parents, and so
388 // every intermediate crumb carries a real URL. The taxonomy label
389 // itself has no archive of its own to link to, so emitting it here
390 // produced an intermediate ListItem with no `item` property, which
391 // Google rejects.
392 $ancestors = array_reverse(get_ancestors($term->term_id, $term->taxonomy, 'taxonomy'));
393 foreach ($ancestors as $ancestor_id) {
394 $ancestor = get_term($ancestor_id, $term->taxonomy);
395 if (is_wp_error($ancestor) || !$ancestor) {
396 continue;
397 }
398 $ancestor_link = get_term_link($ancestor);
399 $trail[] = array(
400 'label' => $this->format_archive_label($ancestor->name),
401 'url' => is_wp_error($ancestor_link) ? '' : $ancestor_link,
402 );
403 }
404 $trail[] = array('label' => $this->format_archive_label($term->name), 'url' => '');
405 }
406 return $trail;
407 }
408
409 return $trail;
410 }
411
412 /**
413 * Resolve WooCommerce-specific breadcrumb segments.
414 *
415 * @param int $post_id Optional explicit post ID.
416 * @return array|null Null when the current page is not a WooCommerce page.
417 */
418 private function resolve_woocommerce_trail($post_id = 0) {
419 $trail = array();
420
421 // Shop page.
422 // wc_get_page_id() returns -1 (not 0) when the shop page is unset. -1 is
423 // truthy, so an unguarded check yields get_the_title(-1) === '' and
424 // get_permalink(-1) === false — an intermediate crumb with neither name
425 // nor URL.
426 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
427 if ($shop_page_id < 1) {
428 $shop_page_id = 0;
429 }
430 $shop_label = $shop_page_id ? get_the_title($shop_page_id) : __('Shop', 'metasync');
431 $shop_url = $shop_page_id ? get_permalink($shop_page_id) : '';
432
433 // WooCommerce product category archive.
434 if (is_tax('product_cat')) {
435 $trail[] = array('label' => $shop_label, 'url' => $shop_url);
436 $term = get_queried_object();
437 if ($term) {
438 $cat_ancestors = get_ancestors($term->term_id, 'product_cat');
439 $cat_ancestors = array_reverse($cat_ancestors);
440 foreach ($cat_ancestors as $anc_id) {
441 $anc = get_term($anc_id, 'product_cat');
442 if ($anc && !is_wp_error($anc)) {
443 $trail[] = array('label' => $this->format_archive_label($anc->name), 'url' => get_term_link($anc));
444 }
445 }
446 $trail[] = array('label' => $this->format_archive_label($term->name), 'url' => '');
447 }
448 return $trail;
449 }
450
451 // WooCommerce shop page.
452 if (is_shop()) {
453 $trail[] = array('label' => $shop_label, 'url' => '');
454 return $trail;
455 }
456
457 // Single product (frontend context or explicit post_id in MCP/CLI).
458 $is_product = is_singular('product');
459 if (!$is_product && $post_id > 0) {
460 $maybe_post = get_post($post_id);
461 $is_product = $maybe_post && $maybe_post->post_type === 'product';
462 }
463 if ($is_product) {
464 $trail[] = array('label' => $shop_label, 'url' => $shop_url);
465
466 $product_post = $post_id ? get_post($post_id) : get_queried_object();
467 if ($product_post) {
468 $terms = get_the_terms($product_post->ID, 'product_cat');
469 if ($terms && !is_wp_error($terms)) {
470 // Use first product category (or primary if set).
471 $primary_cat_id = get_post_meta($product_post->ID, '_metasync_primary_category', true);
472 $category = null;
473 if ($primary_cat_id) {
474 foreach ($terms as $t) {
475 if ((int) $t->term_id === (int) $primary_cat_id) {
476 $category = $t;
477 break;
478 }
479 }
480 }
481 if (!$category) {
482 $category = $terms[0];
483 }
484
485 $cat_ancestors = get_ancestors($category->term_id, 'product_cat');
486 $cat_ancestors = array_reverse($cat_ancestors);
487 foreach ($cat_ancestors as $anc_id) {
488 $anc = get_term($anc_id, 'product_cat');
489 if ($anc && !is_wp_error($anc)) {
490 $trail[] = array('label' => $this->format_archive_label($anc->name), 'url' => get_term_link($anc));
491 }
492 }
493 $trail[] = array('label' => $this->format_archive_label($category->name), 'url' => get_term_link($category));
494 }
495
496 $label = $this->get_breadcrumb_title($product_post->ID);
497 $trail[] = array('label' => $label, 'url' => '');
498 }
499 return $trail;
500 }
501
502 // Not a WooCommerce page.
503 return null;
504 }
505
506 /**
507 * Get the breadcrumb title for a post, with override support.
508 *
509 * @param int $post_id Post ID.
510 * @return string
511 */
512 private function get_breadcrumb_title($post_id) {
513 $override = get_post_meta($post_id, '_metasync_breadcrumb_title', true);
514 if (!empty($override)) {
515 return $override;
516 }
517 return get_the_title($post_id);
518 }
519
520 /**
521 * Get the primary category for a post.
522 *
523 * Falls back to the first assigned category.
524 *
525 * @param int $post_id Post ID.
526 * @return WP_Term|null
527 */
528 private function get_primary_category($post_id) {
529 $primary_cat_id = get_post_meta($post_id, '_metasync_primary_category', true);
530
531 if ($primary_cat_id) {
532 $term = get_term((int) $primary_cat_id, 'category');
533 if ($term && !is_wp_error($term)) {
534 return $term;
535 }
536 }
537
538 $categories = get_the_category($post_id);
539 if (!empty($categories)) {
540 return $categories[0];
541 }
542
543 return null;
544 }
545
546 /**
547 * Format an archive label using the configured format.
548 *
549 * @param string $name Term/archive name.
550 * @return string
551 */
552 private function format_archive_label($name) {
553 $format = $this->settings['archive_label_format'];
554 if (empty($format) || $format === '{name}') {
555 return $name;
556 }
557 return str_replace('{name}', $name, $format);
558 }
559
560 /**
561 * Render breadcrumb HTML.
562 *
563 * Can be called as a shortcode callback or directly.
564 *
565 * @param array $args Shortcode / function arguments (unused reserved).
566 * @return string HTML output.
567 */
568 public function render_breadcrumb_html($args = array()) {
569 if (empty($this->settings['enabled'])) {
570 return '';
571 }
572
573 $trail = $this->resolve_breadcrumb_trail();
574 if (empty($trail)) {
575 return '';
576 }
577
578 // Enqueue on render: the stylesheet only targets this markup, so it is
579 // loaded here — after the enabled check and a confirmed trail — rather
580 // than site-wide from wp_enqueue_scripts. Every render path (shortcode,
581 // template function, WooCommerce override) funnels through this method,
582 // and a late-enqueued style prints in the footer.
583 // The template function and the schema module both instantiate this class
584 // with the constructor's default-version instance ('1.0.0'), so whichever
585 // render path runs first would otherwise decide the cache-bust version.
586 // Prefer the plugin-wide constant so the version is consistent regardless
587 // of which instance renders.
588 wp_enqueue_style(
589 'metasync-breadcrumbs',
590 plugin_dir_url(dirname(__FILE__)) . 'breadcrumbs/css/metasync-breadcrumbs.css',
591 array(),
592 defined('METASYNC_VERSION') ? METASYNC_VERSION : $this->version
593 );
594
595 $separator = $this->settings['separator'];
596 $prefix_text = $this->settings['prefix_text'];
597 $show_current = $this->settings['show_current_page'];
598
599 $items_html = array();
600 $count = count($trail);
601
602 foreach ($trail as $index => $item) {
603 $is_last = ($index === $count - 1);
604
605 // Skip last item if show_current_page is disabled.
606 if ($is_last && !$show_current && $count > 1) {
607 continue;
608 }
609
610 // Check if this is not the last item (for separator rendering)
611 $should_have_separator = !$is_last;
612
613 if ($is_last || empty($item['url'])) {
614 $items_html[] = '<li class="metasync-breadcrumb__item metasync-breadcrumb__current">'
615 . '<span aria-current="page">' . esc_html($item['label']) . '</span>'
616 . '</li>';
617 } else {
618 $separator_attr = $should_have_separator
619 ? ' data-separator="' . esc_attr($separator) . '"'
620 : '';
621
622 $items_html[] = '<li class="metasync-breadcrumb__item"' . $separator_attr . '>'
623 . '<a class="metasync-breadcrumb__link" href="' . esc_url($item['url']) . '">'
624 . esc_html($item['label'])
625 . '</a>'
626 . '</li>';
627 }
628 }
629
630 $list_html = implode('', $items_html);
631
632 $prefix_html = '';
633 if (!empty($prefix_text)) {
634 $prefix_html = '<span class="metasync-breadcrumb__prefix">' . esc_html($prefix_text) . '</span>';
635 }
636
637 // Add dynamic separator CSS for the configured separator character.
638 // <style> is a raw-text element: HTML entities are not decoded there,
639 // so esc_attr() would print "&gt;" literally for the ">" preset.
640 // Escape for a CSS string (backslash + double quote) instead.
641 $separator_css = str_replace(array('\\', '"'), array('\\\\', '\\"'), $separator);
642 // A stored "</" sequence would close the raw-text <style> element
643 // early. Break the token with a CSS escape ("<\/" still renders "</").
644 $separator_css = preg_replace('#</#', '<\\/', $separator_css);
645 $separator_style = '<style>.metasync-breadcrumb__item[data-separator]::after { content: " ' . $separator_css . ' "; }</style>';
646
647 $html = $separator_style
648 . '<nav class="metasync-breadcrumb" aria-label="breadcrumb">'
649 . $prefix_html
650 . '<ul class="metasync-breadcrumb__list">'
651 . $list_html
652 . '</ul>'
653 . '</nav>';
654
655 return $html;
656 }
657 }
658
659 /**
660 * Global template function for themes.
661 *
662 * Usage: <?php if ( function_exists( 'metasync_breadcrumb' ) ) metasync_breadcrumb(); ?>
663 */
664 if (!function_exists('metasync_breadcrumb')) {
665 function metasync_breadcrumb() {
666 $breadcrumbs = new Metasync_Breadcrumbs();
667 echo $breadcrumbs->render_breadcrumb_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is escaped inside render method
668 }
669 }
670