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

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