PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / public / class-metasync-seo-output.php

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

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