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

1,207 lines 40.9 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 $primary_id = (int) get_post_meta($post->ID, '_metasync_primary_category', true);
55
56 // Fallback to Yoast SEO primary category
57 if ($primary_id === 0 && defined('WPSEO_VERSION')) {
58 $primary_id = (int) get_post_meta($post->ID, '_yoast_wpseo_primary_category', true);
59 }
60
61 // Fallback to Rank Math primary category
62 if ($primary_id === 0 && defined('RANK_MATH_VERSION')) {
63 $primary_id = (int) get_post_meta($post->ID, 'rank_math_primary_category', true);
64 }
65
66 if ($primary_id === 0 || empty($cats)) {
67 return $cat;
68 }
69
70 foreach ($cats as $category) {
71 if ((int) $category->term_id === $primary_id) {
72 return $category;
73 }
74 }
75
76 return $cat;
77 }
78
79 function metasync_wp_robots_meta($robots)
80 {
81 foreach ($robots as $key => $value) {
82 $robots[$key] = false;
83 }
84 return $robots;
85 }
86
87 public function print_metatag($name, $value, $valueAttrib = "content", $nameAttrib = "name", $tagName = "meta")
88 {
89 // Safety: extract string from array values (fixes legacy meta_canonical stored as array)
90 if (is_array($value)) {
91 $value = reset($value) ?: '';
92 }
93
94 if (empty($value))
95 return false;
96
97 printf(
98 "\t<%s %s=\"%s\" %s=\"%s\" />\n",
99 esc_attr($tagName),
100 esc_attr($nameAttrib),
101 esc_attr($name),
102 esc_attr($valueAttrib),
103 esc_attr($value)
104 );
105 }
106
107 /**
108 * Check if the current page is an AMP page
109 *
110 * @return bool True if AMP page, false otherwise
111 */
112 public function is_amp_page()
113 {
114 // Check if URL path contains /amp/
115 $current_url = $_SERVER['REQUEST_URI'] ?? '';
116 if (strpos($current_url, '/amp/') !== false) {
117 return true;
118 }
119
120 // Check if URL ends with /amp
121 if (preg_match('/\/amp\/?$/', $current_url)) {
122 return true;
123 }
124
125 // Check if amp=1 query parameter is present
126 if (isset($_GET['amp']) && $_GET['amp'] == '1') {
127 return true;
128 }
129
130 // Check for other common AMP query parameters
131 if (isset($_GET['amp']) && !empty($_GET['amp'])) {
132 return true;
133 }
134
135 return false;
136 }
137
138 /**
139 * Remove metasync_optimized attribute from head tag on AMP pages
140 * This function uses output buffering to clean the head content
141 */
142 public function cleanup_amp_head_attribute()
143 {
144 // Only run on AMP pages
145 if (!$this->is_amp_page()) {
146 return;
147 }
148
149 // Start output buffering to capture and modify the head content
150 ob_start(function($buffer) {
151 // Remove metasync_optimized attribute from head tag
152 $cleaned_buffer = preg_replace('/(<head[^>]*)\s*metasync_optimized(?:="[^"]*")?([^>]*>)/i', '$1$2', $buffer);
153
154 return $cleaned_buffer;
155 });
156 }
157
158 /**
159 * End output buffering for AMP cleanup
160 */
161 public function end_amp_head_cleanup()
162 {
163 // Only run on AMP pages
164 if (!$this->is_amp_page()) {
165 return;
166 }
167
168 // End output buffering
169 if (ob_get_level()) {
170 ob_end_flush();
171 }
172 }
173
174 public function hook_metasync_metatags()
175 {
176 $conflict_handler = Metasync_SEO_Conflict_Handler::get_instance();
177
178 // On archive pages (category, tag, author, date, blog home) get_the_ID()
179 // returns the first post in the loop — not the archive itself. Guard here so
180 // we never use that first-post ID for canonical / og:url on archives.
181 // Instead, emit the correct archive URL and return early.
182 if (!is_singular()) {
183 if (!$conflict_handler->has_active_seo_plugin()) {
184 $archive_url = $this->get_archive_canonical_url();
185 if (!empty($archive_url)) {
186 printf("\t<link rel=\"canonical\" href=\"%s\" />\n", esc_url($archive_url));
187 printf("\t<meta property=\"og:url\" content=\"%s\" />\n", esc_url($archive_url));
188 }
189 }
190 return;
191 }
192
193 // Singular post/page: suppress WP core's canonical and let MetaSync handle it.
194 if (!$conflict_handler->has_active_seo_plugin()) {
195 remove_action('wp_head', 'rel_canonical');
196 }
197
198 $get_page_meta = get_post_meta(get_the_ID());
199
200 // When a third-party SEO plugin is active, only output our description
201 // if MetaSync has an intentional value (OTTO or sidebar). Otherwise let
202 // the other plugin handle it to avoid duplicates.
203 $include_description = $conflict_handler->should_output_legacy_description();
204
205 $list_page_meta = array();
206
207 // WP-196: When synced to an active SEO plugin, skip MetaSync's own
208 // robots and description output — the native plugin handles it.
209 $post_id = get_the_ID();
210 $is_synced_to_plugin = false;
211 if ($post_id && $conflict_handler->has_active_seo_plugin()) {
212 $sync_ts = get_post_meta($post_id, '_metasync_plugin_sync_ts', true);
213 if (!empty($sync_ts)) {
214 $is_synced_to_plugin = true;
215 }
216 }
217
218 // Robots: resolve from both meta_robots (REST API) and metasync_common_robots (checkbox).
219 // When a third-party SEO plugin is active:
220 // - MetaSync has robots value → output ours, AIOSEO suppressed via filter
221 // - MetaSync has NO value → skip, let the other plugin handle it
222 // When no third-party plugin → always output if we have a value.
223 if (!$is_synced_to_plugin) {
224 $robots_value = $this->resolve_robots_value($get_page_meta);
225 if (!empty($robots_value)) {
226 if (!$conflict_handler->has_active_seo_plugin()) {
227 $list_page_meta['robots'] = $robots_value;
228 } else {
229 // Third-party active but MetaSync has intentional robots — output ours
230 // (the conflict handler filter suppresses the other plugin's robots tag)
231 if ($post_id && $conflict_handler->metasync_has_robots($post_id)) {
232 $list_page_meta['robots'] = $robots_value;
233 }
234 }
235 }
236 }
237
238 if ($include_description) {
239 $list_page_meta['description'] = $get_page_meta['meta_description'][0] ?? '';
240 }
241
242 // Note: enable_metadesc is always enabled by default - no check needed
243
244 $getSearchEngineOptions = Metasync::get_option('searchengines');
245 $keysSearchEngines = [
246 'bing_site_verification' => 'msvalidate.01',
247 'baidu_site_verification' => 'baidu-site-verification',
248 'alexa_site_verification' => 'alexaVerifyID',
249 'yandex_site_verification' => 'yandex-verification',
250 'google_site_verification' => 'google-site-verification',
251 'pinterest_site_verification' => 'p:domain_verify',
252 'norton_save_site_verification' => 'norton-safeweb-site-verification',
253 ];
254
255 $post = get_post(get_the_ID());
256 if (empty($post))
257 return;
258
259 # Keywords: render persisted OTTO focus keyword as <meta name="keywords">
260 $focus_keyword = get_post_meta($post->ID, '_metasync_focus_keyword', true);
261 if (!empty($focus_keyword)) {
262 $list_page_meta['keywords'] = sanitize_text_field($focus_keyword);
263 }
264
265 # Canonical: output <link rel="canonical"> only when no third-party SEO plugin
266 # handles it, to avoid duplicate canonical tags in the page source.
267 if (!$conflict_handler->has_active_seo_plugin()) {
268 $list_page_meta['canonical'] = $this->get_canonical_url($post);
269 }
270
271 # $post_text = wp_trim_words(get_the_content(), 30, '');
272
273 # Check if the post has content, then apply WordPress content filters
274 # $post_content = !empty($post->post_content) ? apply_filters('the_content', $post->post_content) : '';
275 # $post_text = wp_trim_words($post_content, 30, '');
276
277 /**
278 * Extract post content safely for meta descriptions
279 * This solution addresses three critical issues:
280 * 1. Timber compatibility - works without WordPress loop
281 * 2. Plugin conflicts - prevents shortcode execution (e.g., Hostify-booking function redeclaration)
282 * 3. Performance - lightweight content extraction for meta tags
283 */
284
285 $post_text = '';
286
287 # Get content safely without executing shortcodes
288 $content = get_the_content(null, false, $post);
289
290 # Validate content exists
291 if (!empty($content)) {
292 # Clean content for meta description - multi-layer approach for reliability
293 $content = strip_shortcodes($content); # Remove shortcode tags (e.g., [hostify-booking])
294 $content = wp_strip_all_tags($content); # Remove HTML tags
295 $content = preg_replace('/\s+/', ' ', $content); # Normalize whitespace
296 $post_text = wp_trim_words(trim($content), 30, '');
297 }
298
299 $site_info = Metasync::get_option('optimal_settings')['site_info'] ?? [];
300
301 $facebook_page_url = Metasync::get_option('social_meta')['facebook_page_url'] ?? '';
302 $facebook_authorship = Metasync::get_option('social_meta')['facebook_authorship'] ?? '';
303 $facebook_admin = Metasync::get_option('social_meta')['facebook_admin'] ?? '';
304
305 $twitter_username = Metasync::get_option('social_meta')['twitter_username'] ?? '';
306
307 $image = [];
308 $image_mime_type = '';
309
310 // SAFE IMAGE HANDLING: Prevents timeout when images are deleted from filesystem
311 // Constructs URLs directly from metadata without triggering WordPress HTTP validation
312 if ($post) {
313 $image_id = get_post_thumbnail_id($post->ID);
314
315 if ($image_id) {
316 // Verify attachment exists in database
317 $attachment = get_post($image_id);
318
319 if ($attachment && $attachment->post_type === 'attachment') {
320 // Check if physical file exists before constructing URL
321 $file_path = get_attached_file($image_id);
322
323 if ($file_path && file_exists($file_path)) {
324 // Get metadata to construct URL directly
325 $metadata = wp_get_attachment_metadata($image_id);
326
327 if ($metadata && !empty($metadata['file'])) {
328 $upload_dir = wp_upload_dir();
329 $image_url = $upload_dir['baseurl'] . '/' . $metadata['file'];
330
331 // Get image dimensions from metadata (not from file)
332 $width = $metadata['width'] ?? 0;
333 $height = $metadata['height'] ?? 0;
334
335 // Determine MIME type from file extension (safe, no HTTP calls)
336 $file_ext = strtolower(pathinfo($metadata['file'], PATHINFO_EXTENSION));
337 $mime_types = [
338 'jpg' => 'image/jpeg',
339 'jpeg' => 'image/jpeg',
340 'png' => 'image/png',
341 'gif' => 'image/gif',
342 'webp' => 'image/webp',
343 'svg' => 'image/svg+xml'
344 ];
345 $image_mime_type = $mime_types[$file_ext] ?? 'image/jpeg';
346
347 // Build image array in same format as wp_get_attachment_image_src
348 $image = [$image_url, $width, $height];
349 }
350 } else {
351 // File doesn't exist - clean up orphaned thumbnail reference
352 delete_post_meta($post->ID, '_thumbnail_id');
353 }
354 } else {
355 // Attachment doesn't exist - clean up orphaned reference
356 delete_post_meta($post->ID, '_thumbnail_id');
357 }
358 }
359 }
360
361 // Fallback to site default image if post has no featured image
362 if (empty($image) && $site_info && isset($site_info['social_share_image'])) {
363 $fallback_id = $site_info['social_share_image'];
364 $attachment = get_post($fallback_id);
365
366 if ($attachment && $attachment->post_type === 'attachment') {
367 $file_path = get_attached_file($fallback_id);
368
369 if ($file_path && file_exists($file_path)) {
370 $metadata = wp_get_attachment_metadata($fallback_id);
371
372 if ($metadata && !empty($metadata['file'])) {
373 $upload_dir = wp_upload_dir();
374 $image_url = $upload_dir['baseurl'] . '/' . $metadata['file'];
375 $width = $metadata['width'] ?? 0;
376 $height = $metadata['height'] ?? 0;
377
378 $file_ext = strtolower(pathinfo($metadata['file'], PATHINFO_EXTENSION));
379 $mime_types = [
380 'jpg' => 'image/jpeg',
381 'jpeg' => 'image/jpeg',
382 'png' => 'image/png',
383 'gif' => 'image/gif',
384 'webp' => 'image/webp',
385 'svg' => 'image/svg+xml'
386 ];
387 $image_mime_type = $mime_types[$file_ext] ?? 'image/jpeg';
388
389 $image = [$image_url, $width, $height];
390 }
391 }
392 }
393 }
394
395
396 // When a third-party SEO plugin handles OG/Twitter, omit our description
397 // from those tags to avoid duplicates.
398 $og_description = ($include_description) ? ($post_text ?? '') : '';
399
400 $ogMetaKeys = [
401 'og:locale' => get_locale(),
402 'og:type' => 'article',
403 'og:title' => $post->post_title . ' - ' . get_bloginfo('name'),
404 'og:description' => $og_description,
405 'og:url' => $this->get_canonical_url($post),
406 'og:site_name' => get_bloginfo('name'),
407 'og:updated_time' => $post->post_modified,
408 'og:image' => $image ? $image[0] : '',
409 'og:image:width' => $image ? $image[1] : '',
410 'og:image:height' => $image ? $image[2] : '',
411 'og:image:type' => $image ? $image_mime_type : '',
412 'og:image:alt' => $image ? $post->post_title : '',
413 ];
414
415 $facebookMetaKeys = [
416 'article:publisher' => $facebook_page_url && !filter_var($facebook_page_url, FILTER_VALIDATE_URL) ? 'https://' . $facebook_page_url : $facebook_page_url,
417 'article:author' => $facebook_authorship && !filter_var($facebook_authorship, FILTER_VALIDATE_URL) ? 'https://' . $facebook_authorship : $facebook_authorship,
418 'fb:admins' => $facebook_admin,
419 ];
420
421 $twitter_card_type = Metasync::get_option('twitter_card_type') ?? [];
422
423 $twitterMetaKeys = [
424 'twitter:card' => $twitter_card_type ? $twitter_card_type : 'summary_large_image',
425 'twitter:title' => $post->post_title . ' - ' . get_bloginfo('name'),
426 'twitter:site' => $twitter_username ? '@' . $twitter_username : '',
427 'twitter:creator' => $twitter_username ? '@' . $twitter_username : '',
428 'twitter:description' => $og_description,
429 'twitter:image' => $image ? $image[0] : '',
430 ];
431
432 // echo "\t<!-- MetaSync metadata -->\n";
433
434 foreach ($list_page_meta as $item => $value) {
435 if ($item == 'canonical') {
436 $this->print_metatag($item, $value, 'href', 'rel', 'link');
437 continue;
438 }
439 $this->print_metatag($item, $value);
440 }
441
442 if ($getSearchEngineOptions !== null) { // check if searchengine verification options are set
443 foreach ($keysSearchEngines as $optionKey => $metaKey) {
444 $this->print_metatag($metaKey, $getSearchEngineOptions[$optionKey]);
445 }
446 }
447
448 if ($post) {
449
450 // When OTTO is active AND has OG/Twitter data for this post, skip
451 // legacy output. For dynamic pixel injection (where specific OTTO OG
452 // meta is empty), the buffer-level dedup in
453 // Otto_html_class::deduplicate_og_twitter_tags() handles cleanup.
454 $otto_has_og = false;
455 $otto_has_twitter = false;
456 if (class_exists('Metasync_Otto_Config') && Metasync_Otto_Config::is_otto_enabled()) {
457 $otto_has_og = !empty(get_post_meta($post->ID, '_metasync_otto_og_title', true))
458 || !empty(get_post_meta($post->ID, '_metasync_otto_og_description', true));
459 $otto_has_twitter = !empty(get_post_meta($post->ID, '_metasync_otto_twitter_title', true))
460 || !empty(get_post_meta($post->ID, '_metasync_otto_twitter_description', true));
461 }
462
463 // When the canonical emitter (Metasync_OpenGraph::output_opengraph_tags, wp_head
464 // priority 5) will emit for this post, suppress these legacy og:*/twitter:* blocks
465 // so a MetaSync-only page outputs exactly one OG set and one Twitter set, preferring
466 // the persisted/OTTO values that emitter resolves (WP-411). The Facebook block
467 // (article:publisher / article:author / fb:admins) is NOT gated on it — the canonical
468 // emitter never outputs those tags, so they never duplicate.
469 $og2_will_emit = apply_filters('metasync_opengraph_will_emit', false);
470
471 // When a third-party SEO plugin (Yoast / Rank Math / AIOSEO) is active it owns the
472 // social tags — suppress ALL legacy Facebook/OG/Twitter output so MetaSync never
473 // duplicates the other plugin's og:*/twitter:*/article:* tags (WP-411).
474 $has_seo_plugin = $conflict_handler->has_active_seo_plugin();
475
476 $common_meta_settings = Metasync::get_option('common_meta_settings') ?? [];
477
478 if (!$otto_has_og && !$has_seo_plugin && isset($common_meta_settings['facebook_meta_tags'])) {
479 foreach ($facebookMetaKeys as $metaKey => $metaValue) {
480 $this->print_metatag($metaKey, $metaValue, 'content', 'property');
481 }
482 }
483
484 if (!$otto_has_og && !$og2_will_emit && !$has_seo_plugin && isset($common_meta_settings['open_graph_meta_tags'])) {
485 foreach ($ogMetaKeys as $metaKey => $metaValue) {
486 $this->print_metatag($metaKey, $metaValue, 'content', 'property');
487 }
488 }
489 if (!$otto_has_twitter && !$og2_will_emit && !$has_seo_plugin && isset($common_meta_settings['twitter_meta_tags'])) {
490 foreach ($twitterMetaKeys as $metaKey => $metaValue) {
491 $this->print_metatag($metaKey, $metaValue, 'content', 'name');
492 }
493 }
494 }
495 }
496
497 public function add_ld_json()
498 {
499 $post = get_post(get_the_ID());
500 if (empty($post))
501 return;
502
503 $site_info = Metasync::get_option('optimal_settings')['site_info'] ?? '';
504
505 $site_logo_id = $site_info['google_logo'] ?? '';
506 $custom_logo_id = get_theme_mod('custom_logo');
507 $logo_id = $custom_logo_id != '' ? $custom_logo_id : $site_logo_id;
508
509 $site_image_id = $site_info['social_share_image'] ?? '';
510 $post_thumbnail_id = get_post_thumbnail_id($post->ID);
511 $thumbnail_id = $post_thumbnail_id > 0 ? $post_thumbnail_id : $site_image_id;
512
513 $schema = array(
514 '@context' => "http://schema.org",
515 '@type' => "Article",
516 'headline' => str_replace($this->escapers, $this->replacements, $post->post_title ?? ''),
517 'image' => wp_get_attachment_image_url($thumbnail_id, 'full'),
518 'url' => get_permalink(),
519 'datePublished' => $post->post_modified,
520 'author' => array(
521 '@type' => "Person",
522 'name' => get_the_author_meta('display_name', $post->post_author),
523 'url' => get_author_posts_url($post->post_author),
524 ),
525 'publisher' => array(
526 '@type' => "Organization",
527 'name' => str_replace($this->escapers, $this->replacements, get_bloginfo('name') ?? ''),
528 'url' => get_site_url(),
529 'logo' => array(
530 '@type' => "ImageObject",
531 'url' => wp_get_attachment_image_url($logo_id, 'full'),
532 )
533 )
534 );
535
536 return $schema;
537 }
538
539 /*
540 * This will hide the title on single posts and pages
541 * if they were created with the "metasync" system.
542 * Passes default values for $title and $id to avoid errors.
543 */
544 public function hide_title_on_otto_pages($title = '', $id = null){
545
546 # Return title immediately if $id or $title is not provided
547 if (empty($id) || empty($title)) {
548 return $title;
549 }
550
551 # Check if it's a single post or page
552 if ((is_single() || is_page()) && in_the_loop() && is_main_query()) {
553
554 # Check if the post was created with the "metasync" system
555 $metasync_post = get_post_meta($id, 'metasync_post', true);
556 if ($metasync_post === 'yes') {
557 return '';
558 }
559 }
560 return $title;
561 }
562
563 /**
564 * Resolve the robots meta value from all storage formats.
565 *
566 * Priority order:
567 * 1. _metasync_robots_advanced (WP-197 JSON)
568 * 2. meta_robots (string, e.g. "noindex, nofollow") — set via REST API
569 * 3. metasync_common_robots (array) + metasync_advance_robots (array) — admin checkboxes
570 * 4. Global defaults from metasync_options['advance_robots_meta']
571 *
572 * The noindex directive is sourced from _metasync_robots_index (separate key)
573 * and merged into the final string regardless of which storage format is used.
574 *
575 * @param array $all_meta Result of get_post_meta($id) (all meta as arrays).
576 * @return string Robots directive string (e.g. "nofollow, noarchive, max-snippet:-1") or empty.
577 */
578 private function resolve_robots_value($all_meta) {
579 $directives = [];
580
581 // Merge noindex from _metasync_robots_index (separate key, not part of advanced JSON)
582 $robots_index = $all_meta['_metasync_robots_index'][0] ?? '';
583 if ($robots_index === 'noindex') {
584 $directives[] = 'noindex';
585 }
586
587 // 1. Check _metasync_robots_advanced (WP-197 JSON)
588 $advanced_raw = $all_meta['_metasync_robots_advanced'][0] ?? '';
589 if (!empty($advanced_raw)) {
590 $advanced = json_decode($advanced_raw, true);
591 if (is_array($advanced) && !empty($advanced)) {
592 // Boolean directives
593 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
594 if (!empty($advanced[$dir])) {
595 $directives[] = $dir;
596 }
597 }
598 // max-* directives
599 if (isset($advanced['max_snippet'])) {
600 $directives[] = 'max-snippet:' . (int) $advanced['max_snippet'];
601 }
602 if (isset($advanced['max_image_preview'])) {
603 $directives[] = 'max-image-preview:' . esc_attr($advanced['max_image_preview']);
604 }
605 if (isset($advanced['max_video_preview'])) {
606 $directives[] = 'max-video-preview:' . (int) $advanced['max_video_preview'];
607 }
608 return !empty($directives) ? implode(', ', $directives) : '';
609 }
610 }
611
612 // 2. Check meta_robots (REST API / direct string)
613 $meta_robots = $all_meta['meta_robots'][0] ?? '';
614 if (!empty($meta_robots)) {
615 // Prepend noindex from _metasync_robots_index if not already in the string
616 if (!empty($directives) && stripos($meta_robots, 'noindex') === false) {
617 return implode(', ', $directives) . ', ' . $meta_robots;
618 }
619 return $meta_robots;
620 }
621
622 // 3. Check metasync_common_robots (admin checkbox array)
623 $raw = $all_meta['metasync_common_robots'][0] ?? '';
624 if (!empty($raw)) {
625 $common = maybe_unserialize($raw);
626 if (is_array($common) && !empty($common)) {
627 $common_directives = array_values(array_filter($common, function ($v) {
628 return !empty($v);
629 }));
630 $directives = array_merge($directives, $common_directives);
631 }
632 }
633
634 // Also check metasync_advance_robots (admin per-post advanced checkboxes)
635 // Structure: ['max-snippet' => ['enable' => '1', 'length' => 100], ...]
636 $adv_raw = $all_meta['metasync_advance_robots'][0] ?? '';
637 if (!empty($adv_raw)) {
638 $adv = maybe_unserialize($adv_raw);
639 if (is_array($adv)) {
640 if (!empty($adv['max-snippet']['enable'])) {
641 $length = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
642 $directives[] = 'max-snippet:' . $length;
643 }
644 if (!empty($adv['max-video-preview']['enable'])) {
645 $length = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
646 $directives[] = 'max-video-preview:' . $length;
647 }
648 if (!empty($adv['max-image-preview']['enable'])) {
649 $length = isset($adv['max-image-preview']['length']) ? sanitize_text_field($adv['max-image-preview']['length']) : 'large';
650 $directives[] = 'max-image-preview:' . $length;
651 }
652 }
653 }
654
655 if (!empty($directives)) {
656 return implode(', ', array_unique($directives));
657 }
658
659 // 4. Global defaults from metasync_options['advance_robots_meta']
660 $global_options = Metasync::get_option('advance_robots_meta');
661 if (empty($global_options)) {
662 $global_options = Metasync::get_option('advance_robots_mata');
663 }
664 if (is_array($global_options)) {
665 if (!empty($global_options['max-snippet']['enable'])) {
666 $length = isset($global_options['max-snippet']['length']) ? (int) $global_options['max-snippet']['length'] : -1;
667 $directives[] = 'max-snippet:' . $length;
668 }
669 if (!empty($global_options['max-video-preview']['enable'])) {
670 $length = isset($global_options['max-video-preview']['length']) ? (int) $global_options['max-video-preview']['length'] : -1;
671 $directives[] = 'max-video-preview:' . $length;
672 }
673 if (!empty($global_options['max-image-preview']['enable'])) {
674 $length = isset($global_options['max-image-preview']['length']) ? sanitize_text_field($global_options['max-image-preview']['length']) : 'large';
675 $directives[] = 'max-image-preview:' . $length;
676 }
677 }
678
679 // AC-1: max-image-preview:large outputs by default even with no configuration.
680 // If no per-post or global setting included max-image-preview, apply the default.
681 $has_max_image = false;
682 foreach ($directives as $d) {
683 if (strpos($d, 'max-image-preview') !== false) {
684 $has_max_image = true;
685 break;
686 }
687 }
688 if (!$has_max_image) {
689 $directives[] = 'max-image-preview:large';
690 }
691
692 return !empty($directives) ? implode(', ', array_unique($directives)) : '';
693 }
694
695 /**
696 * Get the correct canonical URL for archive pages.
697 *
698 * Handles: Posts page (is_home), category, tag, custom taxonomy,
699 * author, year/month/day date archives.
700 *
701 * @return string Archive URL or empty string if not determinable.
702 */
703 private function get_archive_canonical_url() {
704 if (is_home()) {
705 $blog_page_id = (int) get_option('page_for_posts');
706 if ($blog_page_id > 0) {
707 // Honour a manual MetaSync canonical override on the Posts page.
708 $override = get_post_meta($blog_page_id, '_metasync_canonical_url', true);
709 if (!empty($override)) {
710 return esc_url($override);
711 }
712 return get_permalink($blog_page_id);
713 }
714 return home_url('/');
715 }
716
717 if (is_category() || is_tag() || is_tax()) {
718 $term = get_queried_object();
719 if ($term instanceof WP_Term) {
720 $link = get_term_link($term);
721 return is_wp_error($link) ? '' : $link;
722 }
723 return '';
724 }
725
726 if (is_author()) {
727 return get_author_posts_url(get_queried_object_id());
728 }
729
730 if (is_day()) {
731 return get_day_link(get_query_var('year'), get_query_var('monthnum'), get_query_var('day'));
732 }
733
734 if (is_month()) {
735 return get_month_link(get_query_var('year'), get_query_var('monthnum'));
736 }
737
738 if (is_year()) {
739 return get_year_link(get_query_var('year'));
740 }
741
742 return '';
743 }
744
745 /**
746 * Get the canonical URL for a post
747 */
748 private function get_canonical_url($post) {
749 # Check for persisted OTTO canonical URL first
750 $metasync_canonical = get_post_meta($post->ID, '_metasync_canonical_url', true);
751 if (!empty($metasync_canonical)) {
752 return esc_url($metasync_canonical);
753 }
754
755 # Try to get the permalink using WordPress function
756 $permalink = get_permalink($post->ID);
757
758 # If permalink is not available or is the default query URL, try alternative methods
759 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
760 # Force WordPress to generate the proper permalink by temporarily setting post status
761 $original_status = $post->post_status;
762 if ($post->post_status === 'auto-draft') {
763 $post->post_status = 'publish';
764 }
765
766 # Try get_permalink again with the updated status
767 $permalink = get_permalink($post->ID);
768
769 # Restore original status
770 $post->post_status = $original_status;
771 }
772
773 # If still not working, use WordPress core functions to build proper permalink
774 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
775 # Use WordPress core function that respects permalink structure
776 # This properly handles custom structures, hierarchies, and post types
777 # Load admin function if not already available, Without this it is causing error on post the preview page
778 if (!function_exists('get_sample_permalink')) {
779 require_once ABSPATH . 'wp-admin/includes/post.php';
780 }
781 $permalink = get_sample_permalink($post->ID);
782
783 if (is_array($permalink)) {
784 # get_sample_permalink returns array with template and slug
785 # Replace %postname% or %pagename% with actual slug
786 $permalink = str_replace(
787 array('%pagename%', '%postname%'),
788 $post->post_name,
789 $permalink[0]
790 );
791 }
792
793 # Final fallback: if still problematic, construct URL respecting post type structure
794 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
795 if (!empty($post->post_name)) {
796 # For pages, check if there's a parent hierarchy
797 if ($post->post_type === 'page' && $post->post_parent) {
798 # Get parent page path for proper hierarchy
799 $parent = get_post($post->post_parent);
800 $parent_path = '';
801
802 # Build full path including all parent pages
803 while ($parent) {
804 $parent_path = $parent->post_name . '/' . $parent_path;
805 $parent = $parent->post_parent ? get_post($parent->post_parent) : null;
806 }
807
808 $permalink = home_url('/' . $parent_path . $post->post_name . '/');
809 } else {
810 # For posts and pages without parents, use post type archive base
811 $post_type_obj = get_post_type_object($post->post_type);
812 $slug = $post_type_obj->rewrite['slug'] ?? '';
813
814 if ($slug && $post->post_type !== 'page') {
815 $permalink = home_url('/' . $slug . '/' . $post->post_name . '/');
816 } else {
817 $permalink = home_url('/' . $post->post_name . '/');
818 }
819 }
820 } else {
821 # Fallback to post ID format if no slug available
822 $permalink = home_url('/?p=' . $post->ID);
823 }
824 }
825 }
826
827 return $permalink;
828 }
829
830 /**
831 * Set up indexation controls for archive pages
832 *
833 * Called via template_redirect hook to set up early before any output.
834 * This ensures we can capture and clean robots tags from other plugins.
835 *
836 * Logic:
837 * 1. If user wants to add noindex - always do it (override other plugins)
838 * 2. If user wants to allow indexing - only remove other plugins' tags if override setting is enabled
839 *
840 * @since 1.0.0
841 */
842 public function inject_archive_seo_controls() {
843 // Check if we're on a managed archive type
844 if (!$this->is_managed_archive()) {
845 return;
846 }
847
848 // Get settings
849 $seo_controls = Metasync::get_option('seo_controls', array());
850 $should_noindex = $this->should_noindex_archive();
851 $override_enabled = ($seo_controls['override_robots_tags'] ?? 'false') === 'true' || ($seo_controls['override_robots_tags'] ?? false) === true;
852
853 // Run buffer if either:
854 // 1. User wants to add noindex (always override other plugins), OR
855 // 2. User wants to allow indexing AND override setting is enabled
856 if ($should_noindex || $override_enabled) {
857 // Override WordPress core robots tag
858 add_filter('wp_robots', array($this, 'override_wp_robots'), 999);
859
860 // Start buffering to remove other plugins' robots tags
861 add_action('wp_head', array($this, 'start_robots_buffer'), 0);
862 add_action('wp_head', array($this, 'end_robots_buffer'), PHP_INT_MAX);
863 }
864 // Otherwise: User wants to allow indexing AND override is disabled - don't interfere
865 }
866
867 /**
868 * Check if current page is a managed archive type
869 *
870 * Returns true if we're on any archive type that has indexation controls,
871 * regardless of whether noindex is enabled or not.
872 *
873 * @since 1.0.0
874 * @return bool True if on a managed archive type, false otherwise
875 */
876 private function is_managed_archive() {
877 return is_date() || is_tag() || is_author() || is_category() || is_tax('post_format');
878 }
879
880 /**
881 * Check if current archive is empty (has no posts)
882 *
883 * Applies to category, tag, author, and post format archives.
884 * Date archives are excluded as they're typically either indexed
885 * or not entirely (less meaningful to check for empty state).
886 *
887 * @since 1.0.0
888 * @return bool True if archive is empty (0 posts)
889 */
890 private function is_empty_archive() {
891 global $wp_query;
892
893 // Check taxonomy and author archives (excluding date archives)
894 if (!is_category() && !is_tag() && !is_author() && !is_tax('post_format')) {
895 return false;
896 }
897
898 // Check if the archive has no posts
899 return $wp_query->post_count === 0;
900 }
901
902 /**
903 * Check if current archive should be noindexed
904 *
905 * @since 1.0.0
906 * @return bool True if should add noindex, false otherwise
907 */
908 private function should_noindex_archive() {
909 // Get indexation control settings
910 $seo_controls = Metasync::get_option('seo_controls', array());
911
912 // Check empty archives setting first (applies to categories/tags only)
913 $noindex_empty_archives = $seo_controls['noindex_empty_archives'] ?? false;
914 if (($noindex_empty_archives === 'true' || $noindex_empty_archives === true) && $this->is_empty_archive()) {
915 return true;
916 }
917
918 // Date Archives
919 if (is_date()) {
920 $index_date_archives = $seo_controls['index_date_archives'] ?? false;
921 // Handle both string and boolean values for compatibility
922 if ($index_date_archives === 'true' || $index_date_archives === true) {
923 return true;
924 }
925 }
926
927 // Tag Archives
928 elseif (is_tag()) {
929 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
930 // Handle both string and boolean values for compatibility
931 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
932 return true;
933 }
934 }
935
936 // Author Archives
937 elseif (is_author()) {
938 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
939 // Handle both string and boolean values for compatibility
940 if ($index_author_archives === 'true' || $index_author_archives === true) {
941 return true;
942 }
943 }
944
945 // Category Archives
946 elseif (is_category()) {
947 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
948 // Handle both string and boolean values for compatibility
949 if ($index_category_archives === 'true' || $index_category_archives === true) {
950 return true;
951 }
952 }
953
954 // Format Archives (post format taxonomy)
955 elseif (is_tax('post_format')) {
956 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
957 if ($index_format_archives === 'true' || $index_format_archives === true) {
958 return true;
959 }
960 }
961
962 return false;
963 }
964
965 /**
966 * Override WordPress core robots directives
967 *
968 * Handles both cases:
969 * 1. When noindex is enabled: Add noindex directive
970 * 2. When indexing is allowed: Return empty array (removes WP core robots tag)
971 *
972 * @since 1.0.0
973 * @param array $robots Associative array of robots directives
974 * @return array Modified robots directives
975 */
976 public function override_wp_robots($robots) {
977 if ($this->should_noindex_archive()) {
978 // Case 1: Add noindex directive
979 return array(
980 'noindex' => true,
981 'follow' => true,
982 'max-image-preview' => 'large', // Maintain good image preview for social sharing
983 );
984 } else {
985 // Case 2: Allow indexing - return empty array to remove WP core robots tag
986 // Default behavior without robots tag is to index
987 return array();
988 }
989 }
990
991 /**
992 * Start output buffering to capture robots meta tags from other plugins
993 *
994 * @since 1.0.0
995 */
996 public function start_robots_buffer() {
997 ob_start();
998 }
999
1000 /**
1001 * End output buffering, remove existing robots tags, and optionally add ours
1002 *
1003 * This handles BOTH cases:
1004 * 1. When noindex is enabled: Remove other tags and add noindex tag
1005 * 2. When indexing is allowed: Remove other plugins' noindex tags
1006 *
1007 * This ensures our indexation settings always take precedence over other plugins.
1008 *
1009 * @since 1.0.0
1010 */
1011 public function end_robots_buffer() {
1012 // Only proceed if we're on a managed archive
1013 if (!$this->is_managed_archive()) {
1014 ob_end_flush();
1015 return;
1016 }
1017
1018 // Get buffered content
1019 $content = ob_get_clean();
1020
1021 // Remove all existing robots meta tags (handles various formats)
1022 // This is done for BOTH noindex and index cases to ensure clean slate
1023
1024 // Pattern 1: name="robots" content="..."
1025 $content = preg_replace(
1026 '/<meta\s+name=["\']robots["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1027 '',
1028 $content
1029 );
1030
1031 // Pattern 2: content="..." name="robots"
1032 $content = preg_replace(
1033 '/<meta\s+content=["\'][^"\']*["\']\s+name=["\']robots["\']\s*\/?>\s*/i',
1034 '',
1035 $content
1036 );
1037
1038 // Pattern 3: Single quotes or no quotes (rare but possible)
1039 $content = preg_replace(
1040 "/<meta\s+name='robots'\s+content='[^']*'\s*\/?>\s*/i",
1041 '',
1042 $content
1043 );
1044
1045 // Pattern 4: property="robots" (some plugins use property attribute)
1046 $content = preg_replace(
1047 '/<meta\s+property=["\']robots["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1048 '',
1049 $content
1050 );
1051
1052 // Pattern 5: Search engine specific directives (googlebot, bingbot, etc.)
1053 $content = preg_replace(
1054 '/<meta\s+name=["\'](?:googlebot|bingbot|googlebot-news|slurp)["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1055 '',
1056 $content
1057 );
1058
1059 // Output cleaned content
1060 echo $content;
1061
1062 // Add our robots tag or comment based on settings
1063 if ($this->should_noindex_archive()) {
1064 // Case 1: User wants to disallow indexing - add noindex tag
1065 echo '<!-- MetaSync Indexation Control: Noindex Applied (Overriding other plugins) -->' . "\n";
1066 echo '<meta name="robots" content="noindex, follow">' . "\n";
1067 } else {
1068 // Case 2: User wants to allow indexing AND override is enabled
1069 // Just remove other plugins' tags, don't add our own
1070 // Default behavior without robots tag is to allow indexing
1071 echo '<!-- MetaSync Indexation Control: Index Allowed (Override enabled - Other noindex tags removed) -->' . "\n";
1072 }
1073 }
1074
1075 /**
1076 * Filter taxonomy sitemap entries to exclude disabled archive types
1077 *
1078 * @since 1.0.0
1079 * @param array $taxonomies Array of taxonomy objects
1080 * @return array Modified array of taxonomy objects
1081 */
1082 public function filter_sitemap_taxonomies($taxonomies) {
1083 // Get indexation control settings
1084 $seo_controls = Metasync::get_option('seo_controls', array());
1085
1086 // Check if tag archives are disabled
1087 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1088 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1089 // Remove post_tag taxonomy from sitemap
1090 unset($taxonomies['post_tag']);
1091 }
1092
1093 // Check if category archives are disabled
1094 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1095 if ($index_category_archives === 'true' || $index_category_archives === true) {
1096 // Remove category taxonomy from sitemap
1097 unset($taxonomies['category']);
1098 }
1099
1100 // Check if format archives are disabled
1101 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1102 if ($index_format_archives === 'true' || $index_format_archives === true) {
1103 // Remove post_format taxonomy from sitemap
1104 unset($taxonomies['post_format']);
1105 }
1106
1107 return $taxonomies;
1108 }
1109
1110 /**
1111 * Filter user sitemap entries to exclude disabled author archives
1112 *
1113 * @since 1.0.0
1114 * @param array $entry Sitemap entry for user
1115 * @param WP_User $user User object
1116 * @return array|false Modified sitemap entry or false to exclude
1117 */
1118 public function filter_sitemap_users($entry, $user) {
1119 // Get indexation control settings
1120 $seo_controls = Metasync::get_option('seo_controls', array());
1121
1122 // Check if author archives are disabled
1123 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1124 if ($index_author_archives === 'true' || $index_author_archives === true) {
1125 // Exclude this user from sitemap
1126 return false;
1127 }
1128
1129 return $entry;
1130 }
1131
1132 /**
1133 * Filter sitemap providers to exclude disabled archive types
1134 *
1135 * @since 1.0.0
1136 * @param bool $provider Whether to add the provider
1137 * @param string $name Provider name
1138 * @return bool Whether to add the provider
1139 */
1140 public function filter_sitemap_providers($provider, $name) {
1141 // Get indexation control settings
1142 $seo_controls = Metasync::get_option('seo_controls', array());
1143
1144 // Check if users/authors sitemap provider should be disabled
1145 if ($name === 'users') {
1146 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1147 if ($index_author_archives === 'true' || $index_author_archives === true) {
1148 return false; // Exclude users sitemap provider entirely
1149 }
1150 }
1151
1152 return $provider;
1153 }
1154
1155 /**
1156 * Filter sitemap index entries to exclude disabled archive types
1157 *
1158 * @since 1.0.0
1159 * @param array $sitemap_entry Sitemap entry array
1160 * @param string $object_type Object type (posts, taxonomies, users)
1161 * @param string $subtype Subtype (post type or taxonomy name)
1162 * @param int $page Page number
1163 * @return array|false Modified sitemap entry or false to exclude
1164 */
1165 public function filter_sitemap_index_entries($sitemap_entry, $object_type, $subtype, $page) {
1166 // Get indexation control settings
1167 $seo_controls = Metasync::get_option('seo_controls', array());
1168
1169 // Handle taxonomy sitemaps
1170 if ($object_type === 'taxonomies') {
1171 // Check if tag archives are disabled
1172 if ($subtype === 'post_tag') {
1173 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1174 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1175 return false; // Exclude from sitemap index
1176 }
1177 }
1178
1179 // Check if category archives are disabled
1180 if ($subtype === 'category') {
1181 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1182 if ($index_category_archives === 'true' || $index_category_archives === true) {
1183 return false; // Exclude from sitemap index
1184 }
1185 }
1186
1187 // Check if format archives are disabled
1188 if ($subtype === 'post_format') {
1189 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1190 if ($index_format_archives === 'true' || $index_format_archives === true) {
1191 return false; // Exclude from sitemap index
1192 }
1193 }
1194 }
1195
1196 // Handle user/author sitemaps
1197 if ($object_type === 'users') {
1198 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1199 if ($index_author_archives === 'true' || $index_author_archives === true) {
1200 return false; // Exclude from sitemap index
1201 }
1202 }
1203
1204 return $sitemap_entry;
1205 }
1206 }
1207