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

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