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

1,772 lines 61.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 $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') {
178 return true;
179 }
180
181 // Check for other common AMP query parameters
182 if (isset($_GET['amp']) && !empty($_GET['amp'])) {
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
484 $common_meta_settings = Metasync::get_option('common_meta_settings') ?? [];
485
486 if ($social_enabled && !$otto_has_og && !$has_seo_plugin && isset($common_meta_settings['facebook_meta_tags'])) {
487 foreach ($facebookMetaKeys as $metaKey => $metaValue) {
488 $this->print_metatag($metaKey, $metaValue, 'content', 'property');
489 }
490 }
491
492 if ($social_enabled && !$otto_has_og && !$og2_will_emit && !$has_seo_plugin && isset($common_meta_settings['open_graph_meta_tags'])) {
493 foreach ($ogMetaKeys as $metaKey => $metaValue) {
494 $this->print_metatag($metaKey, $metaValue, 'content', 'property');
495 }
496 }
497 if ($social_enabled && !$otto_has_twitter && !$og2_will_emit && !$has_seo_plugin && isset($common_meta_settings['twitter_meta_tags'])) {
498 foreach ($twitterMetaKeys as $metaKey => $metaValue) {
499 $this->print_metatag($metaKey, $metaValue, 'content', 'name');
500 }
501 }
502 }
503 }
504
505 /**
506 * Build Open Graph image data for an attachment using database metadata only.
507 *
508 * Intentionally avoids `file_exists()`/disk access and any HTTP validation so
509 * it works for media-offload setups (WP Stateless, WP Offload Media / S3) where
510 * the file lives in remote object storage rather than on the local filesystem.
511 * It is strictly read-only and never mutates post or attachment meta.
512 *
513 * The image URL is resolved via `wp_get_attachment_url()`, which offload plugins
514 * filter to the correct remote URL; it falls back to building the URL from the
515 * upload directory and stored metadata when needed.
516 *
517 * @param int|string $attachment_id Attachment ID.
518 * @return array|null ['url' => string, 'width' => int, 'height' => int, 'mime' => string] or null.
519 */
520 private function build_og_image_from_attachment($attachment_id)
521 {
522 $attachment_id = (int) $attachment_id;
523 if ($attachment_id <= 0) {
524 return null;
525 }
526
527 $attachment = get_post($attachment_id);
528 if (!$attachment || $attachment->post_type !== 'attachment') {
529 return null;
530 }
531
532 // Read dimensions from stored metadata — no disk or network access.
533 $metadata = wp_get_attachment_metadata($attachment_id);
534 $width = (is_array($metadata) && !empty($metadata['width'])) ? (int) $metadata['width'] : 0;
535 $height = (is_array($metadata) && !empty($metadata['height'])) ? (int) $metadata['height'] : 0;
536
537 // Prefer wp_get_attachment_url() so offload plugins (WP Stateless / S3)
538 // return the correct remote URL; fall back to upload dir + metadata file.
539 $image_url = wp_get_attachment_url($attachment_id);
540 if (!$image_url && is_array($metadata) && !empty($metadata['file'])) {
541 $upload_dir = wp_upload_dir();
542 if (empty($upload_dir['error'])) {
543 $image_url = trailingslashit($upload_dir['baseurl']) . ltrim($metadata['file'], '/');
544 }
545 }
546
547 if (!$image_url) {
548 return null;
549 }
550
551 // Determine MIME type from the file extension (safe, no HTTP calls).
552 $mime_types = [
553 'jpg' => 'image/jpeg',
554 'jpeg' => 'image/jpeg',
555 'png' => 'image/png',
556 'gif' => 'image/gif',
557 'webp' => 'image/webp',
558 'svg' => 'image/svg+xml',
559 ];
560 $file_ext = strtolower(pathinfo(parse_url($image_url, PHP_URL_PATH) ?: $image_url, PATHINFO_EXTENSION));
561 $image_mime_type = $mime_types[$file_ext] ?? 'image/jpeg';
562
563 return [
564 'url' => $image_url,
565 'width' => $width,
566 'height' => $height,
567 'mime' => $image_mime_type,
568 ];
569 }
570
571 public function add_ld_json()
572 {
573 $post = get_post(get_the_ID());
574 if (empty($post))
575 return;
576
577 $site_info = Metasync::get_option('optimal_settings')['site_info'] ?? '';
578
579 $site_logo_id = $site_info['google_logo'] ?? '';
580 $custom_logo_id = get_theme_mod('custom_logo');
581 $logo_id = $custom_logo_id != '' ? $custom_logo_id : $site_logo_id;
582
583 $site_image_id = $site_info['social_share_image'] ?? '';
584 $post_thumbnail_id = get_post_thumbnail_id($post->ID);
585 $thumbnail_id = $post_thumbnail_id > 0 ? $post_thumbnail_id : $site_image_id;
586
587 $schema = array(
588 '@context' => "http://schema.org",
589 '@type' => "Article",
590 'headline' => $post->post_title ?? '',
591 'image' => wp_get_attachment_image_url($thumbnail_id, 'full'),
592 'url' => get_permalink(),
593 'datePublished' => $post->post_modified,
594 'author' => array(
595 '@type' => "Person",
596 'name' => get_the_author_meta('display_name', $post->post_author),
597 'url' => get_author_posts_url($post->post_author),
598 ),
599 'publisher' => array(
600 '@type' => "Organization",
601 'name' => get_bloginfo('name') ?? '',
602 'url' => get_site_url(),
603 'logo' => array(
604 '@type' => "ImageObject",
605 'url' => wp_get_attachment_image_url($logo_id, 'full'),
606 )
607 )
608 );
609
610 return $schema;
611 }
612
613 /**
614 * Output the LocalBusiness / Organization / Person JSON-LD built from the
615 * Local Business settings page (metasync_options['localseo']).
616 *
617 * Hooked to wp_head so the saved Local Business data appears in the
618 * front-end page source as valid schema.org structured data. Emits nothing
619 * on admin screens, feeds, or when the Local Business page is unconfigured.
620 */
621 public function output_local_business_schema()
622 {
623 if (is_admin() || is_feed()) {
624 return;
625 }
626
627 // The Schema Markup switch covers every JSON-LD block MetaSync owns,
628 // site-wide Local SEO markup included — not just the per-post editor box.
629 if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SCHEMA)) {
630 return;
631 }
632
633 $schema = $this->build_local_business_schema();
634 if (empty($schema)) {
635 return;
636 }
637
638 $json = metasync_safe_json_ld_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
639 if (empty($json)) {
640 return;
641 }
642
643 echo "\t<script type=\"application/ld+json\">" . $json . "</script>\n";
644 }
645
646 /**
647 * Build the LocalBusiness structured-data entity from the saved localseo
648 * option. Returns null when the page has never been configured (no Person/
649 * Organization type selected), so unconfigured sites emit no empty schema.
650 *
651 * @param array|null $options Saved localseo options; when null they are
652 * read from metasync_options['localseo'].
653 * @return array|null JSON-LD entity, or null when nothing is configured.
654 */
655 public function build_local_business_schema($options = null)
656 {
657 if ($options === null) {
658 $options = Metasync::get_option('localseo');
659 }
660 if (empty($options) || !is_array($options)) {
661 return null;
662 }
663
664 $type = $options['local_seo_person_organization'] ?? '';
665 if ($type !== 'Organization' && $type !== 'Person') {
666 return null;
667 }
668
669 $url = !empty($options['local_seo_url']) ? $options['local_seo_url'] : get_home_url();
670
671 $entity = array(
672 '@context' => 'https://schema.org',
673 '@type' => '',
674 'name' => '',
675 'url' => $url,
676 );
677
678 if (!empty($options['local_seo_email'])) {
679 $entity['email'] = $options['local_seo_email'];
680 }
681
682 if ($type === 'Person') {
683 $entity = $this->local_business_person($entity, $options);
684 } else {
685 $entity = $this->local_business_organization($entity, $options);
686 }
687
688 return $entity;
689 }
690
691 /**
692 * Populate an Organization / LocalBusiness entity.
693 *
694 * @param array $entity Base JSON-LD entity.
695 * @param array $options Saved localseo options.
696 * @return array
697 */
698 private function local_business_organization($entity, $options)
699 {
700 $name = (string) ($options['local_seo_name'] ?? '');
701 $entity['name'] = $name !== '' ? $name : (string) get_bloginfo('name');
702
703 // The Business Type field stores a human-readable label (e.g. "Local
704 // Business", "Art Gallery"). schema.org @type tokens have no spaces, so
705 // collapse them ("Local Business" -> "LocalBusiness") to stay valid.
706 // A truthy value can never be the string '0', which is itself falsy, so
707 // the truthiness check alone already covers the "0" case.
708 $business_type = $options['local_seo_business_type'] ?? '';
709 $schema_type = $business_type
710 ? str_replace(' ', '', $business_type)
711 : 'Organization';
712 $entity['@type'] = $schema_type;
713
714 if (!empty($options['local_seo_logo'])) {
715 $logo_url = $this->resolve_local_seo_logo_url($options['local_seo_logo'], 'full');
716 if ($logo_url) {
717 $entity['logo'] = $logo_url;
718 }
719 }
720
721 if (!empty($options['local_seo_price_range'])) {
722 $entity['priceRange'] = $options['local_seo_price_range'];
723 }
724
725 $this->local_business_add_geo($entity, $options);
726 $this->local_business_add_address($entity, $options);
727 $this->local_business_add_contact_points($entity, $options);
728 $this->local_business_add_hours($entity, $options);
729
730 return $entity;
731 }
732
733 /**
734 * Populate a Person entity.
735 *
736 * @param array $entity Base JSON-LD entity.
737 * @param array $options Saved localseo options.
738 * @return array
739 */
740 private function local_business_person($entity, $options)
741 {
742 $name = (string) ($options['local_seo_name'] ?? '');
743 $entity['@type'] = 'Person';
744 $entity['name'] = $name !== '' ? $name : (string) get_bloginfo('name');
745
746 if (!empty($options['local_seo_phone'])) {
747 $entity['telephone'] = $options['local_seo_phone'];
748 }
749
750 if (!empty($options['local_seo_logo'])) {
751 $logo_url = $this->resolve_local_seo_logo_url($options['local_seo_logo'], 'full');
752 if ($logo_url) {
753 $entity['image'] = $logo_url;
754 }
755 }
756
757 $this->local_business_add_geo($entity, $options);
758 $this->local_business_add_address($entity, $options);
759
760 return $entity;
761 }
762
763 /**
764 * Resolve the stored Local Business logo to a publishable image URL.
765 *
766 * The setting holds either a media-library attachment ID or a raw URL.
767 * Rows saved by the legacy sanitizer hold the corrupted form of an
768 * ID ("http://45589"), which is neither numeric nor a resolvable URL, and
769 * would otherwise be published verbatim as broken structured data. Repair
770 * that shape first, then resolve the ID or trust the URL as stored.
771 *
772 * @param mixed $logo Stored logo value.
773 * @param string $size Registered image size to request for an attachment.
774 * @return string|false Resolved image URL, or false when there is none.
775 */
776 private function resolve_local_seo_logo_url($logo, $size = 'full')
777 {
778 $logo = metasync_repair_scheme_prefixed_media_id($logo);
779
780 if (is_numeric($logo)) {
781 $url = wp_get_attachment_image_url($logo, $size);
782 return $url ? $url : false;
783 }
784
785 return $logo ? $logo : false;
786 }
787
788 /**
789 * Add a PostalAddress from the saved address sub-array.
790 */
791 private function local_business_add_address(&$entity, $options)
792 {
793 $address = $options['address'] ?? array();
794 if (empty($address) || !is_array($address)) {
795 return;
796 }
797
798 $street = $address['street'] ?? '';
799 $locality = $address['locality'] ?? '';
800 $region = $address['region'] ?? '';
801 $postal = $address['postalcode'] ?? '';
802 $country = $address['country'] ?? '';
803
804 // Skip entirely if every address field is empty.
805 if ($street === '' && $locality === '' && $region === '' && $postal === '' && $country === '') {
806 return;
807 }
808
809 $entity['address'] = array(
810 '@type' => 'PostalAddress',
811 'streetAddress' => $street,
812 'addressLocality' => $locality,
813 'addressRegion' => $region,
814 'postalCode' => $postal,
815 'addressCountry' => $country,
816 );
817 }
818
819 /**
820 * Add GeoCoordinates + hasMap from "lat,lng" saved coordinates.
821 */
822 private function local_business_add_geo(&$entity, $options)
823 {
824 $coords = $options['local_seo_geo_coordinates'] ?? '';
825 if (empty($coords)) {
826 return;
827 }
828
829 // explode() always yields at least one element, so only the second index
830 // needs an existence check (a value with no comma has no longitude).
831 $geo = array_map('trim', explode(',', $coords));
832 if (!isset($geo[1]) || $geo[0] === '' || $geo[1] === '') {
833 return;
834 }
835
836 $entity['geo'] = array(
837 '@type' => 'GeoCoordinates',
838 'latitude' => $geo[0],
839 'longitude' => $geo[1],
840 );
841 $entity['hasMap'] = 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode($geo[0] . ',' . $geo[1]);
842 }
843
844 /**
845 * Add ContactPoint entries from the parallel phonetype[] / phonenumber[] arrays.
846 */
847 private function local_business_add_contact_points(&$entity, $options)
848 {
849 $types = $options['phonetype'] ?? '';
850 $numbers = $options['phonenumber'] ?? '';
851 if (!is_array($types) || !is_array($numbers) || empty($types) || empty($numbers)) {
852 return;
853 }
854
855 $contacts = array();
856 foreach ($types as $i => $contact_type) {
857 $number = $numbers[$i] ?? '';
858 if ($contact_type === '' || $number === '') {
859 continue;
860 }
861 $contacts[] = array(
862 '@type' => 'ContactPoint',
863 'telephone' => $number,
864 'contactType' => $contact_type,
865 );
866 }
867
868 if (!empty($contacts)) {
869 $entity['contactPoint'] = $contacts;
870 }
871 }
872
873 /**
874 * Add openingHours from the parallel days[] / times[] arrays.
875 */
876 private function local_business_add_hours(&$entity, $options)
877 {
878 $days = $options['days'] ?? '';
879 $times = $options['times'] ?? '';
880 if (!is_array($days) || !is_array($times) || empty($days) || empty($times)) {
881 return;
882 }
883
884 $hours = array();
885 foreach ($days as $i => $day) {
886 $time = $times[$i] ?? '';
887 if ($day === '' || $time === '') {
888 continue;
889 }
890 $hours[] = $day . ' ' . $time;
891 }
892
893 if (!empty($hours)) {
894 $entity['openingHours'] = $hours;
895 }
896 }
897
898 /*
899 * This will hide the title on single posts and pages
900 * if they were created with the "metasync" system.
901 * Passes default values for $title and $id to avoid errors.
902 */
903 public function hide_title_on_otto_pages($title = '', $id = null){
904
905 # Return title immediately if $id or $title is not provided
906 if (empty($id) || empty($title)) {
907 return $title;
908 }
909
910 # Check if it's a single post or page
911 if ((is_single() || is_page()) && in_the_loop() && is_main_query()) {
912
913 # Check if the post was created with the "metasync" system
914 $metasync_post = get_post_meta($id, 'metasync_post', true);
915 if ($metasync_post === 'yes') {
916 return '';
917 }
918 }
919 return $title;
920 }
921
922 /**
923 * Drop serving/appearance directives when the page is set to noindex.
924 *
925 * max-snippet, max-image-preview, max-video-preview, nosnippet, noarchive
926 * and noimageindex only affect how an indexed page is displayed, so they
927 * are meaningless once the page is excluded from the index. When noindex is
928 * present we keep only the indexing/crawling directives (noindex, nofollow)
929 * so the emitted tag stays clean, e.g. "noindex, nofollow" rather than
930 * "noindex, nofollow, noarchive, nosnippet, max-snippet:100, ...".
931 *
932 * @param string[] $directives
933 * @return string[]
934 */
935 private function strip_serving_directives_if_noindex($directives) {
936 $has_noindex = false;
937 foreach ($directives as $d) {
938 if (strcasecmp((string) $d, 'noindex') === 0) {
939 $has_noindex = true;
940 break;
941 }
942 }
943 if (!$has_noindex) {
944 return $directives;
945 }
946 return array_values(array_filter($directives, function ($d) {
947 $d = strtolower((string) $d);
948 return $d === 'noindex' || $d === 'nofollow';
949 }));
950 }
951
952 /**
953 * Resolve the robots meta value, honouring the two robots feature switches.
954 *
955 * Common and advanced directives share one `<meta name="robots">` tag, and
956 * they can arrive together from any single storage format — a REST
957 * `meta_robots` string may hold "noindex, max-snippet:50". So rather than
958 * gate each storage branch, the full value is resolved first and then the
959 * directives belonging to a disabled half are dropped. That way every
960 * storage path is covered, including the ones that return early.
961 *
962 * @param array $all_meta Result of get_post_meta($id) (all meta as arrays).
963 * @return string Robots directive string, or empty.
964 */
965 private function resolve_robots_value($all_meta) {
966 // Neither half is enabled: contribute nothing at all, so core's own
967 // robots tag survives untouched.
968 if (Metasync_Feature_Flags::robots_fully_disabled()) {
969 return '';
970 }
971
972 return $this->filter_robots_by_enabled_features(
973 $this->resolve_robots_value_raw($all_meta),
974 $all_meta
975 );
976 }
977
978 /**
979 * Drops directives owned by a disabled robots feature.
980 *
981 * The three max-* directives are the "advanced" half; everything else
982 * (noindex, nofollow, noarchive, nosnippet, noimageindex) is "common".
983 *
984 * The raw resolver already dropped serving directives when the post carries
985 * noindex. If the common half is then switched off that noindex disappears
986 * too, so the strip no longer has anything to justify it — the advanced
987 * directives it removed belong to a half the user left enabled. They are
988 * therefore re-derived from the raw meta once the surviving directives are
989 * known.
990 *
991 * @param string $value Fully resolved robots string.
992 * @param array $all_meta Raw meta, used to recover stripped directives.
993 * @return string Filtered robots string, or empty when nothing survives.
994 */
995 private function filter_robots_by_enabled_features($value, $all_meta = []) {
996 $value = (string) $value;
997 if ($value === '') {
998 return '';
999 }
1000
1001 $common_enabled = Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::COMMON_ROBOTS);
1002 $advanced_enabled = Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::ADVANCE_ROBOTS);
1003 if ($common_enabled && $advanced_enabled) {
1004 return $value;
1005 }
1006
1007 $kept = [];
1008 foreach (explode(',', $value) as $directive) {
1009 $directive = trim($directive);
1010 if ($directive === '') {
1011 continue;
1012 }
1013 $is_advanced = stripos($directive, 'max-snippet') === 0
1014 || stripos($directive, 'max-image-preview') === 0
1015 || stripos($directive, 'max-video-preview') === 0;
1016
1017 if ($is_advanced ? $advanced_enabled : $common_enabled) {
1018 $kept[] = $directive;
1019 }
1020 }
1021
1022 // Advanced still on, but the noindex that suppressed its serving
1023 // directives has just been filtered out with the common half. Re-resolve
1024 // them from the raw meta, since nothing on the page justifies the strip
1025 // any more. Reaching here with an empty $kept means the common half is
1026 // off (both-enabled returned early above), so this is exactly the
1027 // stripped case and not a genuinely empty robots value.
1028 if ($advanced_enabled && empty($kept) && !empty($all_meta)) {
1029 foreach (explode(',', $this->resolve_advanced_directives($all_meta)) as $directive) {
1030 $directive = trim($directive);
1031 if ($directive !== '') {
1032 $kept[] = $directive;
1033 }
1034 }
1035 }
1036
1037 return !empty($kept) ? implode(', ', array_unique($kept)) : '';
1038 }
1039
1040 /**
1041 * Resolves only the advanced (max-*) directives, ignoring noindex.
1042 *
1043 * Mirrors the advanced branches of resolve_robots_value_raw() without its
1044 * serving-directive strip, so a post whose noindex belongs to a disabled
1045 * feature keeps the max-* directives the user still has switched on.
1046 *
1047 * @param array $all_meta Result of get_post_meta($id) (all meta as arrays).
1048 * @return string Comma-separated advanced directives, or empty.
1049 */
1050 private function resolve_advanced_directives($all_meta) {
1051 $directives = [];
1052
1053 $advanced_raw = $all_meta['_metasync_robots_advanced'][0] ?? '';
1054 if (!empty($advanced_raw)) {
1055 $advanced = json_decode($advanced_raw, true);
1056 if (is_array($advanced) && !empty($advanced)) {
1057 if (isset($advanced['max_snippet'])) {
1058 $directives[] = 'max-snippet:' . (int) $advanced['max_snippet'];
1059 }
1060 if (isset($advanced['max_image_preview'])) {
1061 $directives[] = 'max-image-preview:' . esc_attr($advanced['max_image_preview']);
1062 }
1063 if (isset($advanced['max_video_preview'])) {
1064 $directives[] = 'max-video-preview:' . (int) $advanced['max_video_preview'];
1065 }
1066 if (!empty($directives)) {
1067 return implode(', ', $directives);
1068 }
1069 }
1070 }
1071
1072 $adv_raw = $all_meta['metasync_advance_robots'][0] ?? '';
1073 if (!empty($adv_raw)) {
1074 $adv = maybe_unserialize($adv_raw);
1075 if (is_array($adv)) {
1076 if (!empty($adv['max-snippet']['enable'])) {
1077 $length = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
1078 $directives[] = 'max-snippet:' . $length;
1079 }
1080 if (!empty($adv['max-video-preview']['enable'])) {
1081 $length = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
1082 $directives[] = 'max-video-preview:' . $length;
1083 }
1084 if (!empty($adv['max-image-preview']['enable'])) {
1085 $length = isset($adv['max-image-preview']['length']) ? sanitize_text_field($adv['max-image-preview']['length']) : 'large';
1086 $directives[] = 'max-image-preview:' . $length;
1087 }
1088 }
1089 }
1090
1091 return !empty($directives) ? implode(', ', $directives) : '';
1092 }
1093
1094 /**
1095 * Resolve the robots meta value from all storage formats.
1096 *
1097 * Priority order:
1098 * 1. _metasync_robots_advanced (JSON)
1099 * 2. meta_robots (string, e.g. "noindex, nofollow") — set via REST API
1100 * 3. metasync_common_robots (array) + metasync_advance_robots (array) — admin checkboxes
1101 * 4. Global defaults from metasync_options['advance_robots_meta']
1102 *
1103 * The noindex directive is sourced from _metasync_robots_index (separate key)
1104 * and merged into the final string regardless of which storage format is used.
1105 *
1106 * @param array $all_meta Result of get_post_meta($id) (all meta as arrays).
1107 * @return string Robots directive string (e.g. "nofollow, noarchive, max-snippet:-1") or empty.
1108 */
1109 private function resolve_robots_value_raw($all_meta) {
1110 $directives = [];
1111
1112 // Merge noindex from _metasync_robots_index (separate key, not part of advanced JSON)
1113 $robots_index = $all_meta['_metasync_robots_index'][0] ?? '';
1114 if ($robots_index === 'noindex') {
1115 $directives[] = 'noindex';
1116 }
1117
1118 // Also honor noindex from the classic Common Robots meta box, which stores
1119 // it in metasync_common_robots. Without seeding it here, a noindex set via
1120 // that meta box is lost whenever the advanced-JSON path below short-circuits.
1121 if (!in_array('noindex', $directives, true)) {
1122 $common_index_raw = $all_meta['metasync_common_robots'][0] ?? '';
1123 if (!empty($common_index_raw)) {
1124 $common_index = maybe_unserialize($common_index_raw);
1125 if (is_array($common_index) && !empty($common_index['noindex'])) {
1126 $directives[] = 'noindex';
1127 }
1128 }
1129 }
1130
1131 // 1. Check _metasync_robots_advanced (JSON)
1132 $advanced_raw = $all_meta['_metasync_robots_advanced'][0] ?? '';
1133 if (!empty($advanced_raw)) {
1134 $advanced = json_decode($advanced_raw, true);
1135 if (is_array($advanced) && !empty($advanced)) {
1136 // Boolean directives
1137 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
1138 if (!empty($advanced[$dir])) {
1139 $directives[] = $dir;
1140 }
1141 }
1142 // max-* directives
1143 if (isset($advanced['max_snippet'])) {
1144 $directives[] = 'max-snippet:' . (int) $advanced['max_snippet'];
1145 }
1146 if (isset($advanced['max_image_preview'])) {
1147 $directives[] = 'max-image-preview:' . esc_attr($advanced['max_image_preview']);
1148 }
1149 if (isset($advanced['max_video_preview'])) {
1150 $directives[] = 'max-video-preview:' . (int) $advanced['max_video_preview'];
1151 }
1152 $directives = $this->strip_serving_directives_if_noindex($directives);
1153 return !empty($directives) ? implode(', ', $directives) : '';
1154 }
1155 }
1156
1157 // 2. Check meta_robots (REST API / direct string)
1158 $meta_robots = $all_meta['meta_robots'][0] ?? '';
1159 if (!empty($meta_robots)) {
1160 // Prepend noindex from _metasync_robots_index if not already in the string
1161 if (!empty($directives) && stripos($meta_robots, 'noindex') === false) {
1162 return implode(', ', $directives) . ', ' . $meta_robots;
1163 }
1164 return $meta_robots;
1165 }
1166
1167 // 3. Check metasync_common_robots (admin checkbox array)
1168 $raw = $all_meta['metasync_common_robots'][0] ?? '';
1169 if (!empty($raw)) {
1170 $common = maybe_unserialize($raw);
1171 if (is_array($common) && !empty($common)) {
1172 $common_directives = array_values(array_filter($common, function ($v) {
1173 return !empty($v);
1174 }));
1175 // 'index' is the implicit default and must never be emitted as a
1176 // directive. Dropping it here also de-conflicts legacy posts saved
1177 // with both Index and No Index checked, so noindex always wins.
1178 $common_directives = array_values(array_filter($common_directives, function ($v) {
1179 return strcasecmp((string) $v, 'index') !== 0;
1180 }));
1181 $directives = array_merge($directives, $common_directives);
1182 }
1183 }
1184
1185 // Also check metasync_advance_robots (admin per-post advanced checkboxes)
1186 // Structure: ['max-snippet' => ['enable' => '1', 'length' => 100], ...]
1187 $adv_raw = $all_meta['metasync_advance_robots'][0] ?? '';
1188 if (!empty($adv_raw)) {
1189 $adv = maybe_unserialize($adv_raw);
1190 if (is_array($adv)) {
1191 if (!empty($adv['max-snippet']['enable'])) {
1192 $length = isset($adv['max-snippet']['length']) ? (int) $adv['max-snippet']['length'] : -1;
1193 $directives[] = 'max-snippet:' . $length;
1194 }
1195 if (!empty($adv['max-video-preview']['enable'])) {
1196 $length = isset($adv['max-video-preview']['length']) ? (int) $adv['max-video-preview']['length'] : -1;
1197 $directives[] = 'max-video-preview:' . $length;
1198 }
1199 if (!empty($adv['max-image-preview']['enable'])) {
1200 $length = isset($adv['max-image-preview']['length']) ? sanitize_text_field($adv['max-image-preview']['length']) : 'large';
1201 $directives[] = 'max-image-preview:' . $length;
1202 }
1203 }
1204 }
1205
1206 if (!empty($directives)) {
1207 $directives = $this->strip_serving_directives_if_noindex($directives);
1208 return implode(', ', array_unique($directives));
1209 }
1210
1211 // 4. Global defaults from metasync_options['advance_robots_meta']
1212 $global_options = Metasync::get_option('advance_robots_meta');
1213 if (empty($global_options)) {
1214 $global_options = Metasync::get_option('advance_robots_mata');
1215 }
1216 if (is_array($global_options)) {
1217 if (!empty($global_options['max-snippet']['enable'])) {
1218 $length = isset($global_options['max-snippet']['length']) ? (int) $global_options['max-snippet']['length'] : -1;
1219 $directives[] = 'max-snippet:' . $length;
1220 }
1221 if (!empty($global_options['max-video-preview']['enable'])) {
1222 $length = isset($global_options['max-video-preview']['length']) ? (int) $global_options['max-video-preview']['length'] : -1;
1223 $directives[] = 'max-video-preview:' . $length;
1224 }
1225 if (!empty($global_options['max-image-preview']['enable'])) {
1226 $length = isset($global_options['max-image-preview']['length']) ? sanitize_text_field($global_options['max-image-preview']['length']) : 'large';
1227 $directives[] = 'max-image-preview:' . $length;
1228 }
1229 }
1230
1231 // AC-1: max-image-preview:large outputs by default even with no configuration.
1232 // If no per-post or global setting included max-image-preview, apply the default.
1233 $has_max_image = false;
1234 foreach ($directives as $d) {
1235 if (strpos($d, 'max-image-preview') !== false) {
1236 $has_max_image = true;
1237 break;
1238 }
1239 }
1240 if (!$has_max_image) {
1241 $directives[] = 'max-image-preview:large';
1242 }
1243
1244 return !empty($directives) ? implode(', ', array_unique($directives)) : '';
1245 }
1246
1247 /**
1248 * Get the correct canonical URL for archive pages.
1249 *
1250 * Handles: Posts page (is_home), category, tag, custom taxonomy,
1251 * author, year/month/day date archives.
1252 *
1253 * @return string Archive URL or empty string if not determinable.
1254 */
1255 private function get_archive_canonical_url() {
1256 if (is_home()) {
1257 $blog_page_id = (int) get_option('page_for_posts');
1258 if ($blog_page_id > 0) {
1259 // Honour a manual MetaSync canonical override on the Posts page
1260 // (validated — corrupted legacy rows are ignored,).
1261 $override = Metasync_Canonical_Sanitizer::sanitize(
1262 get_post_meta($blog_page_id, '_metasync_canonical_url', true)
1263 );
1264 if ($override !== '') {
1265 return esc_url($override);
1266 }
1267 return get_permalink($blog_page_id);
1268 }
1269 return home_url('/');
1270 }
1271
1272 if (is_category() || is_tag() || is_tax()) {
1273 $term = get_queried_object();
1274 if ($term instanceof WP_Term) {
1275 $link = get_term_link($term);
1276 return is_wp_error($link) ? '' : $link;
1277 }
1278 return '';
1279 }
1280
1281 if (is_author()) {
1282 return get_author_posts_url(get_queried_object_id());
1283 }
1284
1285 if (is_day()) {
1286 return get_day_link(get_query_var('year'), get_query_var('monthnum'), get_query_var('day'));
1287 }
1288
1289 if (is_month()) {
1290 return get_month_link(get_query_var('year'), get_query_var('monthnum'));
1291 }
1292
1293 if (is_year()) {
1294 return get_year_link(get_query_var('year'));
1295 }
1296
1297 return '';
1298 }
1299
1300 /**
1301 * Get the canonical URL for a post
1302 */
1303 private function get_canonical_url($post) {
1304 # Check for persisted OTTO canonical URL first, then the Canonical meta
1305 # box value (meta_canonical). Both are validated so legacy rows corrupted
1306 # to the literal "Array" (or stored as arrays) are never emitted.
1307 $metasync_canonical = Metasync_Canonical_Sanitizer::sanitize(
1308 get_post_meta($post->ID, '_metasync_canonical_url', true)
1309 );
1310 if ($metasync_canonical === '') {
1311 $metasync_canonical = Metasync_Canonical_Sanitizer::sanitize(
1312 get_post_meta($post->ID, 'meta_canonical', true)
1313 );
1314 }
1315
1316 if ($metasync_canonical !== '') {
1317 return esc_url($metasync_canonical);
1318 }
1319
1320 # Try to get the permalink using WordPress function
1321 $permalink = get_permalink($post->ID);
1322
1323 # If permalink is not available or is the default query URL, try alternative methods
1324 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1325 # Force WordPress to generate the proper permalink by temporarily setting post status
1326 $original_status = $post->post_status;
1327 if ($post->post_status === 'auto-draft') {
1328 $post->post_status = 'publish';
1329 }
1330
1331 # Try get_permalink again with the updated status
1332 $permalink = get_permalink($post->ID);
1333
1334 # Restore original status
1335 $post->post_status = $original_status;
1336 }
1337
1338 # If still not working, use WordPress core functions to build proper permalink
1339 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1340 # Use WordPress core function that respects permalink structure
1341 # This properly handles custom structures, hierarchies, and post types
1342 # Load admin function if not already available, Without this it is causing error on post the preview page
1343 if (!function_exists('get_sample_permalink')) {
1344 require_once ABSPATH . 'wp-admin/includes/post.php';
1345 }
1346 $permalink = get_sample_permalink($post->ID);
1347
1348 if (is_array($permalink)) {
1349 # get_sample_permalink returns array with template and slug
1350 # Replace %postname% or %pagename% with actual slug
1351 $permalink = str_replace(
1352 array('%pagename%', '%postname%'),
1353 $post->post_name,
1354 $permalink[0]
1355 );
1356 }
1357
1358 # Final fallback: if still problematic, construct URL respecting post type structure
1359 if (!$permalink || strpos($permalink, '?p=') !== false || strpos($permalink, '?page_id=') !== false) {
1360 if (!empty($post->post_name)) {
1361 # For pages, check if there's a parent hierarchy
1362 if ($post->post_type === 'page' && $post->post_parent) {
1363 # Get parent page path for proper hierarchy
1364 $parent = get_post($post->post_parent);
1365 $parent_path = '';
1366
1367 # Build full path including all parent pages
1368 while ($parent) {
1369 $parent_path = $parent->post_name . '/' . $parent_path;
1370 $parent = $parent->post_parent ? get_post($parent->post_parent) : null;
1371 }
1372
1373 $permalink = home_url('/' . $parent_path . $post->post_name . '/');
1374 } else {
1375 # For posts and pages without parents, use post type archive base
1376 $post_type_obj = get_post_type_object($post->post_type);
1377 $slug = $post_type_obj->rewrite['slug'] ?? '';
1378
1379 if ($slug && $post->post_type !== 'page') {
1380 $permalink = home_url('/' . $slug . '/' . $post->post_name . '/');
1381 } else {
1382 $permalink = home_url('/' . $post->post_name . '/');
1383 }
1384 }
1385 } else {
1386 # Fallback to post ID format if no slug available
1387 $permalink = home_url('/?p=' . $post->ID);
1388 }
1389 }
1390 }
1391
1392 return $permalink;
1393 }
1394
1395 /**
1396 * Set up indexation controls for archive pages
1397 *
1398 * Called via template_redirect hook to set up early before any output.
1399 * This ensures we can capture and clean robots tags from other plugins.
1400 *
1401 * Logic:
1402 * 1. If user wants to add noindex - always do it (override other plugins)
1403 * 2. If user wants to allow indexing - only remove other plugins' tags if override setting is enabled
1404 *
1405 * @since 1.0.0
1406 */
1407 public function inject_archive_seo_controls() {
1408 // Check if we're on a managed archive type
1409 if (!$this->is_managed_archive()) {
1410 return;
1411 }
1412
1413 // Get settings
1414 $seo_controls = Metasync::get_option('seo_controls', array());
1415 $should_noindex = $this->should_noindex_archive();
1416 $override_enabled = ($seo_controls['override_robots_tags'] ?? 'false') === 'true' || ($seo_controls['override_robots_tags'] ?? false) === true;
1417
1418 // Run buffer if either:
1419 // 1. User wants to add noindex (always override other plugins), OR
1420 // 2. User wants to allow indexing AND override setting is enabled
1421 if ($should_noindex || $override_enabled) {
1422 // Override WordPress core robots tag
1423 add_filter('wp_robots', array($this, 'override_wp_robots'), 999);
1424
1425 // Start buffering to remove other plugins' robots tags
1426 add_action('wp_head', array($this, 'start_robots_buffer'), 0);
1427 add_action('wp_head', array($this, 'end_robots_buffer'), PHP_INT_MAX);
1428 }
1429 // Otherwise: User wants to allow indexing AND override is disabled - don't interfere
1430 }
1431
1432 /**
1433 * Check if current page is a managed archive type
1434 *
1435 * Returns true if we're on any archive type that has indexation controls,
1436 * regardless of whether noindex is enabled or not.
1437 *
1438 * @since 1.0.0
1439 * @return bool True if on a managed archive type, false otherwise
1440 */
1441 private function is_managed_archive() {
1442 return is_date() || is_tag() || is_author() || is_category() || is_tax('post_format');
1443 }
1444
1445 /**
1446 * Check if current archive is empty (has no posts)
1447 *
1448 * Applies to category, tag, author, and post format archives.
1449 * Date archives are excluded as they're typically either indexed
1450 * or not entirely (less meaningful to check for empty state).
1451 *
1452 * @since 1.0.0
1453 * @return bool True if archive is empty (0 posts)
1454 */
1455 private function is_empty_archive() {
1456 global $wp_query;
1457
1458 // Check taxonomy and author archives (excluding date archives)
1459 if (!is_category() && !is_tag() && !is_author() && !is_tax('post_format')) {
1460 return false;
1461 }
1462
1463 // Check if the archive has no posts
1464 return $wp_query->post_count === 0;
1465 }
1466
1467 /**
1468 * Check if current archive should be noindexed
1469 *
1470 * @since 1.0.0
1471 * @return bool True if should add noindex, false otherwise
1472 */
1473 private function should_noindex_archive() {
1474 // Get indexation control settings
1475 $seo_controls = Metasync::get_option('seo_controls', array());
1476
1477 // Check empty archives setting first (applies to categories/tags only)
1478 $noindex_empty_archives = $seo_controls['noindex_empty_archives'] ?? false;
1479 if (($noindex_empty_archives === 'true' || $noindex_empty_archives === true) && $this->is_empty_archive()) {
1480 return true;
1481 }
1482
1483 // Date Archives
1484 if (is_date()) {
1485 $index_date_archives = $seo_controls['index_date_archives'] ?? false;
1486 // Handle both string and boolean values for compatibility
1487 if ($index_date_archives === 'true' || $index_date_archives === true) {
1488 return true;
1489 }
1490 }
1491
1492 // Tag Archives
1493 elseif (is_tag()) {
1494 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1495 // Handle both string and boolean values for compatibility
1496 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1497 return true;
1498 }
1499 }
1500
1501 // Author Archives
1502 elseif (is_author()) {
1503 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1504 // Handle both string and boolean values for compatibility
1505 if ($index_author_archives === 'true' || $index_author_archives === true) {
1506 return true;
1507 }
1508 }
1509
1510 // Category Archives
1511 elseif (is_category()) {
1512 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1513 // Handle both string and boolean values for compatibility
1514 if ($index_category_archives === 'true' || $index_category_archives === true) {
1515 return true;
1516 }
1517 }
1518
1519 // Format Archives (post format taxonomy)
1520 elseif (is_tax('post_format')) {
1521 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1522 if ($index_format_archives === 'true' || $index_format_archives === true) {
1523 return true;
1524 }
1525 }
1526
1527 return false;
1528 }
1529
1530 /**
1531 * Override WordPress core robots directives
1532 *
1533 * Handles both cases:
1534 * 1. When noindex is enabled: Add noindex directive
1535 * 2. When indexing is allowed: Return empty array (removes WP core robots tag)
1536 *
1537 * @since 1.0.0
1538 * @param array $robots Associative array of robots directives
1539 * @return array Modified robots directives
1540 */
1541 public function override_wp_robots($robots) {
1542 if ($this->should_noindex_archive()) {
1543 // Case 1: Add noindex directive
1544 return array(
1545 'noindex' => true,
1546 'follow' => true,
1547 'max-image-preview' => 'large', // Maintain good image preview for social sharing
1548 );
1549 } else {
1550 // Case 2: Allow indexing - return empty array to remove WP core robots tag
1551 // Default behavior without robots tag is to index
1552 return array();
1553 }
1554 }
1555
1556 /**
1557 * Start output buffering to capture robots meta tags from other plugins
1558 *
1559 * @since 1.0.0
1560 */
1561 public function start_robots_buffer() {
1562 ob_start();
1563 }
1564
1565 /**
1566 * End output buffering, remove existing robots tags, and optionally add ours
1567 *
1568 * This handles BOTH cases:
1569 * 1. When noindex is enabled: Remove other tags and add noindex tag
1570 * 2. When indexing is allowed: Remove other plugins' noindex tags
1571 *
1572 * This ensures our indexation settings always take precedence over other plugins.
1573 *
1574 * @since 1.0.0
1575 */
1576 public function end_robots_buffer() {
1577 // Only proceed if we're on a managed archive
1578 if (!$this->is_managed_archive()) {
1579 ob_end_flush();
1580 return;
1581 }
1582
1583 // Get buffered content
1584 $content = ob_get_clean();
1585
1586 // Remove all existing robots meta tags (handles various formats)
1587 // This is done for BOTH noindex and index cases to ensure clean slate
1588
1589 // Pattern 1: name="robots" content="..."
1590 $content = preg_replace(
1591 '/<meta\s+name=["\']robots["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1592 '',
1593 $content
1594 );
1595
1596 // Pattern 2: content="..." name="robots"
1597 $content = preg_replace(
1598 '/<meta\s+content=["\'][^"\']*["\']\s+name=["\']robots["\']\s*\/?>\s*/i',
1599 '',
1600 $content
1601 );
1602
1603 // Pattern 3: Single quotes or no quotes (rare but possible)
1604 $content = preg_replace(
1605 "/<meta\s+name='robots'\s+content='[^']*'\s*\/?>\s*/i",
1606 '',
1607 $content
1608 );
1609
1610 // Pattern 4: property="robots" (some plugins use property attribute)
1611 $content = preg_replace(
1612 '/<meta\s+property=["\']robots["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1613 '',
1614 $content
1615 );
1616
1617 // Pattern 5: Search engine specific directives (googlebot, bingbot, etc.)
1618 $content = preg_replace(
1619 '/<meta\s+name=["\'](?:googlebot|bingbot|googlebot-news|slurp)["\']\s+content=["\'][^"\']*["\']\s*\/?>\s*/i',
1620 '',
1621 $content
1622 );
1623
1624 // Output cleaned content
1625 echo $content;
1626
1627 // Add our robots tag or comment based on settings
1628 if ($this->should_noindex_archive()) {
1629 // Case 1: User wants to disallow indexing - add noindex tag
1630 echo '<!-- MetaSync Indexation Control: Noindex Applied (Overriding other plugins) -->' . "\n";
1631 echo '<meta name="robots" content="noindex, follow">' . "\n";
1632 } else {
1633 // Case 2: User wants to allow indexing AND override is enabled
1634 // Just remove other plugins' tags, don't add our own
1635 // Default behavior without robots tag is to allow indexing
1636 echo '<!-- MetaSync Indexation Control: Index Allowed (Override enabled - Other noindex tags removed) -->' . "\n";
1637 }
1638 }
1639
1640 /**
1641 * Filter taxonomy sitemap entries to exclude disabled archive types
1642 *
1643 * @since 1.0.0
1644 * @param array $taxonomies Array of taxonomy objects
1645 * @return array Modified array of taxonomy objects
1646 */
1647 public function filter_sitemap_taxonomies($taxonomies) {
1648 // Get indexation control settings
1649 $seo_controls = Metasync::get_option('seo_controls', array());
1650
1651 // Check if tag archives are disabled
1652 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1653 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1654 // Remove post_tag taxonomy from sitemap
1655 unset($taxonomies['post_tag']);
1656 }
1657
1658 // Check if category archives are disabled
1659 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1660 if ($index_category_archives === 'true' || $index_category_archives === true) {
1661 // Remove category taxonomy from sitemap
1662 unset($taxonomies['category']);
1663 }
1664
1665 // Check if format archives are disabled
1666 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1667 if ($index_format_archives === 'true' || $index_format_archives === true) {
1668 // Remove post_format taxonomy from sitemap
1669 unset($taxonomies['post_format']);
1670 }
1671
1672 return $taxonomies;
1673 }
1674
1675 /**
1676 * Filter user sitemap entries to exclude disabled author archives
1677 *
1678 * @since 1.0.0
1679 * @param array $entry Sitemap entry for user
1680 * @param WP_User $user User object
1681 * @return array|false Modified sitemap entry or false to exclude
1682 */
1683 public function filter_sitemap_users($entry, $user) {
1684 // Get indexation control settings
1685 $seo_controls = Metasync::get_option('seo_controls', array());
1686
1687 // Check if author archives are disabled
1688 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1689 if ($index_author_archives === 'true' || $index_author_archives === true) {
1690 // Exclude this user from sitemap
1691 return false;
1692 }
1693
1694 return $entry;
1695 }
1696
1697 /**
1698 * Filter sitemap providers to exclude disabled archive types
1699 *
1700 * @since 1.0.0
1701 * @param bool $provider Whether to add the provider
1702 * @param string $name Provider name
1703 * @return bool Whether to add the provider
1704 */
1705 public function filter_sitemap_providers($provider, $name) {
1706 // Get indexation control settings
1707 $seo_controls = Metasync::get_option('seo_controls', array());
1708
1709 // Check if users/authors sitemap provider should be disabled
1710 if ($name === 'users') {
1711 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1712 if ($index_author_archives === 'true' || $index_author_archives === true) {
1713 return false; // Exclude users sitemap provider entirely
1714 }
1715 }
1716
1717 return $provider;
1718 }
1719
1720 /**
1721 * Filter sitemap index entries to exclude disabled archive types
1722 *
1723 * @since 1.0.0
1724 * @param array $sitemap_entry Sitemap entry array
1725 * @param string $object_type Object type (posts, taxonomies, users)
1726 * @param string $subtype Subtype (post type or taxonomy name)
1727 * @param int $page Page number
1728 * @return array|false Modified sitemap entry or false to exclude
1729 */
1730 public function filter_sitemap_index_entries($sitemap_entry, $object_type, $subtype, $page) {
1731 // Get indexation control settings
1732 $seo_controls = Metasync::get_option('seo_controls', array());
1733
1734 // Handle taxonomy sitemaps
1735 if ($object_type === 'taxonomies') {
1736 // Check if tag archives are disabled
1737 if ($subtype === 'post_tag') {
1738 $index_tag_archives = $seo_controls['index_tag_archives'] ?? false;
1739 if ($index_tag_archives === 'true' || $index_tag_archives === true) {
1740 return false; // Exclude from sitemap index
1741 }
1742 }
1743
1744 // Check if category archives are disabled
1745 if ($subtype === 'category') {
1746 $index_category_archives = $seo_controls['index_category_archives'] ?? false;
1747 if ($index_category_archives === 'true' || $index_category_archives === true) {
1748 return false; // Exclude from sitemap index
1749 }
1750 }
1751
1752 // Check if format archives are disabled
1753 if ($subtype === 'post_format') {
1754 $index_format_archives = $seo_controls['index_format_archives'] ?? false;
1755 if ($index_format_archives === 'true' || $index_format_archives === true) {
1756 return false; // Exclude from sitemap index
1757 }
1758 }
1759 }
1760
1761 // Handle user/author sitemaps
1762 if ($object_type === 'users') {
1763 $index_author_archives = $seo_controls['index_author_archives'] ?? false;
1764 if ($index_author_archives === 'true' || $index_author_archives === true) {
1765 return false; // Exclude from sitemap index
1766 }
1767 }
1768
1769 return $sitemap_entry;
1770 }
1771 }
1772