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 / public / class-metasync-seo-output.php

class-metasync-seo-output.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at public/class-metasync-seo-output.php

1,908 lines 67.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // If this file is called directly, abort.
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * SEO/front-end output functionality of the plugin.
9 *
10 * Handles all SEO meta tag output, structured data, robots directives,
11 * and sitemap filtering for the public-facing side of the site.
12 *
13 * @link https://searchatlas.com
14 * @since 1.0.0
15 *
16 * @package Metasync
17 * @subpackage Metasync/public
18 * @author Engineering Team <[email protected]>
19 */
20
21 class Metasync_Seo_Output
22 {
23 private $plugin_name;
24 private $version;
25 private $common;
26 private $metasync_option_data;
27
28 public function __construct($plugin_name, $version)
29 {
30 $this->plugin_name = $plugin_name;
31 $this->version = $version;
32 $this->common = new Metasync_Common();
33 $this->metasync_option_data = Metasync::get_option('general');
34
35 // Filter permalink to use primary category (for canonical URLs and sitemaps)
36 add_filter('post_link_category', array($this, 'filter_post_link_category_primary'), 10, 3);
37 }
38
39 /**
40 * Filter the category used in post permalinks to respect the primary category.
41 * Falls back to Yoast SEO or Rank Math primary category if MetaSync's is not set.
42 *
43 * @param WP_Term $cat The category to use in the permalink
44 * @param array $cats Array of all categories associated with the post
45 * @param WP_Post $post The post object
46 * @return WP_Term The primary category term or the original category
47 */
48 public function filter_post_link_category_primary($cat, $cats, $post)
49 {
50 // When the sitemap generator is collecting URLs on a %category% permalink
51 // site, it primes a per-chunk prefetch map of the primary-category meta
52 // keys (because update_post_meta_cache is disabled there to avoid memory
53 // exhaustion on large catalogs). Read from that map first and only fall
54 // back to get_post_meta() — which lazily fetches on a cache miss — when
55 // the map has no entry for this post. Outside sitemap generation the map
56 // is empty, so behaviour is unchanged.
57 $prefetch = (class_exists('Metasync_Sitemap_Generator') && !empty(Metasync_Sitemap_Generator::$primary_category_prefetch))
58 ? Metasync_Sitemap_Generator::$primary_category_prefetch
59 : [];
60
61 $primary_id = (int) self::primary_meta_lookup($post->ID, '_metasync_primary_category', $prefetch);
62
63 // Fallback to Yoast SEO primary category
64 if ($primary_id === 0 && defined('WPSEO_VERSION')) {
65 $primary_id = (int) self::primary_meta_lookup($post->ID, '_yoast_wpseo_primary_category', $prefetch);
66 }
67
68 // Fallback to Rank Math primary category
69 if ($primary_id === 0 && defined('RANK_MATH_VERSION')) {
70 $primary_id = (int) self::primary_meta_lookup($post->ID, 'rank_math_primary_category', $prefetch);
71 }
72
73 if ($primary_id === 0 || empty($cats)) {
74 return $cat;
75 }
76
77 foreach ($cats as $category) {
78 if ((int) $category->term_id === $primary_id) {
79 return $category;
80 }
81 }
82
83 return $cat;
84 }
85
86 /**
87 * Resolve a single primary-category meta value, preferring the sitemap
88 * generator's per-chunk prefetch map (populated on %category% permalink
89 * sites where the WP_Query post-meta cache prime is disabled) and falling
90 * back to get_post_meta() when the map has no entry for the post/key.
91 *
92 * @param int $post_id Post ID.
93 * @param string $key Meta key to resolve.
94 * @param array $prefetch Map of post_id => [meta_key => value].
95 * @return string The scalar meta value (empty string when not set).
96 */
97 private static function primary_meta_lookup($post_id, $key, array $prefetch)
98 {
99 $pid = (int) $post_id;
100 if (isset($prefetch[$pid]) && is_array($prefetch[$pid]) && array_key_exists($key, $prefetch[$pid])) {
101 return (string) $prefetch[$pid][$key];
102 }
103 return (string) get_post_meta($post_id, $key, true);
104 }
105
106 function metasync_wp_robots_meta($robots)
107 {
108 // Both robots features switched off — MetaSync emits no robots tag of its
109 // own, so blanking core's directives here would leave the page with no
110 // robots tag at all. Hand core's array back untouched.
111 if (Metasync_Feature_Flags::robots_fully_disabled()) {
112 return $robots;
113 }
114 // When the site is set to discourage search engines, core already
115 // emits noindex; blanking every directive here would strip that and
116 // make a discouraged site indexable. Leave the tags untouched.
117 if ((int) get_option('blog_public', 1) === 0) {
118 return $robots;
119 }
120 // On a singular view MetaSync only owns the tag when it actually resolves
121 // a value. With one robots half switched off the resolved value can come
122 // back empty — every directive the post had belonged to the disabled half
123 // — and blanking core here would leave the page with no robots tag at all.
124 if (function_exists('is_singular') && is_singular()) {
125 $post_id = get_the_ID();
126 if ($post_id && $this->resolve_robots_value(get_post_meta($post_id)) === '') {
127 return $robots;
128 }
129 }
130 foreach ($robots as $key => $value) {
131 $robots[$key] = false;
132 }
133 return $robots;
134 }
135
136 public function print_metatag($name, $value, $valueAttrib = "content", $nameAttrib = "name", $tagName = "meta")
137 {
138 // Safety: extract string from array values (fixes legacy meta stored as
139 // arrays). Recursive — a single reset() on a nested array
140 // returns another array and printf would emit the literal "Array".
141 if (is_array($value)) {
142 $value = Metasync_Canonical_Sanitizer::extract_scalar($value);
143 }
144
145 if (empty($value))
146 return false;
147
148 printf(
149 "\t<%s %s=\"%s\" %s=\"%s\" />\n",
150 esc_attr($tagName),
151 esc_attr($nameAttrib),
152 esc_attr($name),
153 esc_attr($valueAttrib),
154 esc_attr($value)
155 );
156 }
157
158 /**
159 * Check if the current page is an AMP page
160 *
161 * @return bool True if AMP page, false otherwise
162 */
163 public function is_amp_page()
164 {
165 // Check if URL path contains /amp/
166 $current_url = $_SERVER['REQUEST_URI'] ?? '';
167 if (strpos($current_url, '/amp/') !== false) {
168 return true;
169 }
170
171 // Check if URL ends with /amp
172 if (preg_match('/\/amp\/?$/', $current_url)) {
173 return true;
174 }
175
176 // Check if amp=1 query parameter is present
177 if (isset($_GET['amp']) && $_GET['amp'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
178 return true;
179 }
180
181 // Check for other common AMP query parameters
182 if (isset($_GET['amp']) && !empty($_GET['amp'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
183 return true;
184 }
185
186 return false;
187 }
188
189 /**
190 * Remove metasync_optimized attribute from head tag on AMP pages
191 * This function uses output buffering to clean the head content
192 */
193 public function cleanup_amp_head_attribute()
194 {
195 // Only run on AMP pages
196 if (!$this->is_amp_page()) {
197 return;
198 }
199
200 // Start output buffering to capture and modify the head content
201 ob_start(function($buffer) {
202 // Remove metasync_optimized attribute from head tag
203 $cleaned_buffer = preg_replace('/(<head[^>]*)\s*metasync_optimized(?:="[^"]*")?([^>]*>)/i', '$1$2', $buffer);
204
205 return $cleaned_buffer;
206 });
207 }
208
209 /**
210 * End output buffering for AMP cleanup
211 */
212 public function end_amp_head_cleanup()
213 {
214 // Only run on AMP pages
215 if (!$this->is_amp_page()) {
216 return;
217 }
218
219 // End output buffering
220 if (ob_get_level()) {
221 ob_end_flush();
222 }
223 }
224
225 public function hook_metasync_metatags()
226 {
227 $conflict_handler = Metasync_SEO_Conflict_Handler::get_instance();
228
229 // On archive pages (category, tag, author, date, blog home) get_the_ID()
230 // returns the first post in the loop — not the archive itself. Guard here so
231 // we never use that first-post ID for canonical / og:url on archives.
232 // Instead, emit the correct archive URL and return early.
233 if (!is_singular()) {
234 if (!$conflict_handler->has_active_seo_plugin()) {
235 $archive_url = $this->get_archive_canonical_url();
236 if (!empty($archive_url)) {
237 // The canonical link and og:url are owned by two different
238 // feature switches, so each is emitted on its own terms.
239 if (Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::CANONICAL)) {
240 printf("\t<link rel=\"canonical\" href=\"%s\" />\n", esc_url($archive_url));
241 }
242 if (Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG)) {
243 printf("\t<meta property=\"og:url\" content=\"%s\" />\n", esc_url($archive_url));
244 }
245 }
246 }
247 return;
248 }
249
250 // Singular post/page: suppress WP core's canonical and let MetaSync handle it.
251 // With the canonical feature switched off MetaSync emits none of its own, so
252 // core's must be left in place rather than removed.
253 if (!$conflict_handler->has_active_seo_plugin()
254 && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::CANONICAL)) {
255 remove_action('wp_head', 'rel_canonical');
256 }
257
258 $get_page_meta = get_post_meta(get_the_ID());
259
260 // When a third-party SEO plugin is active, only output our description
261 // if MetaSync has an intentional value (OTTO or sidebar). Otherwise let
262 // the other plugin handle it to avoid duplicates.
263 $include_description = $conflict_handler->should_output_legacy_description();
264
265 $list_page_meta = array();
266
267 // When synced to an active SEO plugin, skip MetaSync's own
268 // robots and description output — the native plugin handles it.
269 $post_id = get_the_ID();
270 $is_synced_to_plugin = false;
271 if ($post_id && $conflict_handler->has_active_seo_plugin()) {
272 $sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
273 if (!empty($sync_ts)) {
274 $is_synced_to_plugin = true;
275 }
276 }
277
278 // Robots: resolve from both meta_robots (REST API) and metasync_common_robots (checkbox).
279 // When a third-party SEO plugin is active:
280 // - MetaSync has robots value → output ours, AIOSEO suppressed via filter
281 // - MetaSync has NO value → skip, let the other plugin handle it
282 // When no third-party plugin → always output if we have a value.
283 if (!$is_synced_to_plugin) {
284 $robots_value = $this->resolve_robots_value($get_page_meta);
285 if (!empty($robots_value)) {
286 if (!$conflict_handler->has_active_seo_plugin()) {
287 $list_page_meta['robots'] = $robots_value;
288 } else {
289 // Third-party active but MetaSync has intentional robots — output ours
290 // (the conflict handler filter suppresses the other plugin's robots tag)
291 if ($post_id && $conflict_handler->metasync_has_robots($post_id)) {
292 $list_page_meta['robots'] = $robots_value;
293 }
294 }
295 }
296 }
297
298 if ($include_description) {
299 $list_page_meta['description'] = $get_page_meta['meta_description'][0] ?? '';
300 }
301
302 // Note: enable_metadesc is always enabled by default - no check needed
303
304 $getSearchEngineOptions = Metasync::get_option('searchengines');
305 $keysSearchEngines = [
306 'bing_site_verification' => 'msvalidate.01',
307 'baidu_site_verification' => 'baidu-site-verification',
308 'alexa_site_verification' => 'alexaVerifyID',
309 'yandex_site_verification' => 'yandex-verification',
310 'google_site_verification' => 'google-site-verification',
311 'pinterest_site_verification' => 'p:domain_verify',
312 'norton_save_site_verification' => 'norton-safeweb-site-verification',
313 ];
314
315 $post = get_post(get_the_ID());
316 if (empty($post))
317 return;
318
319 # Keywords: resolve through the precedence chain rather than the customer
320 # tier alone. OTTO writes its suggestion to a staging key on every active
321 # sync; reading only the customer tier meant the suggestion rendered solely
322 # on pages OTTO server-side renders itself, and vanished on every path OTTO
323 # bails out of (404, search, paged archive, cart, AJAX, throttled bot).
324 # resolve() also honours the per-post "Disable OTTO" toggle, so a post OTTO
325 # has stood down from leaks no keyword of OTTO's — not the staging value,
326 # not the copy the meta_keywords persistence flag saved to its own key.
327 # The global SEO priority setting orders the customer and OTTO tiers the
328 # same way it orders title and description. A value the flag already
329 # copied into _metasync_focus_keyword before the tiers were separated is
330 # the customer tier by then and still renders — the toggle drops OTTO's
331 # own tiers, not everything OTTO ever wrote.
332 $focus_keyword = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD);
333 if (!empty($focus_keyword)) {
334 $list_page_meta['keywords'] = sanitize_text_field($focus_keyword);
335 }
336
337 # Canonical: output <link rel="canonical"> only when no third-party SEO plugin
338 # handles it, to avoid duplicate canonical tags in the page source.
339 if (!$conflict_handler->has_active_seo_plugin()
340 && Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::CANONICAL)) {
341 $list_page_meta['canonical'] = $this->get_canonical_url($post);
342 }
343
344 # $post_text = wp_trim_words(get_the_content(), 30, '');
345
346 # Check if the post has content, then apply WordPress content filters
347 # $post_content = !empty($post->post_content) ? apply_filters('the_content', $post->post_content) : '';
348 # $post_text = wp_trim_words($post_content, 30, '');
349
350 /**
351 * Extract post content safely for meta descriptions
352 * This solution addresses three critical issues:
353 * 1. Timber compatibility - works without WordPress loop
354 * 2. Plugin conflicts - prevents shortcode execution (e.g., Hostify-booking function redeclaration)
355 * 3. Performance - lightweight content extraction for meta tags
356 */
357
358 $post_text = '';
359
360 # Get content safely without executing shortcodes
361 $content = get_the_content(null, false, $post);
362
363 # Validate content exists
364 if (!empty($content)) {
365 # Clean content for meta description - multi-layer approach for reliability
366 $content = strip_shortcodes($content); # Remove shortcode tags (e.g., [hostify-booking])
367 $content = wp_strip_all_tags($content); # Remove HTML tags
368 $content = preg_replace('/\s+/', ' ', $content); # Normalize whitespace
369 $post_text = wp_trim_words(trim($content), 30, '');
370 }
371
372 $site_info = Metasync::get_option('optimal_settings')['site_info'] ?? [];
373
374 $facebook_page_url = Metasync::get_option('social_meta')['facebook_page_url'] ?? '';
375 $facebook_authorship = Metasync::get_option('social_meta')['facebook_authorship'] ?? '';
376 $facebook_admin = Metasync::get_option('social_meta')['facebook_admin'] ?? '';
377
378 $twitter_username = Metasync::get_option('social_meta')['twitter_username'] ?? '';
379
380 $image = [];
381 $image_mime_type = '';
382
383 // Resolve the Open Graph image from attachment metadata only — no disk
384 // reads, no HTTP calls. This path is READ-ONLY: it must never mutate post
385 // meta while rendering wp_head. Media-offload plugins (WP Stateless, WP
386 // Offload Media / S3) store the actual files in remote object storage, so
387 // the local file is legitimately absent. A missing local file is NOT proof
388 // of an orphaned attachment and must never trigger deletion of the featured
389 // image association.
390 if ($post) {
391 $image_id = get_post_thumbnail_id($post->ID);
392 if ($image_id) {
393 $og_image = $this->build_og_image_from_attachment($image_id);
394 if ($og_image) {
395 $image = [$og_image['url'], $og_image['width'], $og_image['height']];
396 $image_mime_type = $og_image['mime'];
397 }
398 }
399 }
400
401 // Fallback to site default image if post has no featured image.
402 if (empty($image) && $site_info && !empty($site_info['social_share_image'])) {
403 $og_image = $this->build_og_image_from_attachment($site_info['social_share_image']);
404 if ($og_image) {
405 $image = [$og_image['url'], $og_image['width'], $og_image['height']];
406 $image_mime_type = $og_image['mime'];
407 }
408 }
409
410
411 // When a third-party SEO plugin handles OG/Twitter, omit our description
412 // from those tags to avoid duplicates.
413 $og_description = ($include_description) ? ($post_text ?? '') : '';
414
415 $ogMetaKeys = [
416 'og:locale' => get_locale(),
417 'og:type' => 'article',
418 'og:title' => $post->post_title . ' - ' . get_bloginfo('name'),
419 'og:description' => $og_description,
420 'og:url' => $this->get_canonical_url($post),
421 'og:site_name' => get_bloginfo('name'),
422 'og:updated_time' => $post->post_modified,
423 'og:image' => $image ? $image[0] : '',
424 'og:image:width' => $image ? $image[1] : '',
425 'og:image:height' => $image ? $image[2] : '',
426 'og:image:type' => $image ? $image_mime_type : '',
427 'og:image:alt' => $image ? $post->post_title : '',
428 ];
429
430 $facebookMetaKeys = [
431 'article:publisher' => $facebook_page_url && !filter_var($facebook_page_url, FILTER_VALIDATE_URL) ? 'https://' . $facebook_page_url : $facebook_page_url,
432 'article:author' => $facebook_authorship && !filter_var($facebook_authorship, FILTER_VALIDATE_URL) ? 'https://' . $facebook_authorship : $facebook_authorship,
433 'fb:admins' => $facebook_admin,
434 ];
435
436 $twitter_card_type = Metasync::get_option('twitter_card_type') ?? [];
437
438 $twitterMetaKeys = [
439 'twitter:card' => $twitter_card_type ? $twitter_card_type : 'summary_large_image',
440 'twitter:title' => $post->post_title . ' - ' . get_bloginfo('name'),
441 'twitter:site' => $twitter_username ? '@' . $twitter_username : '',
442 'twitter:creator' => $twitter_username ? '@' . $twitter_username : '',
443 'twitter:description' => $og_description,
444 'twitter:image' => $image ? $image[0] : '',
445 ];
446
447 // echo "\t<!-- MetaSync metadata -->\n";
448
449 foreach ($list_page_meta as $item => $value) {
450 if ($item == 'canonical') {
451 $this->print_metatag($item, $value, 'href', 'rel', 'link');
452 continue;
453 }
454 $this->print_metatag($item, $value);
455 }
456
457 if ($getSearchEngineOptions !== null) { // check if searchengine verification options are set
458 foreach ($keysSearchEngines as $optionKey => $metaKey) {
459 $this->print_metatag($metaKey, $getSearchEngineOptions[$optionKey]);
460 }
461 }
462
463 if ($post) {
464
465 // When OTTO is active AND has OG/Twitter data for this post, skip
466 // legacy output. For dynamic pixel injection (where specific OTTO OG
467 // meta is empty), the buffer-level dedup in
468 // Otto_html_class::deduplicate_og_twitter_tags() handles cleanup.
469 $otto_has_og = false;
470 $otto_has_twitter = false;
471 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_otto_enabled()) {
472 $otto_has_og = !empty(get_post_meta($post->ID, '_metasync_otto_og_title', true))
473 || !empty(get_post_meta($post->ID, '_metasync_otto_og_description', true));
474 $otto_has_twitter = !empty(get_post_meta($post->ID, '_metasync_otto_twitter_title', true))
475 || !empty(get_post_meta($post->ID, '_metasync_otto_twitter_description', true));
476 }
477
478 // When the canonical emitter (Metasync_OpenGraph::output_opengraph_tags, wp_head
479 // priority 5) will emit for this post, suppress these legacy og:*/twitter:* blocks
480 // so a MetaSync-only page outputs exactly one OG set and one Twitter set, preferring
481 // the persisted/OTTO values that emitter resolves. The Facebook block
482 // (article:publisher / article:author / fb:admins) is NOT gated on it — the canonical
483 // emitter never outputs those tags, so they never duplicate.
484 $og2_will_emit = apply_filters('metasync_opengraph_will_emit', false);
485
486 // When a third-party SEO plugin (Yoast / Rank Math / AIOSEO) is active it owns the
487 // social tags — suppress ALL legacy Facebook/OG/Twitter output so MetaSync never
488 // duplicates the other plugin's og:*/twitter:*/article:* tags.
489 $has_seo_plugin = $conflict_handler->has_active_seo_plugin();
490
491 // The Social Media & Open Graph feature owns every tag emitted below —
492 // Facebook, Open Graph and Twitter alike. Switched off, this legacy
493 // fallback must stay silent just like the canonical emitter does.
494 $social_enabled = Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG);
495 $social_output_disabled = class_exists('Metasync_OpenGraph') && Metasync_OpenGraph::is_social_output_disabled($post->ID);
496
497 $common_meta_settings = Metasync::get_option('common_meta_settings') ?? [];
498
499 if ($social_enabled && !$social_output_disabled &&!$otto_has_og && !$has_seo_plugin && isset($common_meta_settings['facebook_meta_tags'])) {
500 foreach ($facebookMetaKeys as $metaKey => $metaValue) {
501 $this->print_metatag($metaKey, $metaValue, 'content', 'property');
502 }
503 }
504
505 if ($social_enabled && !$social_output_disabled &&!$otto_has_og && !$og2_will_emit && !$has_seo_plugin && isset($common_meta_settings['open_graph_meta_tags'])) {
506 foreach ($ogMetaKeys as $metaKey => $metaValue) {
507 $this->print_metatag($metaKey, $metaValue, 'content', 'property');
508 }
509 }
510 if ($social_enabled && !$social_output_disabled &&!$otto_has_twitter && !$og2_will_emit && !$has_seo_plugin && isset($common_meta_settings['twitter_meta_tags'])) {
511 foreach ($twitterMetaKeys as $metaKey => $metaValue) {
512 $this->print_metatag($metaKey, $metaValue, 'content', 'name');
513 }
514 }
515 }
516 }
517
518 /**
519 * Build Open Graph image data for an attachment using database metadata only.
520 *
521 * Intentionally avoids `file_exists()`/disk access and any HTTP validation so
522 * it works for media-offload setups (WP Stateless, WP Offload Media / S3) where
523 * the file lives in remote object storage rather than on the local filesystem.
524 * It is strictly read-only and never mutates post or attachment meta.
525 *
526 * The image URL is resolved via `wp_get_attachment_url()`, which offload plugins
527 * filter to the correct remote URL; it falls back to building the URL from the
528 * upload directory and stored metadata when needed.
529 *
530 * @param int|string $attachment_id Attachment ID.
531 * @return array|null ['url' => string, 'width' => int, 'height' => int, 'mime' => string] or null.
532 */
533 private function build_og_image_from_attachment($attachment_id)
534 {
535 $attachment_id = (int) $attachment_id;
536 if ($attachment_id <= 0) {
537 return null;
538 }
539
540 $attachment = get_post($attachment_id);
541 if (!$attachment || $attachment->post_type !== 'attachment') {
542 return null;
543 }
544
545 // Read dimensions from stored metadata — no disk or network access.
546 $metadata = wp_get_attachment_metadata($attachment_id);
547 $width = (is_array($metadata) && !empty($metadata['width'])) ? (int) $metadata['width'] : 0;
548 $height = (is_array($metadata) && !empty($metadata['height'])) ? (int) $metadata['height'] : 0;
549
550 // Prefer wp_get_attachment_url() so offload plugins (WP Stateless / S3)
551 // return the correct remote URL; fall back to upload dir + metadata file.
552 $image_url = wp_get_attachment_url($attachment_id);
553 if (!$image_url && is_array($metadata) && !empty($metadata['file'])) {
554 $upload_dir = wp_upload_dir();
555 if (empty($upload_dir['error'])) {
556 $image_url = trailingslashit($upload_dir['baseurl']) . ltrim($metadata['file'], '/');
557 }
558 }
559
560 if (!$image_url) {
561 return null;
562 }
563
564 // Determine MIME type from the file extension (safe, no HTTP calls).
565 $mime_types = [
566 'jpg' => 'image/jpeg',
567 'jpeg' => 'image/jpeg',
568 'png' => 'image/png',
569 'gif' => 'image/gif',
570 'webp' => 'image/webp',
571 'svg' => 'image/svg+xml',
572 ];
573 $file_ext = strtolower(pathinfo(wp_parse_url($image_url, PHP_URL_PATH) ?: $image_url, PATHINFO_EXTENSION));
574 $image_mime_type = $mime_types[$file_ext] ?? 'image/jpeg';
575
576 return [
577 'url' => $image_url,
578 'width' => $width,
579 'height' => $height,
580 'mime' => $image_mime_type,
581 ];
582 }
583
584 public function add_ld_json()
585 {
586 $post = get_post(get_the_ID());
587 if (empty($post))
588 return;
589
590 $site_info = Metasync::get_option('optimal_settings')['site_info'] ?? '';
591
592 $site_logo_id = $site_info['google_logo'] ?? '';
593 $custom_logo_id = get_theme_mod('custom_logo');
594 $logo_id = $custom_logo_id != '' ? $custom_logo_id : $site_logo_id;
595
596 $site_image_id = $site_info['social_share_image'] ?? '';
597 $post_thumbnail_id = get_post_thumbnail_id($post->ID);
598 $thumbnail_id = $post_thumbnail_id > 0 ? $post_thumbnail_id : $site_image_id;
599
600 $schema = array(
601 '@context' => "http://schema.org",
602 '@type' => "Article",
603 'headline' => $post->post_title ?? '',
604 'image' => wp_get_attachment_image_url($thumbnail_id, 'full'),
605 'url' => get_permalink(),
606 'datePublished' => $post->post_modified,
607 'author' => array(
608 '@type' => "Person",
609 'name' => get_the_author_meta('display_name', $post->post_author),
610 'url' => get_author_posts_url($post->post_author),
611 ),
612 'publisher' => array(
613 '@type' => "Organization",
614 'name' => get_bloginfo('name') ?? '',
615 'url' => get_site_url(),
616 'logo' => array(
617 '@type' => "ImageObject",
618 'url' => wp_get_attachment_image_url($logo_id, 'full'),
619 )
620 )
621 );
622
623 return $schema;
624 }
625
626 /**
627 * Output the LocalBusiness / Organization / Person JSON-LD built from the
628 * Local Business settings page (metasync_options['localseo']).
629 *
630 * Hooked to wp_head so the saved Local Business data appears in the
631 * front-end page source as valid schema.org structured data. Emits nothing
632 * on admin screens, feeds, or when the Local Business page is unconfigured.
633 */
634 public function output_local_business_schema()
635 {
636 if (is_admin() || is_feed()) {
637 return;
638 }
639
640 // The Schema Markup switch covers every JSON-LD block MetaSync owns,
641 // site-wide Local SEO markup included — not just the per-post editor box.
642 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) {
643 return;
644 }
645
646 $schema = $this->build_local_business_schema();
647 if (empty($schema)) {
648 return;
649 }
650
651 $json = metasync_safe_json_ld_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
652 if (empty($json)) {
653 return;
654 }
655
656 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON-LD document; metasync_safe_json_ld_encode() produces the JSON payload for a <script> context
657 echo "\t<script type=\"application/ld+json\">" . $json . "</script>\n";
658 }
659
660 /**
661 * Build the LocalBusiness structured-data entity from the saved localseo
662 * option. Returns null when the page has never been configured (no Person/
663 * Organization type selected), so unconfigured sites emit no empty schema.
664 *
665 * @param array|null $options Saved localseo options; when null they are
666 * read from metasync_options['localseo'].
667 * @return array|null JSON-LD entity, or null when nothing is configured.
668 */
669 public function build_local_business_schema($options = null)
670 {
671 if ($options === null) {
672 $options = Metasync::get_option('localseo');
673 }
674 if (empty($options) || !is_array($options)) {
675 return null;
676 }
677
678 $type = $options['local_seo_person_organization'] ?? '';
679 if ($type !== 'Organization' && $type !== 'Person') {
680 return null;
681 }
682
683 $url = !empty($options['local_seo_url']) ? $options['local_seo_url'] : get_home_url();
684
685 $entity = array(
686 '@context' => 'https://schema.org',
687 '@type' => '',
688 'name' => '',
689 'url' => $url,
690 );
691
692 if (!empty($options['local_seo_email'])) {
693 $entity['email'] = $options['local_seo_email'];
694 }
695
696 if ($type === 'Person') {
697 $entity = $this->local_business_person($entity, $options);
698 } else {
699 $entity = $this->local_business_organization($entity, $options);
700 }
701
702 return $entity;
703 }
704
705 /**
706 * Populate an Organization / LocalBusiness entity.
707 *
708 * @param array $entity Base JSON-LD entity.
709 * @param array $options Saved localseo options.
710 * @return array
711 */
712 private function local_business_organization($entity, $options)
713 {
714 $name = (string) ($options['local_seo_name'] ?? '');
715 $entity['name'] = $name !== '' ? $name : (string) get_bloginfo('name');
716
717 // The Business Type field stores a human-readable label (e.g. "Local
718 // Business", "Art Gallery"). schema.org @type tokens have no spaces, so
719 // collapse them ("Local Business" -> "LocalBusiness") to stay valid.
720 // A truthy value can never be the string '0', which is itself falsy, so
721 // the truthiness check alone already covers the "0" case.
722 $business_type = $options['local_seo_business_type'] ?? '';
723 $schema_type = $business_type
724 ? str_replace(' ', '', $business_type)
725 : 'Organization';
726 $entity['@type'] = $schema_type;
727
728 if (!empty($options['local_seo_logo'])) {
729 $logo_url = $this->resolve_local_seo_logo_url($options['local_seo_logo'], 'full');
730 if ($logo_url) {
731 $entity['logo'] = $logo_url;
732 }
733 }
734
735 if (!empty($options['local_seo_price_range'])) {
736 $entity['priceRange'] = $options['local_seo_price_range'];
737 }
738
739 $this->local_business_add_geo($entity, $options);
740 $this->local_business_add_address($entity, $options);
741 $this->local_business_add_contact_points($entity, $options);
742 $this->local_business_add_hours($entity, $options);
743
744 return $entity;
745 }
746
747 /**
748 * Populate a Person entity.
749 *
750 * @param array $entity Base JSON-LD entity.
751 * @param array $options Saved localseo options.
752 * @return array
753 */
754 private function local_business_person($entity, $options)
755 {
756 $name = (string) ($options['local_seo_name'] ?? '');
757 $entity['@type'] = 'Person';
758 $entity['name'] = $name !== '' ? $name : (string) get_bloginfo('name');
759
760 if (!empty($options['local_seo_phone'])) {
761 $entity['telephone'] = $options['local_seo_phone'];
762 }
763
764 if (!empty($options['local_seo_logo'])) {
765 $logo_url = $this->resolve_local_seo_logo_url($options['local_seo_logo'], 'full');
766 if ($logo_url) {
767 $entity['image'] = $logo_url;
768 }
769 }
770
771 $this->local_business_add_geo($entity, $options);
772 $this->local_business_add_address($entity, $options);
773
774 return $entity;
775 }
776
777 /**
778 * Resolve the stored Local Business logo to a publishable image URL.
779 *
780 * The setting holds either a media-library attachment ID or a raw URL.
781 * Rows saved by the legacy sanitizer hold the corrupted form of an
782 * ID ("http://45589"), which is neither numeric nor a resolvable URL, and
783 * would otherwise be published verbatim as broken structured data. Repair
784 * that shape first, then resolve the ID or trust the URL as stored.
785 *
786 * @param mixed $logo Stored logo value.
787 * @param string $size Registered image size to request for an attachment.
788 * @return string|false Resolved image URL, or false when there is none.
789 */
790 private function resolve_local_seo_logo_url($logo, $size = 'full')
791 {
792 $logo = metasync_repair_scheme_prefixed_media_id($logo);
793
794 if (is_numeric($logo)) {
795 $url = wp_get_attachment_image_url($logo, $size);
796 return $url ? $url : false;
797 }
798
799 return $logo ? $logo : false;
800 }
801
802 /**
803 * Add a PostalAddress from the saved address sub-array.
804 */
805 private function local_business_add_address(&$entity, $options)
806 {
807 $address = $options['address'] ?? array();
808 if (empty($address) || !is_array($address)) {
809 return;
810 }
811
812 $street = $address['street'] ?? '';
813 $locality = $address['locality'] ?? '';
814 $region = $address['region'] ?? '';
815 $postal = $address['postalcode'] ?? '';
816 $country = $address['country'] ?? '';
817
818 // Skip entirely if every address field is empty.
819 if ($street === '' && $locality === '' && $region === '' && $postal === '' && $country === '') {
820 return;
821 }
822
823 $entity['address'] = array(
824 '@type' => 'PostalAddress',
825 'streetAddress' => $street,
826 'addressLocality' => $locality,
827 'addressRegion' => $region,
828 'postalCode' => $postal,
829 'addressCountry' => $country,
830 );
831 }
832
833 /**
834 * Add GeoCoordinates + hasMap from "lat,lng" saved coordinates.
835 */
836 private function local_business_add_geo(&$entity, $options)
837 {
838 $coords = $options['local_seo_geo_coordinates'] ?? '';
839 if (empty($coords)) {
840 return;
841 }
842
843 // explode() always yields at least one element, so only the second index
844 // needs an existence check (a value with no comma has no longitude).
845 $geo = array_map('trim', explode(',', $coords));
846 if (!isset($geo[1]) || $geo[0] === '' || $geo[1] === '') {
847 return;
848 }
849
850 $entity['geo'] = array(
851 '@type' => 'GeoCoordinates',
852 'latitude' => $geo[0],
853 'longitude' => $geo[1],
854 );
855 $entity['hasMap'] = 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode($geo[0] . ',' . $geo[1]);
856 }
857
858 /**
859 * Add ContactPoint entries from the parallel phonetype[] / phonenumber[] arrays.
860 */
861 private function local_business_add_contact_points(&$entity, $options)
862 {
863 $types = $options['phonetype'] ?? '';
864 $numbers = $options['phonenumber'] ?? '';
865 if (!is_array($types) || !is_array($numbers) || empty($types) || empty($numbers)) {
866 return;
867 }
868
869 $contacts = array();
870 foreach ($types as $i => $contact_type) {
871 $number = $numbers[$i] ?? '';
872 if ($contact_type === '' || $number === '') {
873 continue;
874 }
875 $contacts[] = array(
876 '@type' => 'ContactPoint',
877 'telephone' => $number,
878 'contactType' => $contact_type,
879 );
880 }
881
882 if (!empty($contacts)) {
883 $entity['contactPoint'] = $contacts;
884 }
885 }
886
887 /**
888 * Add openingHours from the parallel days[] / times[] arrays.
889 */
890 private function local_business_add_hours(&$entity, $options)
891 {
892 $days = $options['days'] ?? '';
893 $times = $options['times'] ?? '';
894 if (!is_array($days) || !is_array($times) || empty($days) || empty($times)) {
895 return;
896 }
897
898 $hours = array();
899 foreach ($days as $i => $day) {
900 $time = $times[$i] ?? '';
901 if ($day === '' || $time === '') {
902 continue;
903 }
904 $hours[] = $day . ' ' . $time;
905 }
906
907 if (!empty($hours)) {
908 $entity['openingHours'] = $hours;
909 }
910 }
911
912 /*
913 * This will hide the title on single posts and pages
914 * if they were created with the "metasync" system.
915 * Passes default values for $title and $id to avoid errors.
916 */
917 public function hide_title_on_otto_pages($title = '', $id = null){
918
919 # Return title immediately if $id or $title is not provided
920 if (empty($id) || empty($title)) {
921 return $title;
922 }
923
924 # Check if it's a single post or page
925 if ((is_single() || is_page()) && in_the_loop() && is_main_query()) {
926
927 # Check if the post was created with the "metasync" system
928 $metasync_post = get_post_meta($id, 'metasync_post', true);
929 if ($metasync_post === 'yes') {
930 return '';
931 }
932 }
933 return $title;
934 }
935
936 /**
937 * Drop serving/appearance directives when the page is set to noindex.
938 *
939 * max-snippet, max-image-preview, max-video-preview, nosnippet, noarchive
940 * and noimageindex only affect how an indexed page is displayed, so they
941 * are meaningless once the page is excluded from the index. When noindex is
942 * present we keep only the indexing/crawling directives (noindex, nofollow)
943 * so the emitted tag stays clean, e.g. "noindex, nofollow" rather than
944 * "noindex, nofollow, noarchive, nosnippet, max-snippet:100, ...".
945 *
946 * @param string[] $directives
947 * @return string[]
948 */
949 private static function strip_serving_directives_if_noindex($directives) {
950 $has_noindex = false;
951 foreach ($directives as $d) {
952 if (strcasecmp((string) $d, 'noindex') === 0) {
953 $has_noindex = true;
954 break;
955 }
956 }
957 if (!$has_noindex) {
958 return $directives;
959 }
960 return array_values(array_filter($directives, function ($d) {
961 $d = strtolower((string) $d);
962 return $d === 'noindex' || $d === 'nofollow';
963 }));
964 }
965
966 /**
967 * Resolve the robots meta value, honouring the two robots feature switches.
968 *
969 * Common and advanced directives share one `<meta name="robots">` tag, and
970 * they can arrive together from any single storage format — a REST
971 * `meta_robots` string may hold "noindex, max-snippet:50". So rather than
972 * gate each storage branch, the full value is resolved first and then the
973 * directives belonging to a disabled half are dropped. That way every
974 * storage path is covered, including the ones that return early.
975 *
976 * @param array $all_meta Result of get_post_meta($id) (all meta as arrays).
977 * @return string Robots directive string, or empty.
978 */
979 private function resolve_robots_value($all_meta) {
980 // Neither half is enabled: contribute nothing at all, so core's own
981 // robots tag survives untouched.
982 if (Metasync_Feature_Flags::robots_fully_disabled()) {
983 return '';
984 }
985
986 return $this->filter_robots_by_enabled_features(
987 self::resolve_robots_value_raw($all_meta),
988 $all_meta
989 );
990 }
991
992 /**
993 * True when MetaSync's own robots resolution for a post includes noindex.
994 *
995 * Shared with the language-alternates emitter: Google discards a hreflang
996 * cluster whose member page is noindex, so annotating such a page only
997 * invites "alternate page with improper canonical/noindex" noise. Only
998 * MetaSync's own directives count — when the robots features are switched
999 * off, or the noindex comes from another plugin, the cluster is not
1000 * MetaSync's to withhold.
1001 *
1002 * @param int $post_id Post ID.
1003 * @return bool
1004 */
1005 public function is_noindex($post_id) {
1006 $value = $this->resolve_robots_value(get_post_meta($post_id));
1007
1008 foreach (explode(',', (string) $value) as $directive) {
1009 if (strcasecmp(trim($directive), 'noindex') === 0) {
1010 return true;
1011 }
1012 }
1013
1014 return false;
1015 }
1016
1017 /**
1018 * Drops directives owned by a disabled robots feature.
1019 *
1020 * The three max-* directives are the "advanced" half; everything else
1021 * (noindex, nofollow, noarchive, nosnippet, noimageindex) is "common".
1022 *
1023 * The raw resolver already dropped serving directives when the post carries
1024 * noindex. If the common half is then switched off that noindex disappears
1025 * too, so the strip no longer has anything to justify it — the advanced
1026 * directives it removed belong to a half the user left enabled. They are
1027 * therefore re-derived from the raw meta once the surviving directives are
1028 * known.
1029 *
1030 * @param string $value Fully resolved robots string.
1031 * @param array $all_meta Raw meta, used to recover stripped directives.
1032 * @return string Filtered robots string, or empty when nothing survives.
1033 */
1034 private function filter_robots_by_enabled_features($value, $all_meta = []) {
1035 $value = (string) $value;
1036 if ($value === '') {
1037 return '';
1038 }
1039
1040 $common_enabled = Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::COMMON_ROBOTS);
1041 $advanced_enabled = Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::ADVANCE_ROBOTS);
1042 if ($common_enabled && $advanced_enabled) {
1043 return $value;
1044 }
1045
1046 $kept = [];
1047 foreach (explode(',', $value) as $directive) {
1048 $directive = trim($directive);
1049 if ($directive === '') {
1050 continue;
1051 }
1052 $is_advanced = stripos($directive, 'max-snippet') === 0
1053 || stripos($directive, 'max-image-preview') === 0
1054 || stripos($directive, 'max-video-preview') === 0;
1055
1056 if ($is_advanced ? $advanced_enabled : $common_enabled) {
1057 $kept[] = $directive;
1058 }
1059 }
1060
1061 // Advanced still on, but the noindex that suppressed its serving
1062 // directives has just been filtered out with the common half. Re-resolve
1063 // them from the raw meta, since nothing on the page justifies the strip
1064 // any more. Reaching here with an empty $kept means the common half is
1065 // off (both-enabled returned early above), so this is exactly the
1066 // stripped case and not a genuinely empty robots value.
1067 if ($advanced_enabled && empty($kept) && !empty($all_meta)) {
1068 foreach (explode(',', $this->resolve_advanced_directives($all_meta)) as $directive) {
1069 $directive = trim($directive);
1070 if ($directive !== '') {
1071 $kept[] = $directive;
1072 }
1073 }
1074 }
1075
1076 return !empty($kept) ? implode(', ', array_unique($kept)) : '';
1077 }
1078
1079 /**
1080 * Resolves only the advanced (max-*) directives, ignoring noindex.
1081 *
1082 * Mirrors the advanced branches of resolve_robots_value_raw() without its
1083 * serving-directive strip, so a post whose noindex belongs to a disabled
1084 * feature keeps the max-* directives the user still has switched on.
1085 *
1086 * @param array $all_meta Result of get_post_meta($id) (all meta as arrays).
1087 * @return string Comma-separated advanced directives, or empty.
1088 */
1089 private function resolve_advanced_directives($all_meta) {
1090 $directives = [];
1091
1092 $advanced_raw = $all_meta['_metasync_robots_advanced'][0] ?? '';
1093 if (!empty($advanced_raw)) {
1094 $advanced = json_decode($advanced_raw, true);
1095 if (is_array($advanced) && !empty($advanced)) {
1096 if (isset($advanced['max_snippet'])) {
1097 $directives[] = 'max-snippet:' . (int) $advanced['max_snippet'];
1098 }
1099 if (isset($advanced['max_image_preview'])) {
1100 $directives[] = 'max-image-preview:' . esc_attr($advanced['max_image_preview']);
1101 }
1102 if (isset($advanced['max_video_preview'])) {
1103 $directives[] = 'max-video-preview:' . (int) $advanced['max_video_preview'];
1104 }
1105 if (!empty($directives)) {
1106 return implode(', ', $directives);
1107 }
1108 }
1109 }
1110
1111 $adv_raw = $all_meta['metasync_advance_robots'][0] ?? '';
1112 if (!empty($adv_raw)) {
1113 $adv = maybe_unserialize($adv_raw);
1114 if (is_array($adv)) {
1115 if (!empty($adv['max-snippet']['enable'])) {
1116 $length = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
1117 $directives[] = 'max-snippet:' . $length;
1118 }
1119 if (!empty($adv['max-video-preview']['enable'])) {
1120 $length = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
1121 $directives[] = 'max-video-preview:' . $length;
1122 }
1123 if (!empty($adv['max-image-preview']['enable'])) {
1124 $length = isset($adv['max-image-preview']['length']) ? sanitize_text_field($adv['max-image-preview']['length']) : 'large';
1125 $directives[] = 'max-image-preview:' . $length;
1126 }
1127 }
1128 }
1129
1130 return !empty($directives) ? implode(', ', $directives) : '';
1131 }
1132
1133 /**
1134 * Resolve the robots meta value from all storage formats.
1135 *
1136 * Priority order:
1137 * 1. _metasync_robots_advanced (JSON)
1138 * 2. meta_robots (string, e.g. "noindex, nofollow") — set via REST API
1139 * 3. metasync_common_robots (array) + metasync_advance_robots (array) — admin checkboxes
1140 * 4. Global defaults from metasync_options['advance_robots_meta']
1141 *
1142 * The noindex directive is sourced from _metasync_robots_index (separate key)
1143 * and merged into the final string regardless of which storage format is used.
1144 *
1145 * @param array $all_meta Result of get_post_meta($id) (all meta as arrays).
1146 * @return string Robots directive string (e.g. "nofollow, noarchive, max-snippet:-1") or empty.
1147 */
1148 public static function resolve_robots_value_raw($all_meta) {
1149 $directives = [];
1150
1151 // Merge noindex from _metasync_robots_index (separate key, not part of advanced JSON)
1152 $robots_index = $all_meta['_metasync_robots_index'][0] ?? '';
1153 if ($robots_index === 'noindex') {
1154 $directives[] = 'noindex';
1155 }
1156
1157 // Also honor noindex from the classic Common Robots meta box, which stores
1158 // it in metasync_common_robots. Without seeding it here, a noindex set via
1159 // that meta box is lost whenever the advanced-JSON path below short-circuits.
1160 if (!in_array('noindex', $directives, true)) {
1161 $common_index_raw = $all_meta['metasync_common_robots'][0] ?? '';
1162 if (!empty($common_index_raw)) {
1163 $common_index = maybe_unserialize($common_index_raw);
1164 if (is_array($common_index) && !empty($common_index['noindex'])) {
1165 $directives[] = 'noindex';
1166 }
1167 }
1168 }
1169
1170 // 1. Check _metasync_robots_advanced (JSON)
1171 $advanced_raw = $all_meta['_metasync_robots_advanced'][0] ?? '';
1172 if (!empty($advanced_raw)) {
1173 $advanced = json_decode($advanced_raw, true);
1174 if (is_array($advanced) && !empty($advanced)) {
1175 // Boolean directives
1176 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1177 if (!empty($advanced[$dir])) {
1178 $directives[] = $dir;
1179 }
1180 }
1181 // max-* directives
1182 if (isset($advanced['max_snippet'])) {
1183 $directives[] = 'max-snippet:' . (int) $advanced['max_snippet'];
1184 }
1185 if (isset($advanced['max_image_preview'])) {
1186 $directives[] = 'max-image-preview:' . esc_attr($advanced['max_image_preview']);
1187 }
1188 if (isset($advanced['max_video_preview'])) {
1189 $directives[] = 'max-video-preview:' . (int) $advanced['max_video_preview'];
1190 }
1191 $directives = self::strip_serving_directives_if_noindex($directives);
1192 return !empty($directives) ? implode(', ', $directives) : '';
1193 }
1194 }
1195
1196 // 2. Check meta_robots (REST API / direct string)
1197 $meta_robots = $all_meta['meta_robots'][0] ?? '';
1198 if (!empty($meta_robots)) {
1199 // Prepend noindex from _metasync_robots_index if not already in the string
1200 if (!empty($directives) && stripos($meta_robots, 'noindex') === false) {
1201 return implode(', ', $directives) . ', ' . $meta_robots;
1202 }
1203 return $meta_robots;
1204 }
1205
1206 // 3. Check metasync_common_robots (admin checkbox array)
1207 $raw = $all_meta['metasync_common_robots'][0] ?? '';
1208 if (!empty($raw)) {
1209 $common = maybe_unserialize($raw);
1210 if (is_array($common) && !empty($common)) {
1211 $common_directives = array_values(array_filter($common, function ($v) {
1212 return !empty($v);
1213 }));
1214 // 'index' is the implicit default and must never be emitted as a
1215 // directive. Dropping it here also de-conflicts legacy posts saved
1216 // with both Index and No Index checked, so noindex always wins.
1217 $common_directives = array_values(array_filter($common_directives, function ($v) {
1218 return strcasecmp((string) $v, 'index') !== 0;
1219 }));
1220 $directives = array_merge($directives, $common_directives);
1221 }
1222 }
1223
1224 // Also check metasync_advance_robots (admin per-post advanced checkboxes)
1225 // Structure: ['max-snippet' => ['enable' => '1', 'length' => 100], ...]
1226 $adv_raw = $all_meta['metasync_advance_robots'][0] ?? '';
1227 if (!empty($adv_raw)) {
1228 $adv = maybe_unserialize($adv_raw);
1229 if (is_array($adv)) {
1230 if (!empty($adv['max-snippet']['enable'])) {
1231 $length = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
1232 $directives[] = 'max-snippet:' . $length;
1233 }
1234 if (!empty($adv['max-video-preview']['enable'])) {
1235 $length = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
1236 $directives[] = 'max-video-preview:' . $length;
1237 }
1238 if (!empty($adv['max-image-preview']['enable'])) {
1239 $length = isset($adv['max-image-preview']['length']) ? sanitize_text_field($adv['max-image-preview']['length']) : 'large';
1240 $directives[] = 'max-image-preview:' . $length;
1241 }
1242 }
1243 }
1244
1245 if (!empty($directives)) {
1246 $directives = self::strip_serving_directives_if_noindex($directives);
1247 return implode(', ', array_unique($directives));
1248 }
1249
1250 // 4. Global defaults from metasync_options['advance_robots_meta']
1251 $global_options = Metasync::get_option('advance_robots_meta');
1252 if (empty($global_options)) {
1253 $global_options = Metasync::get_option('advance_robots_mata');
1254 }
1255 if (is_array($global_options)) {
1256 if (!empty($global_options['max-snippet']['enable'])) {
1257 $length = isset($global_options['max-snippet']['length']) ? (int) $global_options['max-snippet']['length'] : -1;
1258 $directives[] = 'max-snippet:' . $length;
1259 }
1260 if (!empty($global_options['max-video-preview']['enable'])) {
1261 $length = isset($global_options['max-video-preview']['length']) ? (int) $global_options['max-video-preview']['length'] : -1;
1262 $directives[] = 'max-video-preview:' . $length;
1263 }
1264 if (!empty($global_options['max-image-preview']['enable'])) {
1265 $length = isset($global_options['max-image-preview']['length']) ? sanitize_text_field($global_options['max-image-preview']['length']) : 'large';
1266 $directives[] = 'max-image-preview:' . $length;
1267 }
1268 }
1269
1270 // AC-1: max-image-preview:large outputs by default even with no configuration.
1271 // If no per-post or global setting included max-image-preview, apply the default.
1272 $has_max_image = false;
1273 foreach ($directives as $d) {
1274 if (strpos($d, 'max-image-preview') !== false) {
1275 $has_max_image = true;
1276 break;
1277 }
1278 }
1279 if (!$has_max_image) {
1280 $directives[] = 'max-image-preview:large';
1281 }
1282
1283 return !empty($directives) ? implode(', ', array_unique($directives)) : '';
1284 }
1285
1286 /**
1287 * Get the correct canonical URL for archive pages.
1288 *
1289 * Handles: Posts page (is_home), category, tag, custom taxonomy,
1290 * author, year/month/day date archives.
1291 *
1292 * @return string Archive URL or empty string if not determinable.
1293 */
1294 private function get_archive_canonical_url() {
1295 if (is_home()) {
1296 $blog_page_id = (int) get_option('page_for_posts');
1297 if ($blog_page_id > 0) {
1298 // Honour a manual MetaSync canonical override on the Posts page
1299 // (validated — corrupted legacy rows are ignored,).
1300 $override = Metasync_Canonical_Sanitizer::sanitize(
1301 get_post_meta($blog_page_id, '_metasync_canonical_url', true)
1302 );
1303 if ($override === '') {
1304 $override = Metasync_Canonical_Sanitizer::sanitize(
1305 get_post_meta($blog_page_id, 'meta_canonical', true)
1306 );
1307 }
1308 if ($override !== '') {
1309 return esc_url($override);
1310 }
1311 return $this->maybe_build_headless_url(get_permalink($blog_page_id), 'page');
1312 }
1313 if ($this->headless_is_active()) {
1314 return $this->get_headless_home_url();
1315 }
1316 return home_url('/');
1317 }
1318
1319 if (is_category() || is_tag() || is_tax()) {
1320 $term = get_queried_object();
1321 if ($term instanceof WP_Term) {
1322 $override = Metasync_Canonical_Sanitizer::sanitize(
1323 get_term_meta($term->term_id, '_metasync_canonical_url', true)
1324 );
1325 if ($override === '') {
1326 $override = Metasync_Canonical_Sanitizer::sanitize(
1327 get_term_meta($term->term_id, 'meta_canonical', true)
1328 );
1329 }
1330 if ($override !== '') {
1331 return esc_url($override);
1332 }
1333
1334 $link = get_term_link($term);
1335 return is_wp_error($link) ? '' : $this->maybe_build_headless_url($link);
1336 }
1337 return '';
1338 }
1339
1340 if (is_author()) {
1341 return $this->maybe_build_headless_url(get_author_posts_url(get_queried_object_id()));
1342 }
1343
1344 if (is_day()) {
1345 return $this->maybe_build_headless_url(get_day_link(get_query_var('year'), get_query_var('monthnum'), get_query_var('day')));
1346 }
1347
1348 if (is_month()) {
1349 return $this->maybe_build_headless_url(get_month_link(get_query_var('year'), get_query_var('monthnum')));
1350 }
1351
1352 if (is_year()) {
1353 return $this->maybe_build_headless_url(get_year_link(get_query_var('year')));
1354 }
1355
1356 return '';
1357 }
1358
1359 /**
1360 * Build the public homepage URL for the active headless path.
1361 *
1362 * Kept as a small seam so the archive caller's fail-closed behaviour can be
1363 * verified independently of the builder's current homepage invariants.
1364 *
1365 * @return string Public homepage URL, or an empty string on builder failure.
1366 */
1367 protected function get_headless_home_url() {
1368 return Metasync_Headless_Url_Builder::home();
1369 }
1370
1371 /**
1372 * Get the canonical URL for a post
1373 *
1374 * Resolves through Metasync_Seo_Precedence::FIELD_CANONICAL — the persisted
1375 * or imported value (_metasync_canonical_url), then the Canonical meta box
1376 * value the customer typed, then OTTO's volatile staging key
1377 * (_metasync_otto_canonical, written on every active sync) — so a
1378 * deliberate OTTO canonical renders even on the paths OTTO itself bails out
1379 * of, instead of silently degrading to the permalink. The staging tier sits
1380 * below both manual keys because OTTO's SSR injection stands down when
1381 * either of them is set; any other order would render a different canonical
1382 * than SSR does. The chain also honours the per-post "Disable OTTO" toggle:
1383 * with OTTO stood down the staging tier is dropped.
1384 *
1385 * Every value is validated so legacy rows corrupted to the literal "Array"
1386 * (or stored as arrays) are never emitted. The permalink fallback below the
1387 * chain preserves the pre-existing behaviour when nothing is stored.
1388 */
1389 private function get_canonical_url($post) {
1390 # Resolve the precedence chain first. include_otto defaults to the
1391 # per-post "Disable OTTO" toggle inside resolve(), so a stood-down
1392 # suggestion is never served. A partial install without the resolver
1393 # degrades to the direct reads of the two manual keys it replaced.
1394 $metasync_canonical = '';
1395 if (class_exists('Metasync_Seo_Precedence')) {
1396 $metasync_canonical = Metasync_Canonical_Sanitizer::sanitize(
1397 Metasync_Seo_Precedence::value(
1398 $post->ID,
1399 Metasync_Seo_Precedence::FIELD_CANONICAL
1400 )
1401 );
1402 } else {
1403 $metasync_canonical = Metasync_Canonical_Sanitizer::sanitize(
1404 get_post_meta($post->ID, '_metasync_canonical_url', true)
1405 );
1406 if ($metasync_canonical === '') {
1407 $metasync_canonical = Metasync_Canonical_Sanitizer::sanitize(
1408 get_post_meta($post->ID, 'meta_canonical', true)
1409 );
1410 }
1411 }
1412
1413 if ($metasync_canonical !== '') {
1414 return esc_url($metasync_canonical);
1415 }
1416
1417 # Try to get the permalink using WordPress function
1418 $permalink = get_permalink($post->ID);
1419
1420 # If permalink is not available or is the default query URL, try alternative methods
1421 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1422 # Force WordPress to generate the proper permalink by temporarily setting post status
1423 $original_status = $post->post_status;
1424 if ($post->post_status === 'auto-draft') {
1425 $post->post_status = 'publish';
1426 }
1427
1428 # Try get_permalink again with the updated status
1429 $permalink = get_permalink($post->ID);
1430
1431 # Restore original status
1432 $post->post_status = $original_status;
1433 }
1434
1435 # If still not working, use WordPress core functions to build proper permalink
1436 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1437 # Use WordPress core function that respects permalink structure
1438 # This properly handles custom structures, hierarchies, and post types
1439 # Load admin function if not already available, Without this it is causing error on post the preview page
1440 if (!function_exists('get_sample_permalink')) {
1441 require_once ABSPATH . 'wp-admin/includes/post.php';
1442 }
1443 $permalink = get_sample_permalink($post->ID);
1444
1445 if (is_array($permalink)) {
1446 # get_sample_permalink returns array with template and slug
1447 # Replace %postname% or %pagename% with actual slug
1448 $permalink = str_replace(
1449 array('%pagename%', '%postname%'),
1450 $post->post_name,
1451 $permalink[0]
1452 );
1453 }
1454
1455 # Final fallback: if still problematic, construct URL respecting post type structure
1456 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1457 if (!empty($post->post_name)) {
1458 # For pages, check if there's a parent hierarchy
1459 if ($post->post_type === 'page' && $post->post_parent) {
1460 # Get parent page path for proper hierarchy
1461 $parent = get_post($post->post_parent);
1462 $parent_path = '';
1463
1464 # Build full path including all parent pages
1465 while ($parent) {
1466 $parent_path = $parent->post_name . '/' . $parent_path;
1467 $parent = $parent->post_parent ? get_post($parent->post_parent) : null;
1468 }
1469
1470 $permalink = home_url('/' . $parent_path . $post->post_name . '/');
1471 } else {
1472 # For posts and pages without parents, use post type archive base
1473 $post_type_obj = get_post_type_object($post->post_type);
1474 $slug = $post_type_obj->rewrite['slug'] ?? '';
1475
1476 if ($slug && $post->post_type !== 'page') {
1477 $permalink = home_url('/' . $slug . '/' . $post->post_name . '/');
1478 } else {
1479 $permalink = home_url('/' . $post->post_name . '/');
1480 }
1481 }
1482 } else {
1483 # Fallback to post ID format if no slug available
1484 $permalink = home_url('/?p=' . $post->ID);
1485 }
1486 }
1487 }
1488
1489 return $this->maybe_build_headless_url($permalink, $post->post_type);
1490 }
1491
1492 /**
1493 * Rehost a derived WordPress URL when headless mode is active.
1494 *
1495 * Explicit canonical overrides bypass this helper, so they remain authoritative.
1496 * While active, a builder failure returns an empty string so the WordPress URL
1497 * cannot leak into public metadata.
1498 *
1499 * @param mixed $url Derived URL from WordPress.
1500 * @param string $post_type Post type for an optional frontend path prefix.
1501 * @return mixed Original URL when inactive; public URL or empty string when active.
1502 */
1503 /**
1504 * Is headless mode active?
1505 *
1506 * Delegates to metasync_headless_is_active() (includes/metasync-helpers.php),
1507 * which treats a class that the Composer classmap cannot resolve mid-upgrade
1508 * as "not headless" instead of fataling. function_exists() because this class
1509 * is also loaded directly — by tests and by early bootstrap paths — where the
1510 * helpers file may not have been required yet.
1511 *
1512 * @return bool
1513 */
1514 private function headless_is_active() {
1515 if (function_exists('metasync_headless_is_active')) {
1516 return metasync_headless_is_active();
1517 }
1518
1519 return class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active();
1520 }
1521
1522 private function maybe_build_headless_url($url, $post_type = '') {
1523 if (!$this->headless_is_active()) {
1524 return $url;
1525 }
1526
1527 return Metasync_Headless_Url_Builder::from_wp_url($url, $post_type);
1528 }
1529
1530 /**
1531 * Set up indexation controls for archive pages
1532 *
1533 * Called via template_redirect hook to set up early before any output.
1534 * This ensures we can capture and clean robots tags from other plugins.
1535 *
1536 * Logic:
1537 * 1. If user wants to add noindex - always do it (override other plugins)
1538 * 2. If user wants to allow indexing - only remove other plugins' tags if override setting is enabled
1539 *
1540 * @since 1.0.0
1541 */
1542 public function inject_archive_seo_controls() {
1543 // Check if we're on a managed archive type
1544 if (!$this->is_managed_archive()) {
1545 return;
1546 }
1547
1548 // Get settings
1549 $seo_controls = Metasync::get_option('seo_controls', array());
1550 $should_noindex = $this->should_noindex_archive();
1551 $override_enabled = ($seo_controls['override_robots_tags'] ?? 'false') === 'true' || ($seo_controls['override_robots_tags'] ?? false) === true;
1552
1553 // Run buffer if either:
1554 // 1. User wants to add noindex (always override other plugins), OR
1555 // 2. User wants to allow indexing AND override setting is enabled
1556 if ($should_noindex || $override_enabled) {
1557 // Override WordPress core robots tag
1558 add_filter('wp_robots', array($this, 'override_wp_robots'), 999);
1559
1560 // Start buffering to remove other plugins' robots tags
1561 add_action('wp_head', array($this, 'start_robots_buffer'), 0);
1562 add_action('wp_head', array($this, 'end_robots_buffer'), PHP_INT_MAX);
1563 }
1564 // Otherwise: User wants to allow indexing AND override is disabled - don't interfere
1565 }
1566
1567 /**
1568 * Check if current page is a managed archive type
1569 *
1570 * Returns true if we're on any archive type that has indexation controls,
1571 * regardless of whether noindex is enabled or not.
1572 *
1573 * @since 1.0.0
1574 * @return bool True if on a managed archive type, false otherwise
1575 */
1576 private function is_managed_archive() {
1577 return is_date() || is_tag() || is_author() || is_category() || is_tax('post_format');
1578 }
1579
1580 /**
1581 * Check if current archive is empty (has no posts)
1582 *
1583 * Applies to category, tag, author, and post format archives.
1584 * Date archives are excluded as they're typically either indexed
1585 * or not entirely (less meaningful to check for empty state).
1586 *
1587 * @since 1.0.0
1588 * @return bool True if archive is empty (0 posts)
1589 */
1590 private function is_empty_archive() {
1591 global $wp_query;
1592
1593 // Check taxonomy and author archives (excluding date archives)
1594 if (!is_category() && !is_tag() && !is_author() && !is_tax('post_format')) {
1595 return false;
1596 }
1597
1598 // Check if the archive has no posts
1599 return $wp_query->post_count === 0;
1600 }
1601
1602 /**
1603 * Check if current archive should be noindexed
1604 *
1605 * @since 1.0.0
1606 * @return bool True if should add noindex, false otherwise
1607 */
1608 private function should_noindex_archive() {
1609 // Get indexation control settings
1610 $seo_controls = Metasync::get_option('seo_controls', array());
1611
1612 // Check empty archives setting first (applies to categories/tags only)
1613 $noindex_empty_archives = $seo_controls['noindex_empty_archives'] ?? false;
1614 if (($noindex_empty_archives === 'true' || $noindex_empty_archives === true) && $this->is_empty_archive()) {
1615 return true;
1616 }
1617
1618 // Date Archives
1619 if (is_date()) {
1620 $index_date_archives = $seo_controls['index_date_archives'] ?? false;
1621 // Handle both string and boolean values for compatibility
1622 if ($index_date_archives === 'true' || $index_date_archives === true) {
1623 return true;
1624 }
1625 }
1626
1627 // Tag Archives
1628 elseif (is_tag()) {
1629 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1630 // Handle both string and boolean values for compatibility
1631 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1632 return true;
1633 }
1634 }
1635
1636 // Author Archives
1637 elseif (is_author()) {
1638 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1639 // Handle both string and boolean values for compatibility
1640 if ($index_author_archives === 'true' || $index_author_archives === true) {
1641 return true;
1642 }
1643 }
1644
1645 // Category Archives
1646 elseif (is_category()) {
1647 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1648 // Handle both string and boolean values for compatibility
1649 if ($index_category_archives === 'true' || $index_category_archives === true) {
1650 return true;
1651 }
1652 }
1653
1654 // Format Archives (post format taxonomy)
1655 elseif (is_tax('post_format')) {
1656 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1657 if ($index_format_archives === 'true' || $index_format_archives === true) {
1658 return true;
1659 }
1660 }
1661
1662 return false;
1663 }
1664
1665 /**
1666 * Override WordPress core robots directives
1667 *
1668 * Handles both cases:
1669 * 1. When noindex is enabled: Add noindex directive
1670 * 2. When indexing is allowed: Return empty array (removes WP core robots tag)
1671 *
1672 * @since 1.0.0
1673 * @param array $robots Associative array of robots directives
1674 * @return array Modified robots directives
1675 */
1676 public function override_wp_robots($robots) {
1677 if ($this->should_noindex_archive()) {
1678 // Case 1: Add noindex directive
1679 return array(
1680 'noindex' => true,
1681 'follow' => true,
1682 'max-image-preview' => 'large', // Maintain good image preview for social sharing
1683 );
1684 } else {
1685 // Case 2: Allow indexing - return empty array to remove WP core robots tag
1686 // Default behavior without robots tag is to index
1687 return array();
1688 }
1689 }
1690
1691 /**
1692 * Start output buffering to capture robots meta tags from other plugins
1693 *
1694 * @since 1.0.0
1695 */
1696 public function start_robots_buffer() {
1697 ob_start();
1698 }
1699
1700 /**
1701 * End output buffering, remove existing robots tags, and optionally add ours
1702 *
1703 * This handles BOTH cases:
1704 * 1. When noindex is enabled: Remove other tags and add noindex tag
1705 * 2. When indexing is allowed: Remove other plugins' noindex tags
1706 *
1707 * This ensures our indexation settings always take precedence over other plugins.
1708 *
1709 * @since 1.0.0
1710 */
1711 public function end_robots_buffer() {
1712 // Only proceed if we're on a managed archive
1713 if (!$this->is_managed_archive()) {
1714 ob_end_flush();
1715 return;
1716 }
1717
1718 // Get buffered content
1719 $content = ob_get_clean();
1720
1721 // Remove all existing robots meta tags (handles various formats)
1722 // This is done for BOTH noindex and index cases to ensure clean slate
1723
1724 // Pattern 1: name="robots" content="..."
1725 $content = preg_replace(
1726 '/<meta\s+name=["\']robots["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1727 '',
1728 $content
1729 );
1730
1731 // Pattern 2: content="..." name="robots"
1732 $content = preg_replace(
1733 '/<meta\s+content=["\'][^"\']*["\']\s+name=["\']robots["\']\s*\/?>\s*/i',
1734 '',
1735 $content
1736 );
1737
1738 // Pattern 3: Single quotes or no quotes (rare but possible)
1739 $content = preg_replace(
1740 "/<meta\s+name='robots'\s+content='[^']*'\s*\/?>\s*/i",
1741 '',
1742 $content
1743 );
1744
1745 // Pattern 4: property="robots" (some plugins use property attribute)
1746 $content = preg_replace(
1747 '/<meta\s+property=["\']robots["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1748 '',
1749 $content
1750 );
1751
1752 // Pattern 5: Search engine specific directives (googlebot, bingbot, etc.)
1753 $content = preg_replace(
1754 '/<meta\s+name=["\'](?:googlebot|bingbot|googlebot-news|slurp)["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1755 '',
1756 $content
1757 );
1758
1759 // Output cleaned content
1760 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- the page's own HTML content passthrough after the robots-meta strip; escaping would corrupt the document
1761 echo $content;
1762
1763 // Add our robots tag or comment based on settings
1764 if ($this->should_noindex_archive()) {
1765 // Case 1: User wants to disallow indexing - add noindex tag
1766 echo '<!-- MetaSync Indexation Control: Noindex Applied (Overriding other plugins) -->' . "\n";
1767 echo '<meta name="robots" content="noindex, follow">' . "\n";
1768 } else {
1769 // Case 2: User wants to allow indexing AND override is enabled
1770 // Just remove other plugins' tags, don't add our own
1771 // Default behavior without robots tag is to allow indexing
1772 echo '<!-- MetaSync Indexation Control: Index Allowed (Override enabled - Other noindex tags removed) -->' . "\n";
1773 }
1774 }
1775
1776 /**
1777 * Filter taxonomy sitemap entries to exclude disabled archive types
1778 *
1779 * @since 1.0.0
1780 * @param array $taxonomies Array of taxonomy objects
1781 * @return array Modified array of taxonomy objects
1782 */
1783 public function filter_sitemap_taxonomies($taxonomies) {
1784 // Get indexation control settings
1785 $seo_controls = Metasync::get_option('seo_controls', array());
1786
1787 // Check if tag archives are disabled
1788 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1789 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1790 // Remove post_tag taxonomy from sitemap
1791 unset($taxonomies['post_tag']);
1792 }
1793
1794 // Check if category archives are disabled
1795 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1796 if ($index_category_archives === 'true' || $index_category_archives === true) {
1797 // Remove category taxonomy from sitemap
1798 unset($taxonomies['category']);
1799 }
1800
1801 // Check if format archives are disabled
1802 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1803 if ($index_format_archives === 'true' || $index_format_archives === true) {
1804 // Remove post_format taxonomy from sitemap
1805 unset($taxonomies['post_format']);
1806 }
1807
1808 return $taxonomies;
1809 }
1810
1811 /**
1812 * Filter user sitemap entries to exclude disabled author archives
1813 *
1814 * @since 1.0.0
1815 * @param array $entry Sitemap entry for user
1816 * @param WP_User $user User object
1817 * @return array|false Modified sitemap entry or false to exclude
1818 */
1819 public function filter_sitemap_users($entry, $user) {
1820 // Get indexation control settings
1821 $seo_controls = Metasync::get_option('seo_controls', array());
1822
1823 // Check if author archives are disabled
1824 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1825 if ($index_author_archives === 'true' || $index_author_archives === true) {
1826 // Exclude this user from sitemap
1827 return false;
1828 }
1829
1830 return $entry;
1831 }
1832
1833 /**
1834 * Filter sitemap providers to exclude disabled archive types
1835 *
1836 * @since 1.0.0
1837 * @param bool $provider Whether to add the provider
1838 * @param string $name Provider name
1839 * @return bool Whether to add the provider
1840 */
1841 public function filter_sitemap_providers($provider, $name) {
1842 // Get indexation control settings
1843 $seo_controls = Metasync::get_option('seo_controls', array());
1844
1845 // Check if users/authors sitemap provider should be disabled
1846 if ($name === 'users') {
1847 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1848 if ($index_author_archives === 'true' || $index_author_archives === true) {
1849 return false; // Exclude users sitemap provider entirely
1850 }
1851 }
1852
1853 return $provider;
1854 }
1855
1856 /**
1857 * Filter sitemap index entries to exclude disabled archive types
1858 *
1859 * @since 1.0.0
1860 * @param array $sitemap_entry Sitemap entry array
1861 * @param string $object_type Object type (posts, taxonomies, users)
1862 * @param string $subtype Subtype (post type or taxonomy name)
1863 * @param int $page Page number
1864 * @return array|false Modified sitemap entry or false to exclude
1865 */
1866 public function filter_sitemap_index_entries($sitemap_entry, $object_type, $subtype, $page) {
1867 // Get indexation control settings
1868 $seo_controls = Metasync::get_option('seo_controls', array());
1869
1870 // Handle taxonomy sitemaps
1871 if ($object_type === 'taxonomies') {
1872 // Check if tag archives are disabled
1873 if ($subtype === 'post_tag') {
1874 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1875 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1876 return false; // Exclude from sitemap index
1877 }
1878 }
1879
1880 // Check if category archives are disabled
1881 if ($subtype === 'category') {
1882 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1883 if ($index_category_archives === 'true' || $index_category_archives === true) {
1884 return false; // Exclude from sitemap index
1885 }
1886 }
1887
1888 // Check if format archives are disabled
1889 if ($subtype === 'post_format') {
1890 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1891 if ($index_format_archives === 'true' || $index_format_archives === true) {
1892 return false; // Exclude from sitemap index
1893 }
1894 }
1895 }
1896
1897 // Handle user/author sitemaps
1898 if ($object_type === 'users') {
1899 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1900 if ($index_author_archives === 'true' || $index_author_archives === true) {
1901 return false; // Exclude from sitemap index
1902 }
1903 }
1904
1905 return $sitemap_entry;
1906 }
1907 }
1908