PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.22
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.22
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.6.22, at public/class-metasync-seo-output.php

1,570 lines 53.4 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 $escapers;
27 private $replacements;
28 private $metasync_option_data;
29
30 public function __construct($plugin_name, $version)
31 {
32 $this->plugin_name = $plugin_name;
33 $this->version = $version;
34 $this->escapers = array("\\", "/", "\"");
35 $this->replacements = array("", "", "");
36 $this->common = new Metasync_Common();
37 $this->metasync_option_data = Metasync::get_option('general');
38
39 // Filter permalink to use primary category (for canonical URLs and sitemaps)
40 add_filter('post_link_category', array($this, 'filter_post_link_category_primary'), 10, 3);
41 }
42
43 /**
44 * Filter the category used in post permalinks to respect the primary category.
45 * Falls back to Yoast SEO or Rank Math primary category if MetaSync's is not set.
46 *
47 * @param WP_Term $cat The category to use in the permalink
48 * @param array $cats Array of all categories associated with the post
49 * @param WP_Post $post The post object
50 * @return WP_Term The primary category term or the original category
51 */
52 public function filter_post_link_category_primary($cat, $cats, $post)
53 {
54 // When the sitemap generator is collecting URLs on a %category% permalink
55 // site, it primes a per-chunk prefetch map of the primary-category meta
56 // keys (because update_post_meta_cache is disabled there to avoid memory
57 // exhaustion on large catalogs). Read from that map first and only fall
58 // back to get_post_meta() — which lazily fetches on a cache miss — when
59 // the map has no entry for this post. Outside sitemap generation the map
60 // is empty, so behaviour is unchanged.
61 $prefetch = (class_exists('Metasync_Sitemap_Generator') && !empty(Metasync_Sitemap_Generator::$primary_category_prefetch))
62 ? Metasync_Sitemap_Generator::$primary_category_prefetch
63 : [];
64
65 $primary_id = (int) self::primary_meta_lookup($post->ID, '_metasync_primary_category', $prefetch);
66
67 // Fallback to Yoast SEO primary category
68 if ($primary_id === 0 && defined('WPSEO_VERSION')) {
69 $primary_id = (int) self::primary_meta_lookup($post->ID, '_yoast_wpseo_primary_category', $prefetch);
70 }
71
72 // Fallback to Rank Math primary category
73 if ($primary_id === 0 && defined('RANK_MATH_VERSION')) {
74 $primary_id = (int) self::primary_meta_lookup($post->ID, 'rank_math_primary_category', $prefetch);
75 }
76
77 if ($primary_id === 0 || empty($cats)) {
78 return $cat;
79 }
80
81 foreach ($cats as $category) {
82 if ((int) $category->term_id === $primary_id) {
83 return $category;
84 }
85 }
86
87 return $cat;
88 }
89
90 /**
91 * Resolve a single primary-category meta value, preferring the sitemap
92 * generator's per-chunk prefetch map (populated on %category% permalink
93 * sites where the WP_Query post-meta cache prime is disabled) and falling
94 * back to get_post_meta() when the map has no entry for the post/key.
95 *
96 * @param int $post_id Post ID.
97 * @param string $key Meta key to resolve.
98 * @param array $prefetch Map of post_id => [meta_key => value].
99 * @return string The scalar meta value (empty string when not set).
100 */
101 private static function primary_meta_lookup($post_id, $key, array $prefetch)
102 {
103 $pid = (int) $post_id;
104 if (isset($prefetch[$pid]) && is_array($prefetch[$pid]) && array_key_exists($key, $prefetch[$pid])) {
105 return (string) $prefetch[$pid][$key];
106 }
107 return (string) get_post_meta($post_id, $key, true);
108 }
109
110 function metasync_wp_robots_meta($robots)
111 {
112 foreach ($robots as $key => $value) {
113 $robots[$key] = false;
114 }
115 return $robots;
116 }
117
118 public function print_metatag($name, $value, $valueAttrib = "content", $nameAttrib = "name", $tagName = "meta")
119 {
120 // Safety: extract string from array values (fixes legacy meta stored as
121 // arrays). Recursive — a single reset() on a nested array
122 // returns another array and printf would emit the literal "Array".
123 if (is_array($value)) {
124 $value = Metasync_Canonical_Sanitizer::extract_scalar($value);
125 }
126
127 if (empty($value))
128 return false;
129
130 printf(
131 "\t<%s %s=\"%s\" %s=\"%s\" />\n",
132 esc_attr($tagName),
133 esc_attr($nameAttrib),
134 esc_attr($name),
135 esc_attr($valueAttrib),
136 esc_attr($value)
137 );
138 }
139
140 /**
141 * Check if the current page is an AMP page
142 *
143 * @return bool True if AMP page, false otherwise
144 */
145 public function is_amp_page()
146 {
147 // Check if URL path contains /amp/
148 $current_url = $_SERVER['REQUEST_URI'] ?? '';
149 if (strpos($current_url, '/amp/') !== false) {
150 return true;
151 }
152
153 // Check if URL ends with /amp
154 if (preg_match('/\/amp\/?$/', $current_url)) {
155 return true;
156 }
157
158 // Check if amp=1 query parameter is present
159 if (isset($_GET['amp']) && $_GET['amp'] == '1') {
160 return true;
161 }
162
163 // Check for other common AMP query parameters
164 if (isset($_GET['amp']) && !empty($_GET['amp'])) {
165 return true;
166 }
167
168 return false;
169 }
170
171 /**
172 * Remove metasync_optimized attribute from head tag on AMP pages
173 * This function uses output buffering to clean the head content
174 */
175 public function cleanup_amp_head_attribute()
176 {
177 // Only run on AMP pages
178 if (!$this->is_amp_page()) {
179 return;
180 }
181
182 // Start output buffering to capture and modify the head content
183 ob_start(function($buffer) {
184 // Remove metasync_optimized attribute from head tag
185 $cleaned_buffer = preg_replace('/(<head[^>]*)\s*metasync_optimized(?:="[^"]*")?([^>]*>)/i', '$1$2', $buffer);
186
187 return $cleaned_buffer;
188 });
189 }
190
191 /**
192 * End output buffering for AMP cleanup
193 */
194 public function end_amp_head_cleanup()
195 {
196 // Only run on AMP pages
197 if (!$this->is_amp_page()) {
198 return;
199 }
200
201 // End output buffering
202 if (ob_get_level()) {
203 ob_end_flush();
204 }
205 }
206
207 public function hook_metasync_metatags()
208 {
209 $conflict_handler = Metasync_SEO_Conflict_Handler::get_instance();
210
211 // On archive pages (category, tag, author, date, blog home) get_the_ID()
212 // returns the first post in the loop — not the archive itself. Guard here so
213 // we never use that first-post ID for canonical / og:url on archives.
214 // Instead, emit the correct archive URL and return early.
215 if (!is_singular()) {
216 if (!$conflict_handler->has_active_seo_plugin()) {
217 $archive_url = $this->get_archive_canonical_url();
218 if (!empty($archive_url)) {
219 printf("\t<link rel=\"canonical\" href=\"%s\" />\n", esc_url($archive_url));
220 printf("\t<meta property=\"og:url\" content=\"%s\" />\n", esc_url($archive_url));
221 }
222 }
223 return;
224 }
225
226 // Singular post/page: suppress WP core's canonical and let MetaSync handle it.
227 if (!$conflict_handler->has_active_seo_plugin()) {
228 remove_action('wp_head', 'rel_canonical');
229 }
230
231 $get_page_meta = get_post_meta(get_the_ID());
232
233 // When a third-party SEO plugin is active, only output our description
234 // if MetaSync has an intentional value (OTTO or sidebar). Otherwise let
235 // the other plugin handle it to avoid duplicates.
236 $include_description = $conflict_handler->should_output_legacy_description();
237
238 $list_page_meta = array();
239
240 // When synced to an active SEO plugin, skip MetaSync's own
241 // robots and description output — the native plugin handles it.
242 $post_id = get_the_ID();
243 $is_synced_to_plugin = false;
244 if ($post_id && $conflict_handler->has_active_seo_plugin()) {
245 $sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
246 if (!empty($sync_ts)) {
247 $is_synced_to_plugin = true;
248 }
249 }
250
251 // Robots: resolve from both meta_robots (REST API) and metasync_common_robots (checkbox).
252 // When a third-party SEO plugin is active:
253 // - MetaSync has robots value → output ours, AIOSEO suppressed via filter
254 // - MetaSync has NO value → skip, let the other plugin handle it
255 // When no third-party plugin → always output if we have a value.
256 if (!$is_synced_to_plugin) {
257 $robots_value = $this->resolve_robots_value($get_page_meta);
258 if (!empty($robots_value)) {
259 if (!$conflict_handler->has_active_seo_plugin()) {
260 $list_page_meta['robots'] = $robots_value;
261 } else {
262 // Third-party active but MetaSync has intentional robots — output ours
263 // (the conflict handler filter suppresses the other plugin's robots tag)
264 if ($post_id && $conflict_handler->metasync_has_robots($post_id)) {
265 $list_page_meta['robots'] = $robots_value;
266 }
267 }
268 }
269 }
270
271 if ($include_description) {
272 $list_page_meta['description'] = $get_page_meta['meta_description'][0] ?? '';
273 }
274
275 // Note: enable_metadesc is always enabled by default - no check needed
276
277 $getSearchEngineOptions = Metasync::get_option('searchengines');
278 $keysSearchEngines = [
279 'bing_site_verification' => 'msvalidate.01',
280 'baidu_site_verification' => 'baidu-site-verification',
281 'alexa_site_verification' => 'alexaVerifyID',
282 'yandex_site_verification' => 'yandex-verification',
283 'google_site_verification' => 'google-site-verification',
284 'pinterest_site_verification' => 'p:domain_verify',
285 'norton_save_site_verification' => 'norton-safeweb-site-verification',
286 ];
287
288 $post = get_post(get_the_ID());
289 if (empty($post))
290 return;
291
292 # Keywords: render persisted OTTO focus keyword as <meta name="keywords">
293 $focus_keyword = get_post_meta($post->ID, '_metasync_focus_keyword', true);
294 if (!empty($focus_keyword)) {
295 $list_page_meta['keywords'] = sanitize_text_field($focus_keyword);
296 }
297
298 # Canonical: output <link rel="canonical"> only when no third-party SEO plugin
299 # handles it, to avoid duplicate canonical tags in the page source.
300 if (!$conflict_handler->has_active_seo_plugin()) {
301 $list_page_meta['canonical'] = $this->get_canonical_url($post);
302 }
303
304 # $post_text = wp_trim_words(get_the_content(), 30, '');
305
306 # Check if the post has content, then apply WordPress content filters
307 # $post_content = !empty($post->post_content) ? apply_filters('the_content', $post->post_content) : '';
308 # $post_text = wp_trim_words($post_content, 30, '');
309
310 /**
311 * Extract post content safely for meta descriptions
312 * This solution addresses three critical issues:
313 * 1. Timber compatibility - works without WordPress loop
314 * 2. Plugin conflicts - prevents shortcode execution (e.g., Hostify-booking function redeclaration)
315 * 3. Performance - lightweight content extraction for meta tags
316 */
317
318 $post_text = '';
319
320 # Get content safely without executing shortcodes
321 $content = get_the_content(null, false, $post);
322
323 # Validate content exists
324 if (!empty($content)) {
325 # Clean content for meta description - multi-layer approach for reliability
326 $content = strip_shortcodes($content); # Remove shortcode tags (e.g., [hostify-booking])
327 $content = wp_strip_all_tags($content); # Remove HTML tags
328 $content = preg_replace('/\s+/', ' ', $content); # Normalize whitespace
329 $post_text = wp_trim_words(trim($content), 30, '');
330 }
331
332 $site_info = Metasync::get_option('optimal_settings')['site_info'] ?? [];
333
334 $facebook_page_url = Metasync::get_option('social_meta')['facebook_page_url'] ?? '';
335 $facebook_authorship = Metasync::get_option('social_meta')['facebook_authorship'] ?? '';
336 $facebook_admin = Metasync::get_option('social_meta')['facebook_admin'] ?? '';
337
338 $twitter_username = Metasync::get_option('social_meta')['twitter_username'] ?? '';
339
340 $image = [];
341 $image_mime_type = '';
342
343 // Resolve the Open Graph image from attachment metadata only — no disk
344 // reads, no HTTP calls. This path is READ-ONLY: it must never mutate post
345 // meta while rendering wp_head. Media-offload plugins (WP Stateless, WP
346 // Offload Media / S3) store the actual files in remote object storage, so
347 // the local file is legitimately absent. A missing local file is NOT proof
348 // of an orphaned attachment and must never trigger deletion of the featured
349 // image association.
350 if ($post) {
351 $image_id = get_post_thumbnail_id($post->ID);
352 if ($image_id) {
353 $og_image = $this->build_og_image_from_attachment($image_id);
354 if ($og_image) {
355 $image = [$og_image['url'], $og_image['width'], $og_image['height']];
356 $image_mime_type = $og_image['mime'];
357 }
358 }
359 }
360
361 // Fallback to site default image if post has no featured image.
362 if (empty($image) && $site_info && !empty($site_info['social_share_image'])) {
363 $og_image = $this->build_og_image_from_attachment($site_info['social_share_image']);
364 if ($og_image) {
365 $image = [$og_image['url'], $og_image['width'], $og_image['height']];
366 $image_mime_type = $og_image['mime'];
367 }
368 }
369
370
371 // When a third-party SEO plugin handles OG/Twitter, omit our description
372 // from those tags to avoid duplicates.
373 $og_description = ($include_description) ? ($post_text ?? '') : '';
374
375 $ogMetaKeys = [
376 'og:locale' => get_locale(),
377 'og:type' => 'article',
378 'og:title' => $post->post_title . ' - ' . get_bloginfo('name'),
379 'og:description' => $og_description,
380 'og:url' => $this->get_canonical_url($post),
381 'og:site_name' => get_bloginfo('name'),
382 'og:updated_time' => $post->post_modified,
383 'og:image' => $image ? $image[0] : '',
384 'og:image:width' => $image ? $image[1] : '',
385 'og:image:height' => $image ? $image[2] : '',
386 'og:image:type' => $image ? $image_mime_type : '',
387 'og:image:alt' => $image ? $post->post_title : '',
388 ];
389
390 $facebookMetaKeys = [
391 'article:publisher' => $facebook_page_url && !filter_var($facebook_page_url, FILTER_VALIDATE_URL) ? 'https://' . $facebook_page_url : $facebook_page_url,
392 'article:author' => $facebook_authorship && !filter_var($facebook_authorship, FILTER_VALIDATE_URL) ? 'https://' . $facebook_authorship : $facebook_authorship,
393 'fb:admins' => $facebook_admin,
394 ];
395
396 $twitter_card_type = Metasync::get_option('twitter_card_type') ?? [];
397
398 $twitterMetaKeys = [
399 'twitter:card' => $twitter_card_type ? $twitter_card_type : 'summary_large_image',
400 'twitter:title' => $post->post_title . ' - ' . get_bloginfo('name'),
401 'twitter:site' => $twitter_username ? '@' . $twitter_username : '',
402 'twitter:creator' => $twitter_username ? '@' . $twitter_username : '',
403 'twitter:description' => $og_description,
404 'twitter:image' => $image ? $image[0] : '',
405 ];
406
407 // echo "\t<!-- MetaSync metadata -->\n";
408
409 foreach ($list_page_meta as $item => $value) {
410 if ($item == 'canonical') {
411 $this->print_metatag($item, $value, 'href', 'rel', 'link');
412 continue;
413 }
414 $this->print_metatag($item, $value);
415 }
416
417 if ($getSearchEngineOptions !== null) { // check if searchengine verification options are set
418 foreach ($keysSearchEngines as $optionKey => $metaKey) {
419 $this->print_metatag($metaKey, $getSearchEngineOptions[$optionKey]);
420 }
421 }
422
423 if ($post) {
424
425 // When OTTO is active AND has OG/Twitter data for this post, skip
426 // legacy output. For dynamic pixel injection (where specific OTTO OG
427 // meta is empty), the buffer-level dedup in
428 // Otto_html_class::deduplicate_og_twitter_tags() handles cleanup.
429 $otto_has_og = false;
430 $otto_has_twitter = false;
431 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_otto_enabled()) {
432 $otto_has_og = !empty(get_post_meta($post->ID, '_metasync_otto_og_title', true))
433 || !empty(get_post_meta($post->ID, '_metasync_otto_og_description', true));
434 $otto_has_twitter = !empty(get_post_meta($post->ID, '_metasync_otto_twitter_title', true))
435 || !empty(get_post_meta($post->ID, '_metasync_otto_twitter_description', true));
436 }
437
438 // When the canonical emitter (Metasync_OpenGraph::output_opengraph_tags, wp_head
439 // priority 5) will emit for this post, suppress these legacy og:*/twitter:* blocks
440 // so a MetaSync-only page outputs exactly one OG set and one Twitter set, preferring
441 // the persisted/OTTO values that emitter resolves. The Facebook block
442 // (article:publisher / article:author / fb:admins) is NOT gated on it — the canonical
443 // emitter never outputs those tags, so they never duplicate.
444 $og2_will_emit = apply_filters('metasync_opengraph_will_emit', false);
445
446 // When a third-party SEO plugin (Yoast / Rank Math / AIOSEO) is active it owns the
447 // social tags — suppress ALL legacy Facebook/OG/Twitter output so MetaSync never
448 // duplicates the other plugin's og:*/twitter:*/article:* tags.
449 $has_seo_plugin = $conflict_handler->has_active_seo_plugin();
450
451 $common_meta_settings = Metasync::get_option('common_meta_settings') ?? [];
452
453 if (!$otto_has_og && !$has_seo_plugin && isset($common_meta_settings['facebook_meta_tags'])) {
454 foreach ($facebookMetaKeys as $metaKey => $metaValue) {
455 $this->print_metatag($metaKey, $metaValue, 'content', 'property');
456 }
457 }
458
459 if (!$otto_has_og && !$og2_will_emit && !$has_seo_plugin && isset($common_meta_settings['open_graph_meta_tags'])) {
460 foreach ($ogMetaKeys as $metaKey => $metaValue) {
461 $this->print_metatag($metaKey, $metaValue, 'content', 'property');
462 }
463 }
464 if (!$otto_has_twitter && !$og2_will_emit && !$has_seo_plugin && isset($common_meta_settings['twitter_meta_tags'])) {
465 foreach ($twitterMetaKeys as $metaKey => $metaValue) {
466 $this->print_metatag($metaKey, $metaValue, 'content', 'name');
467 }
468 }
469 }
470 }
471
472 /**
473 * Build Open Graph image data for an attachment using database metadata only.
474 *
475 * Intentionally avoids `file_exists()`/disk access and any HTTP validation so
476 * it works for media-offload setups (WP Stateless, WP Offload Media / S3) where
477 * the file lives in remote object storage rather than on the local filesystem.
478 * It is strictly read-only and never mutates post or attachment meta.
479 *
480 * The image URL is resolved via `wp_get_attachment_url()`, which offload plugins
481 * filter to the correct remote URL; it falls back to building the URL from the
482 * upload directory and stored metadata when needed.
483 *
484 * @param int|string $attachment_id Attachment ID.
485 * @return array|null ['url' => string, 'width' => int, 'height' => int, 'mime' => string] or null.
486 */
487 private function build_og_image_from_attachment($attachment_id)
488 {
489 $attachment_id = (int) $attachment_id;
490 if ($attachment_id <= 0) {
491 return null;
492 }
493
494 $attachment = get_post($attachment_id);
495 if (!$attachment || $attachment->post_type !== 'attachment') {
496 return null;
497 }
498
499 // Read dimensions from stored metadata — no disk or network access.
500 $metadata = wp_get_attachment_metadata($attachment_id);
501 $width = (is_array($metadata) && !empty($metadata['width'])) ? (int) $metadata['width'] : 0;
502 $height = (is_array($metadata) && !empty($metadata['height'])) ? (int) $metadata['height'] : 0;
503
504 // Prefer wp_get_attachment_url() so offload plugins (WP Stateless / S3)
505 // return the correct remote URL; fall back to upload dir + metadata file.
506 $image_url = wp_get_attachment_url($attachment_id);
507 if (!$image_url && is_array($metadata) && !empty($metadata['file'])) {
508 $upload_dir = wp_upload_dir();
509 if (empty($upload_dir['error'])) {
510 $image_url = trailingslashit($upload_dir['baseurl']) . ltrim($metadata['file'], '/');
511 }
512 }
513
514 if (!$image_url) {
515 return null;
516 }
517
518 // Determine MIME type from the file extension (safe, no HTTP calls).
519 $mime_types = [
520 'jpg' => 'image/jpeg',
521 'jpeg' => 'image/jpeg',
522 'png' => 'image/png',
523 'gif' => 'image/gif',
524 'webp' => 'image/webp',
525 'svg' => 'image/svg+xml',
526 ];
527 $file_ext = strtolower(pathinfo(parse_url($image_url, PHP_URL_PATH) ?: $image_url, PATHINFO_EXTENSION));
528 $image_mime_type = $mime_types[$file_ext] ?? 'image/jpeg';
529
530 return [
531 'url' => $image_url,
532 'width' => $width,
533 'height' => $height,
534 'mime' => $image_mime_type,
535 ];
536 }
537
538 public function add_ld_json()
539 {
540 $post = get_post(get_the_ID());
541 if (empty($post))
542 return;
543
544 $site_info = Metasync::get_option('optimal_settings')['site_info'] ?? '';
545
546 $site_logo_id = $site_info['google_logo'] ?? '';
547 $custom_logo_id = get_theme_mod('custom_logo');
548 $logo_id = $custom_logo_id != '' ? $custom_logo_id : $site_logo_id;
549
550 $site_image_id = $site_info['social_share_image'] ?? '';
551 $post_thumbnail_id = get_post_thumbnail_id($post->ID);
552 $thumbnail_id = $post_thumbnail_id > 0 ? $post_thumbnail_id : $site_image_id;
553
554 $schema = array(
555 '@context' => "http://schema.org",
556 '@type' => "Article",
557 'headline' => str_replace($this->escapers, $this->replacements, $post->post_title ?? ''),
558 'image' => wp_get_attachment_image_url($thumbnail_id, 'full'),
559 'url' => get_permalink(),
560 'datePublished' => $post->post_modified,
561 'author' => array(
562 '@type' => "Person",
563 'name' => get_the_author_meta('display_name', $post->post_author),
564 'url' => get_author_posts_url($post->post_author),
565 ),
566 'publisher' => array(
567 '@type' => "Organization",
568 'name' => str_replace($this->escapers, $this->replacements, get_bloginfo('name') ?? ''),
569 'url' => get_site_url(),
570 'logo' => array(
571 '@type' => "ImageObject",
572 'url' => wp_get_attachment_image_url($logo_id, 'full'),
573 )
574 )
575 );
576
577 return $schema;
578 }
579
580 /**
581 * Output the LocalBusiness / Organization / Person JSON-LD built from the
582 * Local Business settings page (metasync_options['localseo']).
583 *
584 * Hooked to wp_head so the saved Local Business data appears in the
585 * front-end page source as valid schema.org structured data. Emits nothing
586 * on admin screens, feeds, or when the Local Business page is unconfigured.
587 */
588 public function output_local_business_schema()
589 {
590 if (is_admin() || is_feed()) {
591 return;
592 }
593
594 $schema = $this->build_local_business_schema();
595 if (empty($schema)) {
596 return;
597 }
598
599 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
600 if (empty($json)) {
601 return;
602 }
603
604 echo "\t<script type=\"application/ld+json\">" . $json . "</script>\n";
605 }
606
607 /**
608 * Build the LocalBusiness structured-data entity from the saved localseo
609 * option. Returns null when the page has never been configured (no Person/
610 * Organization type selected), so unconfigured sites emit no empty schema.
611 *
612 * @param array|null $options Saved localseo options; when null they are
613 * read from metasync_options['localseo'].
614 * @return array|null JSON-LD entity, or null when nothing is configured.
615 */
616 public function build_local_business_schema($options = null)
617 {
618 if ($options === null) {
619 $options = Metasync::get_option('localseo');
620 }
621 if (empty($options) || !is_array($options)) {
622 return null;
623 }
624
625 $type = $options['local_seo_person_organization'] ?? '';
626 if ($type !== 'Organization' && $type !== 'Person') {
627 return null;
628 }
629
630 $url = !empty($options['local_seo_url']) ? $options['local_seo_url'] : get_home_url();
631
632 $entity = array(
633 '@context' => 'https://schema.org',
634 '@type' => '',
635 'name' => '',
636 'url' => $url,
637 );
638
639 if (!empty($options['local_seo_email'])) {
640 $entity['email'] = $options['local_seo_email'];
641 }
642
643 if ($type === 'Person') {
644 $entity = $this->local_business_person($entity, $options);
645 } else {
646 $entity = $this->local_business_organization($entity, $options);
647 }
648
649 return $entity;
650 }
651
652 /**
653 * Populate an Organization / LocalBusiness entity.
654 *
655 * @param array $entity Base JSON-LD entity.
656 * @param array $options Saved localseo options.
657 * @return array
658 */
659 private function local_business_organization($entity, $options)
660 {
661 $name = str_replace($this->escapers, $this->replacements, $options['local_seo_name'] ?? '');
662 $entity['name'] = $name !== '' ? $name : str_replace($this->escapers, $this->replacements, get_bloginfo('name'));
663
664 // The Business Type field stores a human-readable label (e.g. "Local
665 // Business", "Art Gallery"). schema.org @type tokens have no spaces, so
666 // collapse them ("Local Business" -> "LocalBusiness") to stay valid.
667 // A truthy value can never be the string '0', which is itself falsy, so
668 // the truthiness check alone already covers the "0" case.
669 $business_type = $options['local_seo_business_type'] ?? '';
670 $schema_type = $business_type
671 ? str_replace(' ', '', $business_type)
672 : 'Organization';
673 $entity['@type'] = $schema_type;
674
675 if (!empty($options['local_seo_logo'])) {
676 $logo_url = is_numeric($options['local_seo_logo'])
677 ? wp_get_attachment_image_url($options['local_seo_logo'], 'full')
678 : $options['local_seo_logo'];
679 if ($logo_url) {
680 $entity['logo'] = $logo_url;
681 }
682 }
683
684 if (!empty($options['local_seo_price_range'])) {
685 $entity['priceRange'] = $options['local_seo_price_range'];
686 }
687
688 $this->local_business_add_geo($entity, $options);
689 $this->local_business_add_address($entity, $options);
690 $this->local_business_add_contact_points($entity, $options);
691 $this->local_business_add_hours($entity, $options);
692
693 return $entity;
694 }
695
696 /**
697 * Populate a Person entity.
698 *
699 * @param array $entity Base JSON-LD entity.
700 * @param array $options Saved localseo options.
701 * @return array
702 */
703 private function local_business_person($entity, $options)
704 {
705 $name = str_replace($this->escapers, $this->replacements, $options['local_seo_name'] ?? '');
706 $entity['@type'] = 'Person';
707 $entity['name'] = $name !== '' ? $name : str_replace($this->escapers, $this->replacements, get_bloginfo('name'));
708
709 if (!empty($options['local_seo_phone'])) {
710 $entity['telephone'] = $options['local_seo_phone'];
711 }
712
713 if (!empty($options['local_seo_logo'])) {
714 $logo_url = is_numeric($options['local_seo_logo'])
715 ? wp_get_attachment_image_url($options['local_seo_logo'], 'full')
716 : $options['local_seo_logo'];
717 if ($logo_url) {
718 $entity['image'] = $logo_url;
719 }
720 }
721
722 $this->local_business_add_geo($entity, $options);
723 $this->local_business_add_address($entity, $options);
724
725 return $entity;
726 }
727
728 /**
729 * Add a PostalAddress from the saved address sub-array.
730 */
731 private function local_business_add_address(&$entity, $options)
732 {
733 $address = $options['address'] ?? array();
734 if (empty($address) || !is_array($address)) {
735 return;
736 }
737
738 $street = $address['street'] ?? '';
739 $locality = $address['locality'] ?? '';
740 $region = $address['region'] ?? '';
741 $postal = $address['postalcode'] ?? '';
742 $country = $address['country'] ?? '';
743
744 // Skip entirely if every address field is empty.
745 if ($street === '' && $locality === '' && $region === '' && $postal === '' && $country === '') {
746 return;
747 }
748
749 $entity['address'] = array(
750 '@type' => 'PostalAddress',
751 'streetAddress' => str_replace($this->escapers, $this->replacements, $street),
752 'addressLocality' => str_replace($this->escapers, $this->replacements, $locality),
753 'addressRegion' => str_replace($this->escapers, $this->replacements, $region),
754 'postalCode' => str_replace($this->escapers, $this->replacements, $postal),
755 'addressCountry' => str_replace($this->escapers, $this->replacements, $country),
756 );
757 }
758
759 /**
760 * Add GeoCoordinates + hasMap from "lat,lng" saved coordinates.
761 */
762 private function local_business_add_geo(&$entity, $options)
763 {
764 $coords = $options['local_seo_geo_coordinates'] ?? '';
765 if (empty($coords)) {
766 return;
767 }
768
769 // explode() always yields at least one element, so only the second index
770 // needs an existence check (a value with no comma has no longitude).
771 $geo = array_map('trim', explode(',', $coords));
772 if (!isset($geo[1]) || $geo[0] === '' || $geo[1] === '') {
773 return;
774 }
775
776 $entity['geo'] = array(
777 '@type' => 'GeoCoordinates',
778 'latitude' => $geo[0],
779 'longitude' => $geo[1],
780 );
781 $entity['hasMap'] = 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode($geo[0] . ',' . $geo[1]);
782 }
783
784 /**
785 * Add ContactPoint entries from the parallel phonetype[] / phonenumber[] arrays.
786 */
787 private function local_business_add_contact_points(&$entity, $options)
788 {
789 $types = $options['phonetype'] ?? '';
790 $numbers = $options['phonenumber'] ?? '';
791 if (!is_array($types) || !is_array($numbers) || empty($types) || empty($numbers)) {
792 return;
793 }
794
795 $contacts = array();
796 foreach ($types as $i => $contact_type) {
797 $number = $numbers[$i] ?? '';
798 if ($contact_type === '' || $number === '') {
799 continue;
800 }
801 $contacts[] = array(
802 '@type' => 'ContactPoint',
803 'telephone' => $number,
804 'contactType' => $contact_type,
805 );
806 }
807
808 if (!empty($contacts)) {
809 $entity['contactPoint'] = $contacts;
810 }
811 }
812
813 /**
814 * Add openingHours from the parallel days[] / times[] arrays.
815 */
816 private function local_business_add_hours(&$entity, $options)
817 {
818 $days = $options['days'] ?? '';
819 $times = $options['times'] ?? '';
820 if (!is_array($days) || !is_array($times) || empty($days) || empty($times)) {
821 return;
822 }
823
824 $hours = array();
825 foreach ($days as $i => $day) {
826 $time = $times[$i] ?? '';
827 if ($day === '' || $time === '') {
828 continue;
829 }
830 $hours[] = $day . ' ' . $time;
831 }
832
833 if (!empty($hours)) {
834 $entity['openingHours'] = $hours;
835 }
836 }
837
838 /*
839 * This will hide the title on single posts and pages
840 * if they were created with the "metasync" system.
841 * Passes default values for $title and $id to avoid errors.
842 */
843 public function hide_title_on_otto_pages($title = '', $id = null){
844
845 # Return title immediately if $id or $title is not provided
846 if (empty($id) || empty($title)) {
847 return $title;
848 }
849
850 # Check if it's a single post or page
851 if ((is_single() || is_page()) && in_the_loop() && is_main_query()) {
852
853 # Check if the post was created with the "metasync" system
854 $metasync_post = get_post_meta($id, 'metasync_post', true);
855 if ($metasync_post === 'yes') {
856 return '';
857 }
858 }
859 return $title;
860 }
861
862 /**
863 * Drop serving/appearance directives when the page is set to noindex.
864 *
865 * max-snippet, max-image-preview, max-video-preview, nosnippet, noarchive
866 * and noimageindex only affect how an indexed page is displayed, so they
867 * are meaningless once the page is excluded from the index. When noindex is
868 * present we keep only the indexing/crawling directives (noindex, nofollow)
869 * so the emitted tag stays clean, e.g. "noindex, nofollow" rather than
870 * "noindex, nofollow, noarchive, nosnippet, max-snippet:100, ...".
871 *
872 * @param string[] $directives
873 * @return string[]
874 */
875 private function strip_serving_directives_if_noindex($directives) {
876 $has_noindex = false;
877 foreach ($directives as $d) {
878 if (strcasecmp((string) $d, 'noindex') === 0) {
879 $has_noindex = true;
880 break;
881 }
882 }
883 if (!$has_noindex) {
884 return $directives;
885 }
886 return array_values(array_filter($directives, function ($d) {
887 $d = strtolower((string) $d);
888 return $d === 'noindex' || $d === 'nofollow';
889 }));
890 }
891
892 /**
893 * Resolve the robots meta value from all storage formats.
894 *
895 * Priority order:
896 * 1. _metasync_robots_advanced (JSON)
897 * 2. meta_robots (string, e.g. "noindex, nofollow") — set via REST API
898 * 3. metasync_common_robots (array) + metasync_advance_robots (array) — admin checkboxes
899 * 4. Global defaults from metasync_options['advance_robots_meta']
900 *
901 * The noindex directive is sourced from _metasync_robots_index (separate key)
902 * and merged into the final string regardless of which storage format is used.
903 *
904 * @param array $all_meta Result of get_post_meta($id) (all meta as arrays).
905 * @return string Robots directive string (e.g. "nofollow, noarchive, max-snippet:-1") or empty.
906 */
907 private function resolve_robots_value($all_meta) {
908 $directives = [];
909
910 // Merge noindex from _metasync_robots_index (separate key, not part of advanced JSON)
911 $robots_index = $all_meta['_metasync_robots_index'][0] ?? '';
912 if ($robots_index === 'noindex') {
913 $directives[] = 'noindex';
914 }
915
916 // Also honor noindex from the classic Common Robots meta box, which stores
917 // it in metasync_common_robots. Without seeding it here, a noindex set via
918 // that meta box is lost whenever the advanced-JSON path below short-circuits.
919 if (!in_array('noindex', $directives, true)) {
920 $common_index_raw = $all_meta['metasync_common_robots'][0] ?? '';
921 if (!empty($common_index_raw)) {
922 $common_index = maybe_unserialize($common_index_raw);
923 if (is_array($common_index) && !empty($common_index['noindex'])) {
924 $directives[] = 'noindex';
925 }
926 }
927 }
928
929 // 1. Check _metasync_robots_advanced (JSON)
930 $advanced_raw = $all_meta['_metasync_robots_advanced'][0] ?? '';
931 if (!empty($advanced_raw)) {
932 $advanced = json_decode($advanced_raw, true);
933 if (is_array($advanced) && !empty($advanced)) {
934 // Boolean directives
935 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
936 if (!empty($advanced[$dir])) {
937 $directives[] = $dir;
938 }
939 }
940 // max-* directives
941 if (isset($advanced['max_snippet'])) {
942 $directives[] = 'max-snippet:' . (int) $advanced['max_snippet'];
943 }
944 if (isset($advanced['max_image_preview'])) {
945 $directives[] = 'max-image-preview:' . esc_attr($advanced['max_image_preview']);
946 }
947 if (isset($advanced['max_video_preview'])) {
948 $directives[] = 'max-video-preview:' . (int) $advanced['max_video_preview'];
949 }
950 $directives = $this->strip_serving_directives_if_noindex($directives);
951 return !empty($directives) ? implode(', ', $directives) : '';
952 }
953 }
954
955 // 2. Check meta_robots (REST API / direct string)
956 $meta_robots = $all_meta['meta_robots'][0] ?? '';
957 if (!empty($meta_robots)) {
958 // Prepend noindex from _metasync_robots_index if not already in the string
959 if (!empty($directives) && stripos($meta_robots, 'noindex') === false) {
960 return implode(', ', $directives) . ', ' . $meta_robots;
961 }
962 return $meta_robots;
963 }
964
965 // 3. Check metasync_common_robots (admin checkbox array)
966 $raw = $all_meta['metasync_common_robots'][0] ?? '';
967 if (!empty($raw)) {
968 $common = maybe_unserialize($raw);
969 if (is_array($common) && !empty($common)) {
970 $common_directives = array_values(array_filter($common, function ($v) {
971 return !empty($v);
972 }));
973 // 'index' is the implicit default and must never be emitted as a
974 // directive. Dropping it here also de-conflicts legacy posts saved
975 // with both Index and No Index checked, so noindex always wins.
976 $common_directives = array_values(array_filter($common_directives, function ($v) {
977 return strcasecmp((string) $v, 'index') !== 0;
978 }));
979 $directives = array_merge($directives, $common_directives);
980 }
981 }
982
983 // Also check metasync_advance_robots (admin per-post advanced checkboxes)
984 // Structure: ['max-snippet' => ['enable' => '1', 'length' => 100], ...]
985 $adv_raw = $all_meta['metasync_advance_robots'][0] ?? '';
986 if (!empty($adv_raw)) {
987 $adv = maybe_unserialize($adv_raw);
988 if (is_array($adv)) {
989 if (!empty($adv['max-snippet']['enable'])) {
990 $length = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
991 $directives[] = 'max-snippet:' . $length;
992 }
993 if (!empty($adv['max-video-preview']['enable'])) {
994 $length = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
995 $directives[] = 'max-video-preview:' . $length;
996 }
997 if (!empty($adv['max-image-preview']['enable'])) {
998 $length = isset($adv['max-image-preview']['length']) ? sanitize_text_field($adv['max-image-preview']['length']) : 'large';
999 $directives[] = 'max-image-preview:' . $length;
1000 }
1001 }
1002 }
1003
1004 if (!empty($directives)) {
1005 $directives = $this->strip_serving_directives_if_noindex($directives);
1006 return implode(', ', array_unique($directives));
1007 }
1008
1009 // 4. Global defaults from metasync_options['advance_robots_meta']
1010 $global_options = Metasync::get_option('advance_robots_meta');
1011 if (empty($global_options)) {
1012 $global_options = Metasync::get_option('advance_robots_mata');
1013 }
1014 if (is_array($global_options)) {
1015 if (!empty($global_options['max-snippet']['enable'])) {
1016 $length = isset($global_options['max-snippet']['length']) ? (int) $global_options['max-snippet']['length'] : -1;
1017 $directives[] = 'max-snippet:' . $length;
1018 }
1019 if (!empty($global_options['max-video-preview']['enable'])) {
1020 $length = isset($global_options['max-video-preview']['length']) ? (int) $global_options['max-video-preview']['length'] : -1;
1021 $directives[] = 'max-video-preview:' . $length;
1022 }
1023 if (!empty($global_options['max-image-preview']['enable'])) {
1024 $length = isset($global_options['max-image-preview']['length']) ? sanitize_text_field($global_options['max-image-preview']['length']) : 'large';
1025 $directives[] = 'max-image-preview:' . $length;
1026 }
1027 }
1028
1029 // AC-1: max-image-preview:large outputs by default even with no configuration.
1030 // If no per-post or global setting included max-image-preview, apply the default.
1031 $has_max_image = false;
1032 foreach ($directives as $d) {
1033 if (strpos($d, 'max-image-preview') !== false) {
1034 $has_max_image = true;
1035 break;
1036 }
1037 }
1038 if (!$has_max_image) {
1039 $directives[] = 'max-image-preview:large';
1040 }
1041
1042 return !empty($directives) ? implode(', ', array_unique($directives)) : '';
1043 }
1044
1045 /**
1046 * Get the correct canonical URL for archive pages.
1047 *
1048 * Handles: Posts page (is_home), category, tag, custom taxonomy,
1049 * author, year/month/day date archives.
1050 *
1051 * @return string Archive URL or empty string if not determinable.
1052 */
1053 private function get_archive_canonical_url() {
1054 if (is_home()) {
1055 $blog_page_id = (int) get_option('page_for_posts');
1056 if ($blog_page_id > 0) {
1057 // Honour a manual MetaSync canonical override on the Posts page
1058 // (validated — corrupted legacy rows are ignored,).
1059 $override = Metasync_Canonical_Sanitizer::sanitize(
1060 get_post_meta($blog_page_id, '_metasync_canonical_url', true)
1061 );
1062 if ($override !== '') {
1063 return esc_url($override);
1064 }
1065 return get_permalink($blog_page_id);
1066 }
1067 return home_url('/');
1068 }
1069
1070 if (is_category() || is_tag() || is_tax()) {
1071 $term = get_queried_object();
1072 if ($term instanceof WP_Term) {
1073 $link = get_term_link($term);
1074 return is_wp_error($link) ? '' : $link;
1075 }
1076 return '';
1077 }
1078
1079 if (is_author()) {
1080 return get_author_posts_url(get_queried_object_id());
1081 }
1082
1083 if (is_day()) {
1084 return get_day_link(get_query_var('year'), get_query_var('monthnum'), get_query_var('day'));
1085 }
1086
1087 if (is_month()) {
1088 return get_month_link(get_query_var('year'), get_query_var('monthnum'));
1089 }
1090
1091 if (is_year()) {
1092 return get_year_link(get_query_var('year'));
1093 }
1094
1095 return '';
1096 }
1097
1098 /**
1099 * Get the canonical URL for a post
1100 */
1101 private function get_canonical_url($post) {
1102 # Check for persisted OTTO canonical URL first, then the Canonical meta
1103 # box value (meta_canonical). Both are validated so legacy rows corrupted
1104 # to the literal "Array" (or stored as arrays) are never emitted.
1105 $metasync_canonical = Metasync_Canonical_Sanitizer::sanitize(
1106 get_post_meta($post->ID, '_metasync_canonical_url', true)
1107 );
1108 if ($metasync_canonical === '') {
1109 $metasync_canonical = Metasync_Canonical_Sanitizer::sanitize(
1110 get_post_meta($post->ID, 'meta_canonical', true)
1111 );
1112 }
1113
1114 if ($metasync_canonical !== '') {
1115 return esc_url($metasync_canonical);
1116 }
1117
1118 # Try to get the permalink using WordPress function
1119 $permalink = get_permalink($post->ID);
1120
1121 # If permalink is not available or is the default query URL, try alternative methods
1122 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1123 # Force WordPress to generate the proper permalink by temporarily setting post status
1124 $original_status = $post->post_status;
1125 if ($post->post_status === 'auto-draft') {
1126 $post->post_status = 'publish';
1127 }
1128
1129 # Try get_permalink again with the updated status
1130 $permalink = get_permalink($post->ID);
1131
1132 # Restore original status
1133 $post->post_status = $original_status;
1134 }
1135
1136 # If still not working, use WordPress core functions to build proper permalink
1137 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1138 # Use WordPress core function that respects permalink structure
1139 # This properly handles custom structures, hierarchies, and post types
1140 # Load admin function if not already available, Without this it is causing error on post the preview page
1141 if (!function_exists('get_sample_permalink')) {
1142 require_once ABSPATH . 'wp-admin/includes/post.php';
1143 }
1144 $permalink = get_sample_permalink($post->ID);
1145
1146 if (is_array($permalink)) {
1147 # get_sample_permalink returns array with template and slug
1148 # Replace %postname% or %pagename% with actual slug
1149 $permalink = str_replace(
1150 array('%pagename%', '%postname%'),
1151 $post->post_name,
1152 $permalink[0]
1153 );
1154 }
1155
1156 # Final fallback: if still problematic, construct URL respecting post type structure
1157 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1158 if (!empty($post->post_name)) {
1159 # For pages, check if there's a parent hierarchy
1160 if ($post->post_type === 'page' && $post->post_parent) {
1161 # Get parent page path for proper hierarchy
1162 $parent = get_post($post->post_parent);
1163 $parent_path = '';
1164
1165 # Build full path including all parent pages
1166 while ($parent) {
1167 $parent_path = $parent->post_name . '/' . $parent_path;
1168 $parent = $parent->post_parent ? get_post($parent->post_parent) : null;
1169 }
1170
1171 $permalink = home_url('/' . $parent_path . $post->post_name . '/');
1172 } else {
1173 # For posts and pages without parents, use post type archive base
1174 $post_type_obj = get_post_type_object($post->post_type);
1175 $slug = $post_type_obj->rewrite['slug'] ?? '';
1176
1177 if ($slug && $post->post_type !== 'page') {
1178 $permalink = home_url('/' . $slug . '/' . $post->post_name . '/');
1179 } else {
1180 $permalink = home_url('/' . $post->post_name . '/');
1181 }
1182 }
1183 } else {
1184 # Fallback to post ID format if no slug available
1185 $permalink = home_url('/?p=' . $post->ID);
1186 }
1187 }
1188 }
1189
1190 return $permalink;
1191 }
1192
1193 /**
1194 * Set up indexation controls for archive pages
1195 *
1196 * Called via template_redirect hook to set up early before any output.
1197 * This ensures we can capture and clean robots tags from other plugins.
1198 *
1199 * Logic:
1200 * 1. If user wants to add noindex - always do it (override other plugins)
1201 * 2. If user wants to allow indexing - only remove other plugins' tags if override setting is enabled
1202 *
1203 * @since 1.0.0
1204 */
1205 public function inject_archive_seo_controls() {
1206 // Check if we're on a managed archive type
1207 if (!$this->is_managed_archive()) {
1208 return;
1209 }
1210
1211 // Get settings
1212 $seo_controls = Metasync::get_option('seo_controls', array());
1213 $should_noindex = $this->should_noindex_archive();
1214 $override_enabled = ($seo_controls['override_robots_tags'] ?? 'false') === 'true' || ($seo_controls['override_robots_tags'] ?? false) === true;
1215
1216 // Run buffer if either:
1217 // 1. User wants to add noindex (always override other plugins), OR
1218 // 2. User wants to allow indexing AND override setting is enabled
1219 if ($should_noindex || $override_enabled) {
1220 // Override WordPress core robots tag
1221 add_filter('wp_robots', array($this, 'override_wp_robots'), 999);
1222
1223 // Start buffering to remove other plugins' robots tags
1224 add_action('wp_head', array($this, 'start_robots_buffer'), 0);
1225 add_action('wp_head', array($this, 'end_robots_buffer'), PHP_INT_MAX);
1226 }
1227 // Otherwise: User wants to allow indexing AND override is disabled - don't interfere
1228 }
1229
1230 /**
1231 * Check if current page is a managed archive type
1232 *
1233 * Returns true if we're on any archive type that has indexation controls,
1234 * regardless of whether noindex is enabled or not.
1235 *
1236 * @since 1.0.0
1237 * @return bool True if on a managed archive type, false otherwise
1238 */
1239 private function is_managed_archive() {
1240 return is_date() || is_tag() || is_author() || is_category() || is_tax('post_format');
1241 }
1242
1243 /**
1244 * Check if current archive is empty (has no posts)
1245 *
1246 * Applies to category, tag, author, and post format archives.
1247 * Date archives are excluded as they're typically either indexed
1248 * or not entirely (less meaningful to check for empty state).
1249 *
1250 * @since 1.0.0
1251 * @return bool True if archive is empty (0 posts)
1252 */
1253 private function is_empty_archive() {
1254 global $wp_query;
1255
1256 // Check taxonomy and author archives (excluding date archives)
1257 if (!is_category() && !is_tag() && !is_author() && !is_tax('post_format')) {
1258 return false;
1259 }
1260
1261 // Check if the archive has no posts
1262 return $wp_query->post_count === 0;
1263 }
1264
1265 /**
1266 * Check if current archive should be noindexed
1267 *
1268 * @since 1.0.0
1269 * @return bool True if should add noindex, false otherwise
1270 */
1271 private function should_noindex_archive() {
1272 // Get indexation control settings
1273 $seo_controls = Metasync::get_option('seo_controls', array());
1274
1275 // Check empty archives setting first (applies to categories/tags only)
1276 $noindex_empty_archives = $seo_controls['noindex_empty_archives'] ?? false;
1277 if (($noindex_empty_archives === 'true' || $noindex_empty_archives === true) && $this->is_empty_archive()) {
1278 return true;
1279 }
1280
1281 // Date Archives
1282 if (is_date()) {
1283 $index_date_archives = $seo_controls['index_date_archives'] ?? false;
1284 // Handle both string and boolean values for compatibility
1285 if ($index_date_archives === 'true' || $index_date_archives === true) {
1286 return true;
1287 }
1288 }
1289
1290 // Tag Archives
1291 elseif (is_tag()) {
1292 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1293 // Handle both string and boolean values for compatibility
1294 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1295 return true;
1296 }
1297 }
1298
1299 // Author Archives
1300 elseif (is_author()) {
1301 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1302 // Handle both string and boolean values for compatibility
1303 if ($index_author_archives === 'true' || $index_author_archives === true) {
1304 return true;
1305 }
1306 }
1307
1308 // Category Archives
1309 elseif (is_category()) {
1310 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1311 // Handle both string and boolean values for compatibility
1312 if ($index_category_archives === 'true' || $index_category_archives === true) {
1313 return true;
1314 }
1315 }
1316
1317 // Format Archives (post format taxonomy)
1318 elseif (is_tax('post_format')) {
1319 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1320 if ($index_format_archives === 'true' || $index_format_archives === true) {
1321 return true;
1322 }
1323 }
1324
1325 return false;
1326 }
1327
1328 /**
1329 * Override WordPress core robots directives
1330 *
1331 * Handles both cases:
1332 * 1. When noindex is enabled: Add noindex directive
1333 * 2. When indexing is allowed: Return empty array (removes WP core robots tag)
1334 *
1335 * @since 1.0.0
1336 * @param array $robots Associative array of robots directives
1337 * @return array Modified robots directives
1338 */
1339 public function override_wp_robots($robots) {
1340 if ($this->should_noindex_archive()) {
1341 // Case 1: Add noindex directive
1342 return array(
1343 'noindex' => true,
1344 'follow' => true,
1345 'max-image-preview' => 'large', // Maintain good image preview for social sharing
1346 );
1347 } else {
1348 // Case 2: Allow indexing - return empty array to remove WP core robots tag
1349 // Default behavior without robots tag is to index
1350 return array();
1351 }
1352 }
1353
1354 /**
1355 * Start output buffering to capture robots meta tags from other plugins
1356 *
1357 * @since 1.0.0
1358 */
1359 public function start_robots_buffer() {
1360 ob_start();
1361 }
1362
1363 /**
1364 * End output buffering, remove existing robots tags, and optionally add ours
1365 *
1366 * This handles BOTH cases:
1367 * 1. When noindex is enabled: Remove other tags and add noindex tag
1368 * 2. When indexing is allowed: Remove other plugins' noindex tags
1369 *
1370 * This ensures our indexation settings always take precedence over other plugins.
1371 *
1372 * @since 1.0.0
1373 */
1374 public function end_robots_buffer() {
1375 // Only proceed if we're on a managed archive
1376 if (!$this->is_managed_archive()) {
1377 ob_end_flush();
1378 return;
1379 }
1380
1381 // Get buffered content
1382 $content = ob_get_clean();
1383
1384 // Remove all existing robots meta tags (handles various formats)
1385 // This is done for BOTH noindex and index cases to ensure clean slate
1386
1387 // Pattern 1: name="robots" content="..."
1388 $content = preg_replace(
1389 '/<meta\s+name=["\']robots["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1390 '',
1391 $content
1392 );
1393
1394 // Pattern 2: content="..." name="robots"
1395 $content = preg_replace(
1396 '/<meta\s+content=["\'][^"\']*["\']\s+name=["\']robots["\']\s*\/?>\s*/i',
1397 '',
1398 $content
1399 );
1400
1401 // Pattern 3: Single quotes or no quotes (rare but possible)
1402 $content = preg_replace(
1403 "/<meta\s+name='robots'\s+content='[^']*'\s*\/?>\s*/i",
1404 '',
1405 $content
1406 );
1407
1408 // Pattern 4: property="robots" (some plugins use property attribute)
1409 $content = preg_replace(
1410 '/<meta\s+property=["\']robots["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1411 '',
1412 $content
1413 );
1414
1415 // Pattern 5: Search engine specific directives (googlebot, bingbot, etc.)
1416 $content = preg_replace(
1417 '/<meta\s+name=["\'](?:googlebot|bingbot|googlebot-news|slurp)["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1418 '',
1419 $content
1420 );
1421
1422 // Output cleaned content
1423 echo $content;
1424
1425 // Add our robots tag or comment based on settings
1426 if ($this->should_noindex_archive()) {
1427 // Case 1: User wants to disallow indexing - add noindex tag
1428 echo '<!-- MetaSync Indexation Control: Noindex Applied (Overriding other plugins) -->' . "\n";
1429 echo '<meta name="robots" content="noindex, follow">' . "\n";
1430 } else {
1431 // Case 2: User wants to allow indexing AND override is enabled
1432 // Just remove other plugins' tags, don't add our own
1433 // Default behavior without robots tag is to allow indexing
1434 echo '<!-- MetaSync Indexation Control: Index Allowed (Override enabled - Other noindex tags removed) -->' . "\n";
1435 }
1436 }
1437
1438 /**
1439 * Filter taxonomy sitemap entries to exclude disabled archive types
1440 *
1441 * @since 1.0.0
1442 * @param array $taxonomies Array of taxonomy objects
1443 * @return array Modified array of taxonomy objects
1444 */
1445 public function filter_sitemap_taxonomies($taxonomies) {
1446 // Get indexation control settings
1447 $seo_controls = Metasync::get_option('seo_controls', array());
1448
1449 // Check if tag archives are disabled
1450 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1451 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1452 // Remove post_tag taxonomy from sitemap
1453 unset($taxonomies['post_tag']);
1454 }
1455
1456 // Check if category archives are disabled
1457 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1458 if ($index_category_archives === 'true' || $index_category_archives === true) {
1459 // Remove category taxonomy from sitemap
1460 unset($taxonomies['category']);
1461 }
1462
1463 // Check if format archives are disabled
1464 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1465 if ($index_format_archives === 'true' || $index_format_archives === true) {
1466 // Remove post_format taxonomy from sitemap
1467 unset($taxonomies['post_format']);
1468 }
1469
1470 return $taxonomies;
1471 }
1472
1473 /**
1474 * Filter user sitemap entries to exclude disabled author archives
1475 *
1476 * @since 1.0.0
1477 * @param array $entry Sitemap entry for user
1478 * @param WP_User $user User object
1479 * @return array|false Modified sitemap entry or false to exclude
1480 */
1481 public function filter_sitemap_users($entry, $user) {
1482 // Get indexation control settings
1483 $seo_controls = Metasync::get_option('seo_controls', array());
1484
1485 // Check if author archives are disabled
1486 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1487 if ($index_author_archives === 'true' || $index_author_archives === true) {
1488 // Exclude this user from sitemap
1489 return false;
1490 }
1491
1492 return $entry;
1493 }
1494
1495 /**
1496 * Filter sitemap providers to exclude disabled archive types
1497 *
1498 * @since 1.0.0
1499 * @param bool $provider Whether to add the provider
1500 * @param string $name Provider name
1501 * @return bool Whether to add the provider
1502 */
1503 public function filter_sitemap_providers($provider, $name) {
1504 // Get indexation control settings
1505 $seo_controls = Metasync::get_option('seo_controls', array());
1506
1507 // Check if users/authors sitemap provider should be disabled
1508 if ($name === 'users') {
1509 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1510 if ($index_author_archives === 'true' || $index_author_archives === true) {
1511 return false; // Exclude users sitemap provider entirely
1512 }
1513 }
1514
1515 return $provider;
1516 }
1517
1518 /**
1519 * Filter sitemap index entries to exclude disabled archive types
1520 *
1521 * @since 1.0.0
1522 * @param array $sitemap_entry Sitemap entry array
1523 * @param string $object_type Object type (posts, taxonomies, users)
1524 * @param string $subtype Subtype (post type or taxonomy name)
1525 * @param int $page Page number
1526 * @return array|false Modified sitemap entry or false to exclude
1527 */
1528 public function filter_sitemap_index_entries($sitemap_entry, $object_type, $subtype, $page) {
1529 // Get indexation control settings
1530 $seo_controls = Metasync::get_option('seo_controls', array());
1531
1532 // Handle taxonomy sitemaps
1533 if ($object_type === 'taxonomies') {
1534 // Check if tag archives are disabled
1535 if ($subtype === 'post_tag') {
1536 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1537 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1538 return false; // Exclude from sitemap index
1539 }
1540 }
1541
1542 // Check if category archives are disabled
1543 if ($subtype === 'category') {
1544 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1545 if ($index_category_archives === 'true' || $index_category_archives === true) {
1546 return false; // Exclude from sitemap index
1547 }
1548 }
1549
1550 // Check if format archives are disabled
1551 if ($subtype === 'post_format') {
1552 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1553 if ($index_format_archives === 'true' || $index_format_archives === true) {
1554 return false; // Exclude from sitemap index
1555 }
1556 }
1557 }
1558
1559 // Handle user/author sitemaps
1560 if ($object_type === 'users') {
1561 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1562 if ($index_author_archives === 'true' || $index_author_archives === true) {
1563 return false; // Exclude from sitemap index
1564 }
1565 }
1566
1567 return $sitemap_entry;
1568 }
1569 }
1570