PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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.2, at breadcrumbs/class-metasync-breadcrumbs.php

759 lines 28.8 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 * True when the global breadcrumbs switch is on.
102 *
103 * True only when MetaSync's own trail renders (front-end trail and
104 * BreadcrumbList schema). Default: on, matching load_settings()'s
105 * defaults. Editor UI that merely *sets* a breadcrumb label must not gate
106 * on this alone — Yoast / Rank Math trails stay alive when ours is off —
107 * so those call is_breadcrumb_title_meaningful() instead.
108 *
109 * @return bool
110 */
111 public static function is_enabled() {
112 $saved = Metasync::get_option('breadcrumbs', array());
113 if (!is_array($saved) || !array_key_exists('enabled', $saved)) {
114 return true;
115 }
116 return (bool) $saved['enabled'];
117 }
118
119 /**
120 * Load breadcrumb settings from the shared option.
121 *
122 * @return array
123 */
124 private function load_settings() {
125 $defaults = array(
126 'enabled' => true,
127 'separator' => '»',
128 'home_label' => 'Home',
129 'home_url' => '',
130 'show_current_page' => true,
131 'prefix_text' => '',
132 'archive_label_format' => '{name}',
133 'disable_schema' => false,
134 );
135
136 $saved = Metasync::get_option('breadcrumbs', array());
137 if (!is_array($saved)) {
138 $saved = array();
139 }
140
141 $settings = wp_parse_args($saved, $defaults);
142
143 // Legacy accordion-era separators may be stored as HTML entities
144 // ('&raquo;'). CSS and attribute contexts never decode entities, so
145 // the trail would render the entity text literally — normalize here.
146 $settings['separator'] = html_entity_decode((string) $settings['separator'], ENT_QUOTES, 'UTF-8');
147
148 return $settings;
149 }
150
151 /**
152 * Cross-write `_metasync_breadcrumb_title` to Yoast / Rank Math meta keys.
153 *
154 * Fires on `added_post_meta` / `updated_post_meta`. Guards against recursion
155 * with a static flag so the cross-written updates don't re-trigger this
156 * handler for the other plugin keys.
157 *
158 * @param int $meta_id Meta ID (unused).
159 * @param int $post_id Post ID.
160 * @param string $meta_key Meta key being written.
161 * @param mixed $meta_value Meta value being written.
162 */
163 public function sync_breadcrumb_title_to_plugins($meta_id, $post_id, $meta_key, $meta_value) {
164 if ($meta_key !== '_metasync_breadcrumb_title') {
165 return;
166 }
167
168 // The user disabled breadcrumbs and no third-party breadcrumb provider
169 // is active: the override can never reach a trail, so we must not touch
170 // another plugin's storage on their behalf. Display-only gate — any
171 // stored value is left exactly as it is, so re-enabling breadcrumbs
172 // brings the override back.
173 if (!self::is_breadcrumb_title_meaningful()) {
174 return;
175 }
176
177 static $is_cross_writing = false;
178 if ($is_cross_writing) {
179 return;
180 }
181
182 if (!function_exists('is_plugin_active')) {
183 require_once ABSPATH . 'wp-admin/includes/plugin.php';
184 }
185
186 $is_cross_writing = true;
187
188 // Consent gate and write-once backup — a breadcrumb title copied into
189 // Yoast or Rank Math is a permanent write into another plugin's storage
190 // like any other, so it asks the same permission and preserves whatever
191 // the field held before.
192 $may_write = class_exists('Metasync_Seo_Backup');
193
194 if ($may_write && (is_plugin_active('wordpress-seo/wp-seo.php') ||
195 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php'))) {
196 Metasync_Seo_Backup::write_post_meta($post_id, '_yoast_wpseo_bctitle', $meta_value);
197 }
198
199 if ($may_write && (is_plugin_active('seo-by-rank-math/rank-math.php') ||
200 is_plugin_active('seo-by-rankmath/rank-math.php'))) {
201 Metasync_Seo_Backup::write_post_meta($post_id, 'rank_math_breadcrumb_title', $meta_value);
202 }
203
204 $is_cross_writing = false;
205 }
206
207 /**
208 * Is a breadcrumb title override meaningful on this site?
209 *
210 * True when MetaSync's own breadcrumb trail is enabled (the default, so a
211 * fresh install that never touched the breadcrumbs settings reads as
212 * enabled), OR when a plugin we cross-write the override into is active.
213 *
214 * A site that turned our breadcrumbs off and runs neither Yoast nor Rank
215 * Math has no breadcrumb the override could ever reach — there the
216 * Breadcrumb Title Override field is hidden and the cross-write is skipped.
217 * Disabling our trail does NOT disable Yoast / Rank Math breadcrumbs (those
218 * render from their own code paths), so the override stays meaningful on a
219 * Yoast site even with our breadcrumbs switched off.
220 *
221 * AIOSEO is deliberately excluded: we do not cross-write to it today.
222 *
223 * Shared by the Gutenberg sidebar (field visibility), the classic SEO
224 * Suite and its save handler, and the meta cross-write below.
225 *
226 * @return bool
227 */
228 public static function is_breadcrumb_title_meaningful() {
229 $settings = Metasync::get_option('breadcrumbs', array());
230 if (!is_array($settings)) {
231 $settings = array();
232 }
233 $settings = wp_parse_args($settings, array('enabled' => true));
234
235 if (!empty($settings['enabled'])) {
236 return true;
237 }
238
239 return self::is_third_party_breadcrumb_provider_active();
240 }
241
242 /**
243 * Is a third-party breadcrumb provider we cross-write to active?
244 *
245 * The same plugin-active checks the cross-write itself uses — Yoast free
246 * and Premium plus both Rank Math slug variants. AIOSEO is not included.
247 *
248 * @return bool
249 */
250 private static function is_third_party_breadcrumb_provider_active() {
251 if (!function_exists('is_plugin_active')) {
252 require_once ABSPATH . 'wp-admin/includes/plugin.php';
253 }
254
255 if (is_plugin_active('wordpress-seo/wp-seo.php') ||
256 is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {
257 return true;
258 }
259
260 if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
261 is_plugin_active('seo-by-rankmath/rank-math.php')) {
262 return true;
263 }
264
265 return false;
266 }
267
268 /**
269 * Register post meta fields for breadcrumb title override and primary category.
270 */
271 public function register_meta_fields() {
272 $post_types = get_post_types(array('public' => true), 'names');
273
274 foreach ($post_types as $post_type) {
275 register_post_meta($post_type, '_metasync_breadcrumb_title', array(
276 'show_in_rest' => true,
277 'single' => true,
278 'type' => 'string',
279 'sanitize_callback' => 'sanitize_text_field',
280 'auth_callback' => function () {
281 return current_user_can('edit_posts');
282 },
283 ));
284
285 register_post_meta($post_type, '_metasync_primary_category', array(
286 'show_in_rest' => true,
287 'single' => true,
288 'type' => 'integer',
289 'sanitize_callback' => 'absint',
290 'auth_callback' => function () {
291 return current_user_can('edit_posts');
292 },
293 ));
294 }
295 }
296
297 /**
298 * Register the [metasync_breadcrumb] shortcode.
299 */
300 public function register_shortcode() {
301 add_shortcode('metasync_breadcrumb', array($this, 'render_breadcrumb_html'));
302 }
303
304 /**
305 * Resolve the breadcrumb trail for the current page context.
306 *
307 * Returns an ordered array of [ 'label' => string, 'url' => string|'' ].
308 * The last item may have an empty URL (current page).
309 *
310 * @param int $post_id Optional. Explicit post ID (used by MCP tool).
311 * @return array
312 */
313 public function resolve_breadcrumb_trail($post_id = 0) {
314 $trail = array();
315
316 // Home item.
317 $home_url = !empty($this->settings['home_url']) ? $this->settings['home_url'] : home_url('/');
318 $home_label = !empty($this->settings['home_label']) ? $this->settings['home_label'] : 'Home';
319 $trail[] = array('label' => $home_label, 'url' => $home_url);
320
321 // --- Front page ---
322 if (is_front_page()) {
323 return $trail;
324 }
325
326 // --- WooCommerce pages (must be checked before generic singular) ---
327 if (class_exists('WooCommerce')) {
328 $woo_trail = $this->resolve_woocommerce_trail($post_id);
329 if ($woo_trail !== null) {
330 return array_merge($trail, $woo_trail);
331 }
332 }
333
334 // --- Single post / page / CPT ---
335 if (is_singular()) {
336 $current_post = $post_id ? get_post($post_id) : get_queried_object();
337 if (!$current_post) {
338 return $trail;
339 }
340
341 if (is_page()) {
342 // Build parent chain.
343 $ancestors = get_post_ancestors($current_post);
344 $ancestors = array_reverse($ancestors);
345 foreach ($ancestors as $ancestor_id) {
346 $ancestor = get_post($ancestor_id);
347 if ($ancestor) {
348 $trail[] = array(
349 'label' => get_the_title($ancestor),
350 'url' => get_permalink($ancestor),
351 );
352 }
353 }
354 } elseif ($current_post->post_type === 'post') {
355 // Category in trail — respect primary category.
356 $category = $this->get_primary_category($current_post->ID);
357 if ($category) {
358 // Build category ancestor chain.
359 $cat_ancestors = get_ancestors($category->term_id, 'category');
360 $cat_ancestors = array_reverse($cat_ancestors);
361 foreach ($cat_ancestors as $cat_ancestor_id) {
362 $cat_ancestor = get_term($cat_ancestor_id, 'category');
363 if ($cat_ancestor && !is_wp_error($cat_ancestor)) {
364 $trail[] = array(
365 'label' => $this->format_archive_label($cat_ancestor->name),
366 'url' => get_term_link($cat_ancestor),
367 );
368 }
369 }
370 $trail[] = array(
371 'label' => $this->format_archive_label($category->name),
372 'url' => get_term_link($category),
373 );
374 }
375 } else {
376 // Custom post type — add post type archive link if available.
377 $post_type_obj = get_post_type_object($current_post->post_type);
378 if ($post_type_obj && $post_type_obj->has_archive) {
379 $trail[] = array(
380 'label' => $post_type_obj->labels->name,
381 'url' => get_post_type_archive_link($current_post->post_type),
382 );
383 }
384 }
385
386 // Final item — the current post.
387 $label = $this->get_breadcrumb_title($current_post->ID);
388 $trail[] = array('label' => $label, 'url' => '');
389
390 return $trail;
391 }
392
393
394 // --- Category archive ---
395 if (is_category()) {
396 $category = get_queried_object();
397 if ($category) {
398 $cat_ancestors = get_ancestors($category->term_id, 'category');
399 $cat_ancestors = array_reverse($cat_ancestors);
400 foreach ($cat_ancestors as $cat_ancestor_id) {
401 $cat_ancestor = get_term($cat_ancestor_id, 'category');
402 if ($cat_ancestor && !is_wp_error($cat_ancestor)) {
403 $trail[] = array(
404 'label' => $this->format_archive_label($cat_ancestor->name),
405 'url' => get_term_link($cat_ancestor),
406 );
407 }
408 }
409 $trail[] = array('label' => $this->format_archive_label($category->name), 'url' => '');
410 }
411 return $trail;
412 }
413
414 // --- Tag archive ---
415 if (is_tag()) {
416 $tag = get_queried_object();
417 if ($tag) {
418 $trail[] = array('label' => $this->format_archive_label($tag->name), 'url' => '');
419 }
420 return $trail;
421 }
422
423 // --- Author archive ---
424 if (is_author()) {
425 $author = get_queried_object();
426 if ($author) {
427 $trail[] = array('label' => $author->display_name, 'url' => '');
428 }
429 return $trail;
430 }
431
432 // --- Date archive ---
433 if (is_date()) {
434 if (is_year()) {
435 $trail[] = array('label' => get_the_date('Y'), 'url' => '');
436 } elseif (is_month()) {
437 $trail[] = array('label' => get_the_date('Y'), 'url' => get_year_link(get_the_date('Y')));
438 $trail[] = array('label' => get_the_date('F'), 'url' => '');
439 } elseif (is_day()) {
440 $trail[] = array('label' => get_the_date('Y'), 'url' => get_year_link(get_the_date('Y')));
441 $trail[] = array('label' => get_the_date('F'), 'url' => get_month_link(get_the_date('Y'), get_the_date('m')));
442 $trail[] = array('label' => get_the_date('j'), 'url' => '');
443 }
444 return $trail;
445 }
446
447 // --- Search results ---
448 if (is_search()) {
449 $trail[] = array(
450 /* translators: %s: search query. */
451 'label' => sprintf(__('Search results for "%s"', 'metasync'), get_search_query()),
452 'url' => '',
453 );
454 return $trail;
455 }
456
457 // --- 404 ---
458 if (is_404()) {
459 $trail[] = array('label' => __('404 Not Found', 'metasync'), 'url' => '');
460 return $trail;
461 }
462
463 // --- Post type archive (generic) ---
464 if (is_post_type_archive()) {
465 $post_type_obj = get_queried_object();
466 if ($post_type_obj) {
467 $trail[] = array('label' => $post_type_obj->labels->name, 'url' => '');
468 }
469 return $trail;
470 }
471
472 // --- Taxonomy archive (generic) ---
473 if (is_tax()) {
474 $term = get_queried_object();
475 if ($term) {
476 // Walk term ancestors so nested terms keep their parents, and so
477 // every intermediate crumb carries a real URL. The taxonomy label
478 // itself has no archive of its own to link to, so emitting it here
479 // produced an intermediate ListItem with no `item` property, which
480 // Google rejects.
481 $ancestors = array_reverse(get_ancestors($term->term_id, $term->taxonomy, 'taxonomy'));
482 foreach ($ancestors as $ancestor_id) {
483 $ancestor = get_term($ancestor_id, $term->taxonomy);
484 if (is_wp_error($ancestor) || !$ancestor) {
485 continue;
486 }
487 $ancestor_link = get_term_link($ancestor);
488 $trail[] = array(
489 'label' => $this->format_archive_label($ancestor->name),
490 'url' => is_wp_error($ancestor_link) ? '' : $ancestor_link,
491 );
492 }
493 $trail[] = array('label' => $this->format_archive_label($term->name), 'url' => '');
494 }
495 return $trail;
496 }
497
498 return $trail;
499 }
500
501 /**
502 * Resolve WooCommerce-specific breadcrumb segments.
503 *
504 * @param int $post_id Optional explicit post ID.
505 * @return array|null Null when the current page is not a WooCommerce page.
506 */
507 private function resolve_woocommerce_trail($post_id = 0) {
508 $trail = array();
509
510 // Shop page.
511 // wc_get_page_id() returns -1 (not 0) when the shop page is unset. -1 is
512 // truthy, so an unguarded check yields get_the_title(-1) === '' and
513 // get_permalink(-1) === false — an intermediate crumb with neither name
514 // nor URL.
515 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
516 if ($shop_page_id < 1) {
517 $shop_page_id = 0;
518 }
519 $shop_label = $shop_page_id ? get_the_title($shop_page_id) : __('Shop', 'metasync');
520 $shop_url = $shop_page_id ? get_permalink($shop_page_id) : '';
521
522 // WooCommerce product category archive.
523 if (is_tax('product_cat')) {
524 $trail[] = array('label' => $shop_label, 'url' => $shop_url);
525 $term = get_queried_object();
526 if ($term) {
527 $cat_ancestors = get_ancestors($term->term_id, 'product_cat');
528 $cat_ancestors = array_reverse($cat_ancestors);
529 foreach ($cat_ancestors as $anc_id) {
530 $anc = get_term($anc_id, 'product_cat');
531 if ($anc && !is_wp_error($anc)) {
532 $trail[] = array('label' => $this->format_archive_label($anc->name), 'url' => get_term_link($anc));
533 }
534 }
535 $trail[] = array('label' => $this->format_archive_label($term->name), 'url' => '');
536 }
537 return $trail;
538 }
539
540 // WooCommerce shop page.
541 if (is_shop()) {
542 $trail[] = array('label' => $shop_label, 'url' => '');
543 return $trail;
544 }
545
546 // Single product (frontend context or explicit post_id in MCP/CLI).
547 $is_product = is_singular('product');
548 if (!$is_product && $post_id > 0) {
549 $maybe_post = get_post($post_id);
550 $is_product = $maybe_post && $maybe_post->post_type === 'product';
551 }
552 if ($is_product) {
553 $trail[] = array('label' => $shop_label, 'url' => $shop_url);
554
555 $product_post = $post_id ? get_post($post_id) : get_queried_object();
556 if ($product_post) {
557 $terms = get_the_terms($product_post->ID, 'product_cat');
558 if ($terms && !is_wp_error($terms)) {
559 // Use first product category (or primary if set).
560 $primary_cat_id = get_post_meta($product_post->ID, '_metasync_primary_category', true);
561 $category = null;
562 if ($primary_cat_id) {
563 foreach ($terms as $t) {
564 if ((int) $t->term_id === (int) $primary_cat_id) {
565 $category = $t;
566 break;
567 }
568 }
569 }
570 if (!$category) {
571 $category = $terms[0];
572 }
573
574 $cat_ancestors = get_ancestors($category->term_id, 'product_cat');
575 $cat_ancestors = array_reverse($cat_ancestors);
576 foreach ($cat_ancestors as $anc_id) {
577 $anc = get_term($anc_id, 'product_cat');
578 if ($anc && !is_wp_error($anc)) {
579 $trail[] = array('label' => $this->format_archive_label($anc->name), 'url' => get_term_link($anc));
580 }
581 }
582 $trail[] = array('label' => $this->format_archive_label($category->name), 'url' => get_term_link($category));
583 }
584
585 $label = $this->get_breadcrumb_title($product_post->ID);
586 $trail[] = array('label' => $label, 'url' => '');
587 }
588 return $trail;
589 }
590
591 // Not a WooCommerce page.
592 return null;
593 }
594
595 /**
596 * Get the breadcrumb title for a post, with override support.
597 *
598 * @param int $post_id Post ID.
599 * @return string
600 */
601 private function get_breadcrumb_title($post_id) {
602 $override = get_post_meta($post_id, '_metasync_breadcrumb_title', true);
603 if (!empty($override)) {
604 return $override;
605 }
606 return get_the_title($post_id);
607 }
608
609 /**
610 * Get the primary category for a post.
611 *
612 * Falls back to the first assigned category.
613 *
614 * @param int $post_id Post ID.
615 * @return WP_Term|null
616 */
617 private function get_primary_category($post_id) {
618 $primary_cat_id = get_post_meta($post_id, '_metasync_primary_category', true);
619
620 if ($primary_cat_id) {
621 $term = get_term((int) $primary_cat_id, 'category');
622 if ($term && !is_wp_error($term)) {
623 return $term;
624 }
625 }
626
627 $categories = get_the_category($post_id);
628 if (!empty($categories)) {
629 return $categories[0];
630 }
631
632 return null;
633 }
634
635 /**
636 * Format an archive label using the configured format.
637 *
638 * @param string $name Term/archive name.
639 * @return string
640 */
641 private function format_archive_label($name) {
642 $format = $this->settings['archive_label_format'];
643 if (empty($format) || $format === '{name}') {
644 return $name;
645 }
646 return str_replace('{name}', $name, $format);
647 }
648
649 /**
650 * Render breadcrumb HTML.
651 *
652 * Can be called as a shortcode callback or directly.
653 *
654 * @param array $args Shortcode / function arguments (unused reserved).
655 * @return string HTML output.
656 */
657 public function render_breadcrumb_html($args = array()) {
658 if (empty($this->settings['enabled'])) {
659 return '';
660 }
661
662 $trail = $this->resolve_breadcrumb_trail();
663 if (empty($trail)) {
664 return '';
665 }
666
667 // Enqueue on render: the stylesheet only targets this markup, so it is
668 // loaded here — after the enabled check and a confirmed trail — rather
669 // than site-wide from wp_enqueue_scripts. Every render path (shortcode,
670 // template function, WooCommerce override) funnels through this method,
671 // and a late-enqueued style prints in the footer.
672 // The template function and the schema module both instantiate this class
673 // with the constructor's default-version instance ('1.0.0'), so whichever
674 // render path runs first would otherwise decide the cache-bust version.
675 // Prefer the plugin-wide constant so the version is consistent regardless
676 // of which instance renders.
677 wp_enqueue_style(
678 'metasync-breadcrumbs',
679 plugin_dir_url(dirname(__FILE__)) . 'breadcrumbs/css/metasync-breadcrumbs.css',
680 array(),
681 defined('METASYNC_VERSION') ? METASYNC_VERSION : $this->version
682 );
683
684 $separator = $this->settings['separator'];
685 $prefix_text = $this->settings['prefix_text'];
686 $show_current = $this->settings['show_current_page'];
687
688 $items_html = array();
689 $count = count($trail);
690
691 foreach ($trail as $index => $item) {
692 $is_last = ($index === $count - 1);
693
694 // Skip last item if show_current_page is disabled.
695 if ($is_last && !$show_current && $count > 1) {
696 continue;
697 }
698
699 // Check if this is not the last item (for separator rendering)
700 $should_have_separator = !$is_last;
701
702 if ($is_last || empty($item['url'])) {
703 $items_html[] = '<li class="metasync-breadcrumb__item metasync-breadcrumb__current">'
704 . '<span aria-current="page">' . esc_html($item['label']) . '</span>'
705 . '</li>';
706 } else {
707 $separator_attr = $should_have_separator
708 ? ' data-separator="' . esc_attr($separator) . '"'
709 : '';
710
711 $items_html[] = '<li class="metasync-breadcrumb__item"' . $separator_attr . '>'
712 . '<a class="metasync-breadcrumb__link" href="' . esc_url($item['url']) . '">'
713 . esc_html($item['label'])
714 . '</a>'
715 . '</li>';
716 }
717 }
718
719 $list_html = implode('', $items_html);
720
721 $prefix_html = '';
722 if (!empty($prefix_text)) {
723 $prefix_html = '<span class="metasync-breadcrumb__prefix">' . esc_html($prefix_text) . '</span>';
724 }
725
726 // Add dynamic separator CSS for the configured separator character.
727 // <style> is a raw-text element: HTML entities are not decoded there,
728 // so esc_attr() would print "&gt;" literally for the ">" preset.
729 // Escape for a CSS string (backslash + double quote) instead.
730 $separator_css = str_replace(array('\\', '"'), array('\\\\', '\\"'), $separator);
731 // A stored "</" sequence would close the raw-text <style> element
732 // early. Break the token with a CSS escape ("<\/" still renders "</").
733 $separator_css = preg_replace('#</#', '<\\/', $separator_css);
734 $separator_style = '<style>.metasync-breadcrumb__item[data-separator]::after { content: " ' . $separator_css . ' "; }</style>';
735
736 $html = $separator_style
737 . '<nav class="metasync-breadcrumb" aria-label="breadcrumb">'
738 . $prefix_html
739 . '<ul class="metasync-breadcrumb__list">'
740 . $list_html
741 . '</ul>'
742 . '</nav>';
743
744 return $html;
745 }
746 }
747
748 /**
749 * Global template function for themes.
750 *
751 * Usage: <?php if ( function_exists( 'metasync_breadcrumb' ) ) metasync_breadcrumb(); ?>
752 */
753 if (!function_exists('metasync_breadcrumb')) {
754 function metasync_breadcrumb() {
755 $breadcrumbs = new Metasync_Breadcrumbs();
756 echo $breadcrumbs->render_breadcrumb_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is escaped inside render method
757 }
758 }
759