| @@ -3,9 +3,8 @@ | ||
| 3 | 3 | * MetaSync Breadcrumbs — core breadcrumb path resolver and HTML output. |
| 4 | 4 | * |
| 5 | 5 | * @package Metasync |
| 6 | 6 | * @subpackage Metasync/breadcrumbs |
| 7 | - * @since 2.9.0 | |
| 8 | 7 | */ |
| 9 | 8 | |
| 10 | 9 | if (!defined('ABSPATH')) { |
| 11 | 10 | exit; |
| @@ -46,9 +45,8 @@ | ||
| 46 | 45 | $this->settings = $this->load_settings(); |
| 47 | 46 | |
| 48 | 47 | add_action('init', array($this, 'register_meta_fields')); |
| 49 | 48 | add_action('init', array($this, 'register_shortcode')); |
| 50 | - add_action('wp_enqueue_scripts', array($this, 'enqueue_styles')); | |
| 51 | 49 | add_action('wp', array($this, 'maybe_override_woocommerce_breadcrumb')); |
| 52 | 50 | |
| 53 | 51 | // Cross-write the per-post breadcrumb label override to Yoast / Rank Math |
| 54 | 52 | // so sites running those plugins stay in sync when editors use MetaSync. |
| @@ -56,20 +54,8 @@ | ||
| 56 | 54 | add_action('added_post_meta', array($this, 'sync_breadcrumb_title_to_plugins'), 10, 4); |
| 57 | 55 | } |
| 58 | 56 | |
| 59 | 57 | /** |
| 60 | - * Enqueue breadcrumb styles on the front-end. | |
| 61 | - */ | |
| 62 | - public function enqueue_styles() { | |
| 63 | - wp_enqueue_style( | |
| 64 | - 'metasync-breadcrumbs', | |
| 65 | - plugin_dir_url(dirname(__FILE__)) . 'breadcrumbs/css/metasync-breadcrumbs.css', | |
| 66 | - array(), | |
| 67 | - $this->version | |
| 68 | - ); | |
| 69 | - } | |
| 70 | - | |
| 71 | - /** | |
| 72 | 58 | * Replace WooCommerce breadcrumb with MetaSync's if the filter is enabled. |
| 73 | 59 | * |
| 74 | 60 | * Off by default — enable with: |
| 75 | 61 | * add_filter( 'metasync_override_woocommerce_breadcrumb', '__return_true' ); |
| @@ -132,9 +118,16 @@ | ||
| 132 | 118 | if (!is_array($saved)) { |
| 133 | 119 | $saved = array(); |
| 134 | 120 | } |
| 135 | 121 | |
| 136 | - return wp_parse_args($saved, $defaults); | |
| 122 | + $settings = wp_parse_args($saved, $defaults); | |
| 123 | + | |
| 124 | + // Legacy accordion-era separators may be stored as HTML entities | |
| 125 | + // ('»'). 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; | |
| 137 | 130 | } |
| 138 | 131 | |
| 139 | 132 | /** |
| 140 | 133 | * Cross-write `_metasync_breadcrumb_title` to Yoast / Rank Math meta keys. |
| @@ -163,16 +156,22 @@ | ||
| 163 | 156 | } |
| 164 | 157 | |
| 165 | 158 | $is_cross_writing = true; |
| 166 | 159 | |
| 167 | - if (is_plugin_active('wordpress-seo/wp-seo.php') || | |
| 168 | - is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) { | |
| 169 | - update_post_meta($post_id, '_yoast_wpseo_bctitle', $meta_value); | |
| 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); | |
| 170 | 169 | } |
| 171 | 170 | |
| 172 | - if (is_plugin_active('seo-by-rank-math/rank-math.php') || | |
| 173 | - is_plugin_active('seo-by-rankmath/rank-math.php')) { | |
| 174 | - update_post_meta($post_id, 'rank_math_breadcrumb_title', $meta_value); | |
| 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); | |
| 175 | 174 | } |
| 176 | 175 | |
| 177 | 176 | $is_cross_writing = false; |
| 178 | 177 | } |
| @@ -357,10 +356,10 @@ | ||
| 357 | 356 | } |
| 358 | 357 | |
| 359 | 358 | // --- Search results --- |
| 360 | 359 | if (is_search()) { |
| 361 | - /* translators: %s: search query */ | |
| 362 | 360 | $trail[] = array( |
| 361 | + /* translators: %s: search query. */ | |
| 363 | 362 | 'label' => sprintf(__('Search results for "%s"', 'metasync'), get_search_query()), |
| 364 | 363 | 'url' => '', |
| 365 | 364 | ); |
| 366 | 365 | return $trail; |
| @@ -384,11 +383,24 @@ | ||
| 384 | 383 | // --- Taxonomy archive (generic) --- |
| 385 | 384 | if (is_tax()) { |
| 386 | 385 | $term = get_queried_object(); |
| 387 | 386 | if ($term) { |
| 388 | - $taxonomy_obj = get_taxonomy($term->taxonomy); | |
| 389 | - if ($taxonomy_obj) { | |
| 390 | - $trail[] = array('label' => $taxonomy_obj->labels->name, 'url' => ''); | |
| 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 | + ); | |
| 391 | 403 | } |
| 392 | 404 | $trail[] = array('label' => $this->format_archive_label($term->name), 'url' => ''); |
| 393 | 405 | } |
| 394 | 406 | return $trail; |
| @@ -406,9 +418,16 @@ | ||
| 406 | 418 | private function resolve_woocommerce_trail($post_id = 0) { |
| 407 | 419 | $trail = array(); |
| 408 | 420 | |
| 409 | 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. | |
| 410 | 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 | + } | |
| 411 | 430 | $shop_label = $shop_page_id ? get_the_title($shop_page_id) : __('Shop', 'metasync'); |
| 412 | 431 | $shop_url = $shop_page_id ? get_permalink($shop_page_id) : ''; |
| 413 | 432 | |
| 414 | 433 | // WooCommerce product category archive. |
| @@ -555,8 +574,25 @@ | ||
| 555 | 574 | if (empty($trail)) { |
| 556 | 575 | return ''; |
| 557 | 576 | } |
| 558 | 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 | + | |
| 559 | 595 | $separator = $this->settings['separator']; |
| 560 | 596 | $prefix_text = $this->settings['prefix_text']; |
| 561 | 597 | $show_current = $this->settings['show_current_page']; |
| 562 | 598 | |
| @@ -597,11 +633,17 @@ | ||
| 597 | 633 | if (!empty($prefix_text)) { |
| 598 | 634 | $prefix_html = '<span class="metasync-breadcrumb__prefix">' . esc_html($prefix_text) . '</span>'; |
| 599 | 635 | } |
| 600 | 636 | |
| 601 | - // Add dynamic separator CSS for the configured separator character | |
| 602 | - $separator_escaped = esc_attr($separator); | |
| 603 | - $separator_style = '<style>.metasync-breadcrumb__item[data-separator]::after { content: " ' . $separator_escaped . ' "; }</style>'; | |
| 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 ">" 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>'; | |
| 604 | 646 | |
| 605 | 647 | $html = $separator_style |
| 606 | 648 | . '<nav class="metasync-breadcrumb" aria-label="breadcrumb">' |
| 607 | 649 | . $prefix_html |