PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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 / sitemap / class-metasync-sitemap-generator.php

class-metasync-sitemap-generator.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at sitemap/class-metasync-sitemap-generator.php

2,688 lines 109.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 * The sitemap generator functionality of the plugin.
9 *
10 * @link https://searchatlas.com
11 * @since 1.0.0
12 *
13 * @package Metasync
14 * @subpackage Metasync/sitemap
15 */
16
17 /**
18 * The sitemap generator class.
19 *
20 * Handles XML sitemap generation and management.
21 *
22 * @package Metasync
23 * @subpackage Metasync/sitemap
24 * @author Engineering Team <[email protected]>
25 */
26 class Metasync_Sitemap_Generator
27 {
28 /**
29 * Prefetched primary-category meta map for the current chunk.
30 *
31 * Populated by collect_all_urls() only on sites whose permalink structure
32 * contains %category%. The map is keyed by `post_id` => [meta_key => value]
33 * and holds the three primary-category keys (_metasync_primary_category,
34 * _yoast_wpseo_primary_category, rank_math_primary_category).
35 *
36 * Metasync_Seo_Output::filter_post_link_category_primary() reads this map
37 * before falling back to get_post_meta(), so that disabling the WP_Query
38 * post-meta cache prime (update_post_meta_cache => false) does not regress
39 * primary-category permalink resolution with per-post queries.
40 *
41 * @var array
42 */
43 public static $primary_category_prefetch = [];
44
45 /**
46 * The sitemap index file path
47 *
48 * @var string
49 */
50 private $sitemap_index_path;
51
52 /**
53 * The sitemap index URL
54 *
55 * @var string
56 */
57 private $sitemap_index_url;
58
59 /**
60 * Maximum URLs per sitemap file
61 *
62 * @var int
63 */
64 private $urls_per_sitemap = 5000;
65
66 /**
67 * Initialize the class and set its properties.
68 */
69 public function __construct()
70 {
71 $this->sitemap_index_path = ABSPATH . 'sitemap_index.xml';
72 $this->sitemap_index_url = home_url('/sitemap_index.xml');
73
74 // MetaSync must only take over the sitemap URLs (/sitemap*.xml)
75 // when its own sitemap feature is actually in use. When it is not, the
76 // URLs are left untouched so a third-party provider (RankMath, Yoast,
77 // AIOSEO, or WordPress core) can serve them. Previously the interception
78 // below was registered unconditionally on every front-end request, which
79 // silently shadowed another sitemap plugin, could not be turned off from
80 // the UI, and even regenerated our sitemap on access after a delete.
81 if ($this->is_sitemap_enabled()) {
82 // Disable WordPress core sitemap so we don't serve duplicate sitemaps
83 // to search engines.
84 add_filter('wp_sitemaps_enabled', '__return_false', 10);
85 if (!get_option('metasync_disable_wp_sitemap', false)) {
86 update_option('metasync_disable_wp_sitemap', true);
87 }
88
89 // Register hook to serve virtual sitemap files.
90 add_action('template_redirect', array($this, 'serve_virtual_sitemap'), 1);
91
92 // Route sitemap URLs through WordPress rewrites so requests always reach PHP,
93 // even on nginx setups that 403 direct .xml access.
94 static $rewrite_hooks_registered = false;
95 if (!$rewrite_hooks_registered) {
96 $rewrite_hooks_registered = true;
97 add_filter('query_vars', array($this, 'add_sitemap_query_var'));
98 add_filter('option_rewrite_rules', array($this, 'inject_rewrite_rules'));
99 add_filter('sanitize_option_rewrite_rules', array($this, 'strip_dynamic_rewrite_rules'));
100 add_filter('redirect_canonical', array($this, 'disable_canonical_redirect_for_sitemaps'));
101 }
102
103 // Reconcile stray physical sitemap files on any request that runs
104 // PHP, not just one that reaches our sitemap route.
105 //
106 // WordPress' own rewrite block ends with
107 // `RewriteCond %{REQUEST_FILENAME} !-f`, so on Apache a physical
108 // news-sitemap.xml at ABSPATH is served statically and index.php
109 // is never loaded — serve_virtual_sitemap() and the absorption in
110 // get_sitemap_document() are both unreachable for exactly the
111 // request that needs them. Sweeping from `init` closes that window:
112 // any page view on the site absorbs the file, so the next crawler
113 // fetch gets the live document.
114 static $absorb_hook_registered = false;
115 if (!$absorb_hook_registered) {
116 $absorb_hook_registered = true;
117 add_action('init', array($this, 'absorb_stray_physical_sitemaps'), 20);
118 }
119 }
120
121 $this->register_cache_bust_hooks();
122 }
123
124 /**
125 * Whether MetaSync should own the XML sitemap URLs (/sitemap*.xml).
126 *
127 * Instance wrapper around {@see is_feature_enabled()}; kept so existing
128 * callers (and tests) that hold a generator instance are unchanged.
129 *
130 * @return bool
131 */
132 public function is_sitemap_enabled()
133 {
134 return self::is_feature_enabled();
135 }
136
137 /**
138 * Side-effect-free, static form of the sitemap-feature gate.
139 *
140 * MetaSync only intercepts and serves the sitemap URLs when its own sitemap
141 * feature is actually in use. When it is not, the URLs are left untouched so
142 * a third-party provider (RankMath, Yoast, AIOSEO, or WordPress core) can
143 * serve them — this prevents MetaSync from silently shadowing another
144 * sitemap plugin.
145 *
146 * This is exposed as a static so callers outside the generator — notably the
147 * robots.txt manager, which must not advertise a `Sitemap:` line when the
148 * feature is off — can consult the exact same gate without constructing a
149 * generator (whose constructor registers front-end hooks and would
150 * re-register them). Single source of truth.
151 *
152 * Resolution order:
153 * 1. Explicit `metasync_sitemap_enabled` option when set to a real boolean.
154 * Store it as the string '1' or '0' — never boolean false, which
155 * WordPress persists as an empty string and which we (correctly) read
156 * back as "not configured". An unset option or empty string falls
157 * through to auto-detect.
158 * 2. Auto-detect: the general sitemap is tracked, auto-update is on, or the
159 * news/video sitemaps are enabled.
160 * The result is passed through the `metasync_sitemap_enabled` filter so a
161 * host or site owner can force it on or off in code.
162 *
163 * @return bool
164 */
165 public static function is_feature_enabled()
166 {
167 // Only a value that parses to a real boolean counts as an explicit
168 // choice; unset, '' or unparseable values fall through to auto-detect
169 // (so garbage never silently forces the feature off).
170 $explicit = get_option('metasync_sitemap_enabled', null);
171 $explicit = (null === $explicit || '' === $explicit)
172 ? null
173 : filter_var($explicit, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
174
175 if (null !== $explicit) {
176 $enabled = $explicit;
177 } else {
178 // Auto-detect: is any MetaSync sitemap feature actually in use?
179 $news_settings = get_option('metasync_news_sitemap_settings', []);
180 $video_settings = get_option('metasync_video_sitemap_settings', []);
181
182 $enabled = (bool) get_option('metasync_sitemap_auto_update', false)
183 || !empty(get_option('metasync_sitemap_files', []))
184 || (is_array($news_settings) && !empty($news_settings['enabled']))
185 || (is_array($video_settings) && !empty($video_settings['enabled']))
186 || self::sitemap_index_exists();
187 }
188
189 /**
190 * Filter whether MetaSync manages the XML sitemap URLs.
191 *
192 * Return false to hand /sitemap*.xml back to another sitemap provider
193 * (e.g. RankMath, Yoast, AIOSEO, or WordPress core) while keeping the
194 * rest of MetaSync active.
195 *
196 * @param bool $enabled Whether MetaSync currently owns the sitemap URLs.
197 */
198 return (bool) apply_filters('metasync_sitemap_enabled', $enabled);
199 }
200
201 /**
202 * Static, side-effect-free check for the general sitemap index, mirroring
203 * the instance {@see sitemap_exists()} without touching $this so it can be
204 * used by {@see is_feature_enabled()} from a static context.
205 *
206 * @return bool
207 */
208 private static function sitemap_index_exists()
209 {
210 // Virtual content is the primary storage path (transient).
211 if (false !== get_transient('metasync_vsm_' . md5('sitemap_index.xml'))) {
212 return true;
213 }
214
215 // Legacy option storage (pre-transient installs), read-only — no migration
216 // side effects here.
217 $legacy_virtual = get_option('metasync_sitemap_virtual', []);
218 if (is_array($legacy_virtual) && isset($legacy_virtual['sitemap_index.xml'])) {
219 return true;
220 }
221
222 // Tracked sitemap files option (survives transient eviction).
223 if (!empty(get_option('metasync_sitemap_files', []))) {
224 return true;
225 }
226
227 // Legacy physical file for installs that haven't migrated yet.
228 return file_exists(ABSPATH . 'sitemap_index.xml');
229 }
230
231 /**
232 * Register `metasync_sitemap` as a public WordPress query var.
233 *
234 * @param array $vars Registered query vars.
235 * @return array
236 */
237 public function add_sitemap_query_var($vars)
238 {
239 $vars[] = 'metasync_sitemap';
240 return $vars;
241 }
242
243 /**
244 * Dynamically inject sitemap rewrite rules at the top of the rules array so
245 * they take effect without requiring a manual flush_rewrite_rules() call
246 * (mirrors Yoast's class-yoast-dynamic-rewrites.php pattern).
247 *
248 * @param mixed $rules Existing rewrite rules array (or empty value before flush).
249 * @return array
250 */
251 public function inject_rewrite_rules($rules)
252 {
253 // CRITICAL: never inject into an empty/non-array rules value.
254 //
255 // WordPress self-heals broken routing in WP_Rewrite::wp_rewrite_rules():
256 // "if ( empty( get_option('rewrite_rules') ) ) { regenerate + save; }".
257 // Because this filter runs on `option_rewrite_rules`, returning a
258 // non-empty array when the stored option is empty makes WordPress think
259 // rules already exist, so it NEVER regenerates. The site is then left
260 // with only these sitemap rules, and every URL except the home page
261 // 404s (pages, posts, WooCommerce products) until the plugin is
262 // deactivated (filter removed → WP regenerates) or permalinks are
263 // re-saved. Returning the empty value unchanged preserves the self-heal.
264 if (empty($rules) || !is_array($rules)) {
265 return $rules;
266 }
267
268 $sitemap_rules = [
269 '^sitemap_index\.xml$' => 'index.php?metasync_sitemap=sitemap_index.xml',
270 '^sitemap(\d*)\.xml$' => 'index.php?metasync_sitemap=sitemap$matches[1].xml',
271 '^news-sitemap\.xml$' => 'index.php?metasync_sitemap=news-sitemap.xml',
272 '^video-sitemap\.xml$' => 'index.php?metasync_sitemap=video-sitemap.xml',
273 ];
274
275 return $sitemap_rules + $rules;
276 }
277
278 /**
279 * Strip dynamically-injected sitemap rules before they are persisted to the
280 * rewrite_rules option in the database.
281 *
282 * @param mixed $rules Rewrite rules about to be saved.
283 * @return array|string Filtered rules without the dynamic sitemap entries.
284 */
285 public function strip_dynamic_rewrite_rules($rules)
286 {
287 if (empty($rules) || !is_array($rules)) {
288 return $rules;
289 }
290
291 $dynamic_keys = [
292 '^sitemap_index\.xml$',
293 '^sitemap(\d*)\.xml$',
294 '^news-sitemap\.xml$',
295 '^video-sitemap\.xml$',
296 ];
297
298 return array_diff_key($rules, array_flip($dynamic_keys));
299 }
300
301 /**
302 * Suppress WordPress's canonical redirect when a sitemap is being served.
303 *
304 * @param string $redirect_url The proposed canonical URL.
305 * @return string|false The unmodified URL, or false to cancel the redirect.
306 */
307 public function disable_canonical_redirect_for_sitemaps($redirect_url)
308 {
309 if (get_query_var('metasync_sitemap')) {
310 return false;
311 }
312 return $redirect_url;
313 }
314
315 /**
316 * Register WordPress content lifecycle hooks that bust the sitemap caches.
317 *
318 * Bust callbacks are intentionally lightweight (transient deletes only) so they
319 * do not add latency to the editor's save action. Optional async warm-up can
320 * be enabled via the `metasync_sitemap_async_warmup` filter.
321 */
322 public function register_cache_bust_hooks()
323 {
324 // Post lifecycle.
325 add_action('save_post', array($this, 'bust_sitemap_cache'));
326 add_action('delete_post', array($this, 'bust_sitemap_cache'));
327 add_action('trashed_post', array($this, 'bust_sitemap_cache'));
328
329 // Taxonomy lifecycle.
330 add_action('created_term', array($this, 'bust_sitemap_cache'));
331 add_action('edited_term', array($this, 'bust_sitemap_cache'));
332 add_action('delete_term', array($this, 'bust_sitemap_cache'));
333
334 // Meta updates that affect indexability.
335 add_action('added_post_meta', array($this, 'bust_sitemap_on_meta_update'), 10, 3);
336 add_action('updated_post_meta', array($this, 'bust_sitemap_on_meta_update'), 10, 3);
337 add_action('deleted_post_meta', array($this, 'bust_sitemap_on_meta_update'), 10, 3);
338
339 // Redirect stores: our own manager fires 'metasync_redirections_changed'
340 // on every write; Rank Math fires 'rank_math/redirection/deleted' on
341 // deletions (its free build exposes no save-side hook). As a coarse
342 // save proxy, 'rank_math/redirection/get_redirections_query' fires
343 // whenever Rank Math re-renders its redirect list — i.e. immediately
344 // after every add/update/toggle. Yoast's per-post redirect meta is
345 // covered by the meta listeners above via the '_yoast_wpseo_redirect'
346 // bust key.
347 add_action('metasync_redirections_changed', array($this, 'bust_sitemap_cache'));
348 add_action('rank_math/redirection/deleted', array($this, 'bust_sitemap_cache'));
349 add_action('rank_math/redirection/get_redirections_query', array($this, 'bust_sitemap_cache'));
350
351 // Async warm-up listener.
352 add_action('metasync_sitemap_async_warmup_event', array($this, 'async_warmup_handler'));
353 }
354
355 /**
356 * Bust all sitemap-related transients.
357 *
358 * Coalesces multiple calls within the same request via a static guard so
359 * bulk operations (e.g. a 50-product import) only trigger one bust.
360 */
361 public function bust_sitemap_cache()
362 {
363 static $already_busted = false;
364 if ($already_busted) {
365 return;
366 }
367 $already_busted = true;
368
369 delete_transient('metasync_vsm_' . md5('news-sitemap.xml'));
370 delete_transient('metasync_vsm_' . md5('video-sitemap.xml'));
371
372 $virtual_index = get_option('metasync_sitemap_virtual_index', []);
373 if (is_array($virtual_index)) {
374 foreach ($virtual_index as $tkey) {
375 if (is_string($tkey) && $tkey !== '') {
376 delete_transient($tkey);
377 }
378 }
379 }
380 delete_option('metasync_sitemap_virtual_index');
381
382 if (apply_filters('metasync_sitemap_async_warmup', false)
383 && !wp_next_scheduled('metasync_sitemap_async_warmup_event')) {
384 wp_schedule_single_event(time() + 30, 'metasync_sitemap_async_warmup_event');
385 }
386 }
387
388 /**
389 * Conditionally bust the sitemap cache when meta affecting indexability changes.
390 *
391 * Only `_metasync_robots_index`, `_metasync_canonical_url`, and
392 * `metasync_common_robots` (the robots metabox array, which carries the
393 * per-post noindex toggle the sitemap excludes on) trigger a bust;
394 * unrelated keys (e.g. `_edit_lock`) are ignored.
395 *
396 * @param int|int[] $meta_id ID of the metadata entry (an array of IDs for
397 * the deleted_post_meta hook); unused here.
398 * @param int $object_id Object the metadata is attached to.
399 * @param string $meta_key The meta key being updated.
400 */
401 public function bust_sitemap_on_meta_update($meta_id, $object_id, $meta_key)
402 {
403 $bust_keys = array('_metasync_robots_index', '_metasync_canonical_url', 'metasync_common_robots', '_yoast_wpseo_redirect');
404 if (!in_array($meta_key, $bust_keys, true)) {
405 return;
406 }
407 $this->bust_sitemap_cache();
408 }
409
410 /**
411 * Async warm-up handler invoked by wp_schedule_single_event after a bust.
412 */
413 public function async_warmup_handler()
414 {
415 $this->generate_sitemap();
416 }
417
418 /**
419 * Generate the XML sitemap (split into multiple files if needed)
420 *
421 * @return bool|WP_Error True on success, WP_Error on failure
422 */
423 public function generate_sitemap( $track_generation = false )
424 {
425 try {
426 // Delete existing general sitemap files first (preserve news/video)
427 $this->delete_sitemap('general');
428
429 // Clean up any orphaned temp files from previous crashed generations
430 $this->cleanup_temp_sitemap_files();
431
432 // Collect all URLs
433 $all_urls = $this->collect_all_urls();
434
435 if (empty($all_urls)) {
436 return new WP_Error('no_urls', 'No URLs found to include in sitemap.');
437 }
438
439 // Split URLs into chunks
440 $url_chunks = array_chunk($all_urls, $this->urls_per_sitemap);
441 $sitemap_files = [];
442
443 // Generate individual sitemap files
444 foreach ($url_chunks as $index => $urls) {
445 $sitemap_number = $index + 1;
446 $sitemap_filename = $this->get_sitemap_filename($sitemap_number);
447 $sitemap_path = ABSPATH . $sitemap_filename;
448
449 $result = $this->generate_sitemap_file($sitemap_path, $urls);
450
451 if ($result === false) {
452 // The chunk could not be stored, so it cannot be served.
453 // Carrying on would publish an index that points at a
454 // sitemap which does not exist, and report success while
455 // doing it.
456 return false;
457 }
458
459 if (is_wp_error($result)) {
460 return $result;
461 }
462
463 $sitemap_files[] = [
464 'filename' => $sitemap_filename,
465 'url' => home_url('/' . $sitemap_filename),
466 'lastmod' => $this->get_chunk_lastmod($urls),
467 ];
468 }
469
470 // Build the index entries: main sitemaps + news/video if they exist
471 $index_entries = $sitemap_files;
472
473 $news_settings = get_option('metasync_news_sitemap_settings', []);
474 $video_settings = get_option('metasync_video_sitemap_settings', []);
475
476 if (!empty($news_settings['enabled'])) {
477 $index_entries[] = [
478 'filename' => 'news-sitemap.xml',
479 'url' => home_url('/news-sitemap.xml'),
480 'lastmod' => current_time('mysql', true),
481 ];
482 }
483
484 if (!empty($video_settings['enabled'])) {
485 $index_entries[] = [
486 'filename' => 'video-sitemap.xml',
487 'url' => home_url('/video-sitemap.xml'),
488 'lastmod' => current_time('mysql', true),
489 ];
490 }
491
492 // Generate sitemap index file (includes all sitemaps)
493 $index_result = $this->generate_sitemap_index($index_entries);
494
495 if ($index_result === false) {
496 return false; // Return false instead of WP_Error to prevent Sentry capture
497 }
498
499 if (is_wp_error($index_result)) {
500 return $index_result;
501 }
502
503 // Store sitemap info for admin display
504 update_option('metasync_sitemap_files', $sitemap_files);
505 update_option('metasync_sitemap_total_urls', count($all_urls));
506 update_option('metasync_sitemap_last_generated', current_time('mysql'));
507
508 // Consent-gated product analytics: only callers passing
509 // $track_generation (the manual admin buttons) report; auto
510 // regeneration (save_post hooks, warm-up cron, MCP) must not —
511 // bulk publishing would otherwise send one event per post.
512 if ($track_generation && !wp_doing_cron()) {
513 Metasync_PostHog::feature('sitemap_generated', array(
514 'url_count' => count($all_urls),
515 ));
516 }
517
518 // Auto-update robots.txt with sitemap index URL
519 $robots_result = $this->update_robots_txt_sitemap();
520 if ($robots_result['success'] && $robots_result['action'] !== 'unchanged') {
521 // Store the result for admin notice
522 set_transient('metasync_sitemap_robots_updated', $robots_result, 60);
523 }
524
525 return true;
526
527 } catch (Exception $e) {
528 return new WP_Error('sitemap_generation_failed', $e->getMessage());
529 }
530 }
531
532 /**
533 * Collect all URLs for the sitemap
534 *
535 * @return array Array of URL data
536 */
537 private function collect_all_urls()
538 {
539 global $wpdb;
540
541 $urls = [];
542
543 // Load user-configured sitemap settings
544 $sitemap_settings = get_option('metasync_sitemap_settings', []);
545
546 // Get all published posts, pages, and custom post types
547 $post_types = get_post_types(['public' => true], 'names');
548
549 // Exclude certain post types
550 $excluded_types = [
551 'attachment',
552 'revision',
553 'nav_menu_item',
554 'elementor_library',
555 'elementor-hf',
556 'e-floating-buttons',
557 'ct_template',
558 'oxy_user_library',
559 'brizy-template',
560 'fusion_template',
561 'fusion_tb_section',
562 'ae_global_templates',
563 'custom_css',
564 'customize_changeset',
565 'oembed_cache',
566 'user_request',
567 'wp_block',
568 'wp_template',
569 'wp_template_part',
570 'wp_global_styles',
571 'wp_navigation',
572 'acf-field-group',
573 'acf-field',
574 'fl-builder-template',
575 'fl-theme-layout',
576 'wpr_mega_menu',
577 'wpr_templates',
578 ];
579 $excluded_types = apply_filters('metasync_sitemap_excluded_post_types', $excluded_types);
580 $post_types = array_diff($post_types, $excluded_types);
581
582 // Apply user-configured post type selection (only when settings have been explicitly saved)
583 if (!empty($sitemap_settings['_configured'])) {
584 if (!empty($sitemap_settings['post_types'])) {
585 $post_types = array_intersect($post_types, (array) $sitemap_settings['post_types']);
586 } else {
587 // Configured but empty = user unchecked all post types
588 $post_types = [];
589 }
590 }
591
592 if (empty($post_types)) {
593 return $urls;
594 }
595
596 // Build excluded URLs set for fast lookup
597 $excluded_urls = [];
598 if (!empty($sitemap_settings['excluded_urls'])) {
599 $raw_lines = explode("\n", $sitemap_settings['excluded_urls']);
600 foreach ($raw_lines as $line) {
601 $line = trim($line);
602 if ($line !== '') {
603 $excluded_urls[$line] = true;
604 }
605 }
606 }
607
608 // Get indexation control settings
609 $seo_controls = $this->get_seo_controls();
610
611 // URLs that are the source of an active redirect must not be
612 // advertised in the sitemap: a crawler following them never receives
613 // the page, only a bounce to the destination, wasting crawl budget
614 // and delaying indexing. Sources are collected from our own redirect
615 // manager and from active third-party SEO plugins (Rank Math,
616 // Yoast), so the sitemap matches what a crawler actually
617 // experiences. Redirect destinations themselves are never excluded.
618 $redirect_sources = $this->get_redirect_source_paths();
619
620 // Add homepage first
621 $home_url = home_url('/');
622 $urls[] = [
623 'loc' => $home_url,
624 'lastmod' => get_lastpostmodified('gmt'),
625 'priority' => '1.0',
626 'changefreq' => 'daily',
627 ];
628
629 // Track seen URLs to prevent duplicates (e.g. front page appearing again from posts query).
630 // Normalize with untrailingslashit() to avoid trailing-slash mismatches between
631 // home_url('/') and get_permalink() for static front pages.
632 $seen_urls = [untrailingslashit($home_url) => true];
633
634 // Build taxonomy query for category/tag filtering (only when explicitly configured).
635 // Each term filter is OR-ed with a NOT EXISTS clause: the query runs across all
636 // selected post types in one WP_Query, and content with no terms in the taxonomy
637 // (pages, custom post types, untagged posts) must not be excluded by a term
638 // selection that cannot apply to it.
639 $sitemap_tax_query = [];
640 if (!empty($sitemap_settings['_configured']) && !empty($sitemap_settings['categories'])) {
641 $sitemap_tax_query[] = [
642 'relation' => 'OR',
643 [
644 'taxonomy' => 'category',
645 'field' => 'term_id',
646 'terms' => array_map('absint', (array) $sitemap_settings['categories']),
647 ],
648 [
649 'taxonomy' => 'category',
650 'operator' => 'NOT EXISTS',
651 ],
652 ];
653 }
654 if (!empty($sitemap_settings['_configured']) && !empty($sitemap_settings['tags'])) {
655 $sitemap_tax_query[] = [
656 'relation' => 'OR',
657 [
658 'taxonomy' => 'post_tag',
659 'field' => 'term_id',
660 'terms' => array_map('absint', (array) $sitemap_settings['tags']),
661 ],
662 [
663 'taxonomy' => 'post_tag',
664 'operator' => 'NOT EXISTS',
665 ],
666 ];
667 }
668 if (count($sitemap_tax_query) > 1) {
669 $sitemap_tax_query['relation'] = 'AND';
670 }
671
672 // Process posts in chunks to bound memory and database load.
673 $chunk_size = (int) apply_filters('metasync_sitemap_chunk_size', 200);
674 if ($chunk_size < 1) {
675 $chunk_size = 200;
676 }
677 $paged = 1;
678
679 while (true) {
680 $query_args = [
681 'post_type' => array_values($post_types),
682 'post_status' => 'publish',
683 'posts_per_page' => $chunk_size,
684 'paged' => $paged,
685 'orderby' => ['modified' => 'DESC', 'ID' => 'DESC'],
686 'no_found_rows' => true,
687 'update_post_term_cache' => true,
688 'update_post_meta_cache' => false,
689 'cache_results' => true,
690 'ignore_sticky_posts' => true,
691 'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- sitemap must enumerate the full site regardless of language/plugin filtering
692 ];
693
694 if (!empty($sitemap_tax_query)) {
695 $query_args['tax_query'] = $sitemap_tax_query;
696 }
697
698 $query = new WP_Query($query_args);
699
700 $posts = $query->posts;
701 if (empty($posts)) {
702 wp_reset_postdata();
703 unset($query, $posts);
704 break;
705 }
706
707 // Batch-resolve noindex status for the entire chunk in a single query.
708 // Mirrors is_post_noindex(): metasync_common_robots is a serialized array
709 // and a post is noindex when it contains 'noindex' => 'noindex'.
710 $post_ids = wp_list_pluck($posts, 'ID');
711 $post_ids = array_map('intval', $post_ids);
712 $noindex_pattern = '%"noindex";s:7:"noindex"%';
713 $noindex_args = array_merge($post_ids, [$noindex_pattern]);
714 $noindex_ids = (array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sitemap build read — output is transient-cached with lifecycle bust hooks; these queries run only on cache rebuilds
715 $wpdb->prepare(
716 'SELECT post_id FROM ' . $wpdb->postmeta . " WHERE meta_key = 'metasync_common_robots' AND post_id IN (" . implode(',', array_fill(0, count($post_ids), '%d')) . ') AND meta_value LIKE %s',
717 $noindex_args
718 )
719 );
720 $noindex_ids = array_map('intval', $noindex_ids);
721 $noindex_set = array_flip($noindex_ids);
722
723 // Yoast SEO Premium stores per-post redirects in post meta: a
724 // published post whose own URL redirects elsewhere is in the same
725 // position as a redirect-source URL and must not be listed either.
726 // Batch-resolved in one query, mirroring the noindex lookup above.
727 $yoast_redirect_set = [];
728 // Per-post redirects are a Yoast SEO Premium feature; the gate
729 // avoids a wasted meta query per chunk on free-Yoast sites.
730 if (apply_filters('metasync_sitemap_yoast_redirects_active', defined('WPSEO_VERSION') && defined('WPSEO_PREMIUM_VERSION'))) {
731 $yoast_ids = (array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sitemap build read — output is transient-cached with lifecycle bust hooks; these queries run only on cache rebuilds
732 $wpdb->prepare(
733 'SELECT post_id FROM ' . $wpdb->postmeta . " WHERE meta_key = '_yoast_wpseo_redirect' AND meta_value <> '' AND post_id IN (" . implode(',', array_fill(0, count($post_ids), '%d')) . ')',
734 $post_ids
735 )
736 );
737 $yoast_redirect_set = array_flip(array_map('intval', $yoast_ids));
738 }
739
740 // Anti-regression for %category% permalink sites: with the post-meta
741 // cache prime disabled (update_post_meta_cache => false), the only
742 // post-meta consumer in the sitemap path — primary-category
743 // resolution in Metasync_Seo_Output::filter_post_link_category_primary()
744 // — would otherwise issue one get_post_meta() call per post. Batch-
745 // fetch the three primary-category keys for the whole chunk in a
746 // single query (mirroring the noindex batch above) and store the
747 // result in the static prefetch map that the filter reads first.
748 // Only relevant when the permalink structure actually contains
749 // %category% (the filter is a no-op otherwise).
750 self::$primary_category_prefetch = [];
751 if (strpos((string) get_option('permalink_structure', ''), '%category%') !== false) {
752 $primary_keys = [
753 '_metasync_primary_category',
754 '_yoast_wpseo_primary_category',
755 'rank_math_primary_category',
756 ];
757 $primary_args = array_merge($post_ids, $primary_keys);
758 $primary_rows = (array) $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sitemap build read — output is transient-cached with lifecycle bust hooks; these queries run only on cache rebuilds
759 $wpdb->prepare(
760 'SELECT post_id, meta_key, meta_value FROM ' . $wpdb->postmeta . ' WHERE post_id IN (' . implode(',', array_fill(0, count($post_ids), '%d')) . ') AND meta_key IN (' . implode(',', array_fill(0, count($primary_keys), '%s')) . ')',
761 $primary_args
762 ),
763 ARRAY_A
764 );
765 $prefetch = [];
766 foreach ($primary_rows as $row) {
767 $pid = (int) $row['post_id'];
768 if (!isset($prefetch[$pid])) {
769 $prefetch[$pid] = [];
770 }
771 $prefetch[$pid][$row['meta_key']] = $row['meta_value'];
772 }
773 self::$primary_category_prefetch = $prefetch;
774 }
775
776 foreach ($posts as $post) {
777 if (isset($noindex_set[(int) $post->ID])) {
778 continue;
779 }
780
781 // Skip posts carrying a Yoast per-post redirect.
782 if (isset($yoast_redirect_set[(int) $post->ID])) {
783 continue;
784 }
785
786 $priority = '0.8';
787 if ($post->post_type === 'page') {
788 $priority = '0.9';
789 } elseif ($post->post_type === 'post') {
790 $priority = '0.7';
791 }
792
793 $changefreq = 'weekly';
794 if ($post->post_type === 'page') {
795 $changefreq = 'monthly';
796 }
797
798 // Note: get_permalink() respects the post_link_category filter registered
799 // in Metasync_Seo_Output, which automatically resolves the primary category.
800 $permalink = get_permalink($post);
801
802 // Skip URLs with query strings (e.g. ?wpr_mega_menu=... internal items).
803 // Only when the site uses pretty permalinks — under the plain permalink
804 // structure (?p=123) every legitimate URL contains '?', so applying this
805 // unconditionally would empty the sitemap down to the homepage.
806 if (get_option('permalink_structure') && strpos($permalink, '?') !== false) {
807 continue;
808 }
809
810 // Skip URLs that are the source of a known redirect — the
811 // sitemap must advertise the destination, not the bounce.
812 if ($this->is_redirect_source($permalink, $redirect_sources)) {
813 continue;
814 }
815
816 // Skip duplicate URLs (e.g. front page appearing again from posts query)
817 $normalized_permalink = untrailingslashit($permalink);
818 if (isset($seen_urls[$normalized_permalink])) {
819 continue;
820 }
821
822 // Skip URLs that are in the exclusion list
823 if (!empty($excluded_urls) && isset($excluded_urls[$permalink])) {
824 continue;
825 }
826
827 $seen_urls[$normalized_permalink] = true;
828
829 $urls[] = [
830 'loc' => $permalink,
831 'lastmod' => $post->post_modified_gmt,
832 'priority' => $priority,
833 'changefreq' => $changefreq,
834 ];
835 }
836
837 wp_reset_postdata();
838
839 // Release the per-chunk object-cache accumulation (post objects, post
840 // meta, and term caches) so memory stays flat across chunks regardless
841 // of catalog size. clean_post_cache() drops each post's cache entries;
842 // with a persistent external object cache (Redis/Memcached) it evicts
843 // only those individual post entries (re-fetched on demand) rather than
844 // flushing the whole cache, so it never wipes the live site cache. Runs
845 // AFTER the foreach above has finished using $post (get_permalink,
846 // post_modified_gmt) and BEFORE the next chunk loads its post objects.
847 $chunk_post_ids = wp_list_pluck($posts, 'ID');
848 foreach ($chunk_post_ids as $chunk_post_id) {
849 clean_post_cache((int) $chunk_post_id);
850 }
851
852 unset($query, $posts, $noindex_ids, $noindex_set, $post_ids, $noindex_args, $prefetch, $primary_rows, $primary_args, $primary_keys, $chunk_post_ids, $yoast_ids, $yoast_redirect_set);
853 $paged++;
854 }
855
856 // Clear the chunk-scoped prefetch map so it does not leak into subsequent
857 // non-sitemap get_permalink() calls during the same request.
858 self::$primary_category_prefetch = [];
859
860 // Add taxonomies (categories, tags, etc.)
861 $taxonomies = get_taxonomies(['public' => true], 'names');
862
863 // Apply user-configured taxonomy selection (only when settings have been explicitly saved)
864 if (!empty($sitemap_settings['_configured'])) {
865 if (!empty($sitemap_settings['taxonomies'])) {
866 $taxonomies = array_intersect($taxonomies, (array) $sitemap_settings['taxonomies']);
867 } else {
868 // Configured but empty = user unchecked all taxonomies
869 $taxonomies = [];
870 }
871 }
872
873 foreach ($taxonomies as $taxonomy) {
874 // Skip taxonomies that are set to noindex
875 if ($this->is_taxonomy_noindex($taxonomy, $seo_controls)) {
876 continue;
877 }
878
879 // Respect per-term category/tag selections for archive URLs too, so an
880 // unchecked term's archive page does not appear in the sitemap.
881 $selected_term_ids = null;
882 if (!empty($sitemap_settings['_configured'])) {
883 if ($taxonomy === 'category' && !empty($sitemap_settings['categories'])) {
884 $selected_term_ids = array_map('absint', (array) $sitemap_settings['categories']);
885 } elseif ($taxonomy === 'post_tag' && !empty($sitemap_settings['tags'])) {
886 $selected_term_ids = array_map('absint', (array) $sitemap_settings['tags']);
887 }
888 }
889
890 $terms = get_terms([
891 'taxonomy' => $taxonomy,
892 'hide_empty' => true,
893 ]);
894
895 if (is_wp_error($terms) || empty($terms)) {
896 continue;
897 }
898
899 // Single aggregated query: most-recently-modified post per term in this taxonomy.
900 $type_values = array_values($post_types);
901 $lastmod_args = array_merge([$taxonomy], $type_values);
902 $lastmod_rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sitemap build read — output is transient-cached with lifecycle bust hooks; these queries run only on cache rebuilds
903 $wpdb->prepare(
904 'SELECT tt.term_id, MAX(p.post_modified_gmt) AS lastmod
905 FROM ' . $wpdb->term_taxonomy . ' tt
906 INNER JOIN ' . $wpdb->term_relationships . ' tr ON tr.term_taxonomy_id = tt.term_taxonomy_id
907 INNER JOIN ' . $wpdb->posts . " p ON p.ID = tr.object_id
908 WHERE tt.taxonomy = %s AND p.post_status = 'publish' AND p.post_type IN (" . implode(',', array_fill(0, count($type_values), '%s')) . ')
909 GROUP BY tt.term_id',
910 $lastmod_args
911 ),
912 ARRAY_A
913 );
914
915 $lastmod_map = [];
916 if (is_array($lastmod_rows)) {
917 foreach ($lastmod_rows as $row) {
918 $lastmod_map[(int) $row['term_id']] = $row['lastmod'];
919 }
920 }
921
922 foreach ($terms as $term) {
923 if ($selected_term_ids !== null && !in_array((int) $term->term_id, $selected_term_ids, true)) {
924 continue;
925 }
926
927 $term_link = get_term_link($term);
928 if (is_wp_error($term_link)) {
929 continue;
930 }
931
932 // Term archives obey the same rule as posts: a redirecting
933 // archive URL is not advertised, its destination is.
934 if ($this->is_redirect_source($term_link, $redirect_sources)) {
935 continue;
936 }
937
938 // Skip URLs that are in the exclusion list
939 if (!empty($excluded_urls) && isset($excluded_urls[$term_link])) {
940 continue;
941 }
942
943 // Skip duplicate URLs
944 $normalized_term_link = untrailingslashit($term_link);
945 if (isset($seen_urls[$normalized_term_link])) {
946 continue;
947 }
948
949 $seen_urls[$normalized_term_link] = true;
950
951 $lastmod = isset($lastmod_map[(int) $term->term_id])
952 ? $lastmod_map[(int) $term->term_id]
953 : current_time('mysql', 1);
954 $urls[] = [
955 'loc' => $term_link,
956 'lastmod' => $lastmod,
957 'priority' => '0.6',
958 'changefreq' => 'weekly',
959 ];
960 }
961
962 unset($lastmod_rows, $lastmod_map, $terms);
963 }
964
965 return $urls;
966 }
967
968 /**
969 * Get the sitemap filename for a given number
970 *
971 * @param int $number The sitemap number (1, 2, 3, etc.)
972 * @return string The filename
973 */
974 private function get_sitemap_filename($number)
975 {
976 if ($number === 1) {
977 return 'sitemap.xml';
978 }
979 return 'sitemap' . $number . '.xml';
980 }
981
982 /**
983 * Generate a single sitemap file
984 *
985 * @param string $path The file path
986 * @param array $urls Array of URL data
987 * @return bool|WP_Error True on success, false when the sitemap could not be
988 * stored, WP_Error on failure
989 */
990 private function generate_sitemap_file($path, $urls)
991 {
992 $xml_content = $this->build_sitemap_xml_string($urls);
993 return $this->store_virtual_sitemap_file(basename($path), $xml_content);
994 }
995
996 /**
997 * Build the sitemap XML string in memory using DOMDocument.
998 *
999 * @param array $urls Array of URL data
1000 * @return string XML content
1001 */
1002 private function build_sitemap_xml_string($urls)
1003 {
1004 if (!class_exists('DOMDocument')) {
1005 error_log('MetaSync: sitemap generation skipped — DOMDocument (php-dom extension) is not available on this server.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1006 return '';
1007 }
1008
1009 $xml = new DOMDocument('1.0', 'UTF-8');
1010 $xml->formatOutput = true;
1011
1012 $urlset = $xml->createElement('urlset');
1013 $urlset->setAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');
1014 $urlset->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');
1015 $urlset->setAttribute('xsi:schemaLocation', 'http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd');
1016 $xml->appendChild($urlset);
1017
1018 foreach ($urls as $url_data) {
1019 $this->add_url_to_sitemap($xml, $urlset, $url_data['loc'], $url_data['lastmod'], $url_data['priority'], $url_data['changefreq']);
1020 }
1021
1022 return $xml->saveXML();
1023 }
1024
1025 /**
1026 * Build the sitemap index XML string in memory using DOMDocument.
1027 *
1028 * @param array $sitemap_files Array of sitemap file info
1029 * @return string XML content
1030 */
1031 private function build_sitemap_index_xml_string($sitemap_files)
1032 {
1033 if (!class_exists('DOMDocument')) {
1034 error_log('MetaSync: sitemap index generation skipped — DOMDocument (php-dom extension) is not available on this server.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
1035 return '';
1036 }
1037
1038 $xml = new DOMDocument('1.0', 'UTF-8');
1039 $xml->formatOutput = true;
1040
1041 $sitemapindex = $xml->createElement('sitemapindex');
1042 $sitemapindex->setAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');
1043 $xml->appendChild($sitemapindex);
1044
1045 foreach ($sitemap_files as $sitemap) {
1046 $sitemap_element = $xml->createElement('sitemap');
1047
1048 $loc = $xml->createElement('loc', esc_url($sitemap['url']));
1049 $sitemap_element->appendChild($loc);
1050
1051 if (!empty($sitemap['lastmod'])) {
1052 $lastmod_formatted = gmdate('Y-m-d\TH:i:s+00:00', strtotime($sitemap['lastmod']));
1053 $lastmod = $xml->createElement('lastmod', $lastmod_formatted);
1054 $sitemap_element->appendChild($lastmod);
1055 }
1056
1057 $sitemapindex->appendChild($sitemap_element);
1058 }
1059
1060 return $xml->saveXML();
1061 }
1062
1063 /**
1064 * Write content to a file atomically using temp file + rename.
1065 *
1066 * Both files must reside on the same filesystem/mount for rename() to be atomic.
1067 *
1068 * @param string $dir Directory for the temp file (same mount as $final_path)
1069 * @param string $final_path Final destination path
1070 * @param string $content File content to write
1071 * @return bool True on success, false on failure
1072 */
1073 private function atomic_write($dir, $final_path, $content)
1074 {
1075 $tmp_path = tempnam($dir, 'metasync-sitemap-tmp-');
1076 if ($tmp_path === false) {
1077 return false;
1078 }
1079
1080 if (file_put_contents($tmp_path, $content) === false) {
1081 $this->safe_unlink($tmp_path);
1082 return false;
1083 }
1084
1085 if (rename($tmp_path, $final_path)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic same-filesystem replace; WP_Filesystem::move() needs credential bootstrapping on non-direct hosts
1086 return true;
1087 }
1088
1089 $this->safe_unlink($tmp_path);
1090 return false;
1091 }
1092
1093 /**
1094 * Unlink a file with WP_DEBUG-gated error logging instead of @ suppression.
1095 *
1096 * @param string $path File path to delete
1097 */
1098 private function safe_unlink($path)
1099 {
1100 if (file_exists($path) && !unlink($path)) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- boolean return value is consumed
1101 if (defined('WP_DEBUG') && WP_DEBUG) {
1102 error_log('MetaSync: failed to remove temp file: ' . $path); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
1103 }
1104 }
1105 }
1106
1107 /**
1108 * Remove orphaned temp files from previous crashed/timed-out generations.
1109 */
1110 private function cleanup_temp_sitemap_files()
1111 {
1112 $dirs = array_unique([dirname($this->sitemap_index_path), ABSPATH]);
1113 foreach ($dirs as $dir) {
1114 $pattern = $dir . '/metasync-sitemap-tmp-*';
1115 $stale_files = glob($pattern);
1116 if (!is_array($stale_files)) {
1117 continue;
1118 }
1119 foreach ($stale_files as $file) {
1120 $this->safe_unlink($file);
1121 }
1122 }
1123 }
1124
1125 /**
1126 * Generate the sitemap index file
1127 *
1128 * @param array $sitemap_files Array of sitemap file info
1129 * @return bool|WP_Error True on success, false when the index could not be
1130 * stored, WP_Error on failure
1131 */
1132 private function generate_sitemap_index($sitemap_files)
1133 {
1134 $xml_content = $this->build_sitemap_index_xml_string($sitemap_files);
1135 return $this->store_virtual_sitemap_file('sitemap_index.xml', $xml_content);
1136 }
1137
1138 /**
1139 * Get the most recent lastmod date from a chunk of URLs
1140 *
1141 * @param array $urls Array of URL data
1142 * @return string The most recent lastmod date
1143 */
1144 private function get_chunk_lastmod($urls)
1145 {
1146 $latest = '';
1147 foreach ($urls as $url) {
1148 if (!empty($url['lastmod']) && $url['lastmod'] > $latest) {
1149 $latest = $url['lastmod'];
1150 }
1151 }
1152 return $latest ?: current_time('mysql', 1);
1153 }
1154
1155 /**
1156 * Collect normalized paths that are the source of an active redirect.
1157 *
1158 * The sitemap must not advertise a URL that immediately redirects: the
1159 * crawler never receives the page, only a bounce. Sources are read from
1160 * every redirect store that can influence this site's responses:
1161 *
1162 * - our own redirect manager (metasync_redirections), honoring the
1163 * 'exact' and 'start' pattern types;
1164 * - Rank Math's Redirections module (rank_math_redirections) when both
1165 * the plugin and its Redirections module are active, honoring its
1166 * 'exact' and 'start' comparisons — a common pairing where redirects
1167 * are managed in Rank Math while we generate the sitemap;
1168 * - Yoast SEO Premium's per-post redirect, resolved per chunk in
1169 * collect_all_urls() via the '_yoast_wpseo_redirect' post meta.
1170 * (Yoast Premium's redirect-manager table — its UI-level redirects —
1171 * is a separate store and is deliberately out of scope here.)
1172 *
1173 * Only deterministic pattern types are honored (exact and prefix).
1174 * 'contain', 'end', 'wildcard' and 'regex' patterns are deliberately
1175 * skipped: projecting them onto generated URLs risks false exclusions,
1176 * and their destinations are still listed.
1177 *
1178 * Return false from the 'metasync_sitemap_exclude_redirect_sources'
1179 * filter to disable the behaviour entirely (e.g. a host that wants the
1180 * raw publish-list back).
1181 *
1182 * @return array{exact: array<string, true>, prefix: array<string, string>} 'exact' maps
1183 * normalized path => true; 'prefix' maps normalized path => itself.
1184 */
1185 private function get_redirect_source_paths()
1186 {
1187 $sources = ['exact' => [], 'prefix' => []];
1188
1189 if (!apply_filters('metasync_sitemap_exclude_redirect_sources', true)) {
1190 return $sources;
1191 }
1192
1193 global $wpdb;
1194
1195 // Our own redirect manager.
1196 $metasync_table = $wpdb->prefix . 'metasync_redirections';
1197 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $metasync_table)) === $metasync_table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sitemap build read — output is transient-cached with lifecycle bust hooks; these queries run only on cache rebuilds
1198 $rows = (array) $wpdb->get_results(
1199 "SELECT sources_from, pattern_type FROM {$wpdb->prefix}metasync_redirections WHERE status = 'active'",
1200 ARRAY_A
1201 );
1202 foreach ($rows as $row) {
1203 $source_list = maybe_unserialize($row['sources_from']);
1204 if (!is_array($source_list)) {
1205 continue;
1206 }
1207 $global_type = isset($row['pattern_type']) ? $row['pattern_type'] : null;
1208 foreach ($source_list as $source_key => $source_value) {
1209 $source_key = (string) $source_key;
1210 // Legacy list-format rows store the URL as the value under
1211 // a numeric key ('0' => '/old'); remap for matching.
1212 if (ctype_digit($source_key) && is_string($source_value) && '' !== $source_value) {
1213 $source_key = $source_value;
1214 }
1215 $type = in_array($source_value, ['exact', 'contain', 'start', 'end', 'wildcard', 'regex'], true)
1216 ? $source_value
1217 : ($global_type ?: 'exact');
1218 $path = $this->normalize_redirect_path($source_key);
1219 if ('' === $path) {
1220 continue;
1221 }
1222 if ('exact' === $type) {
1223 // A bare-root exact source ('https://example.com' or '/')
1224 // would collide with every query-string permalink on
1225 // plain-permalink sites ('?p=N' normalizes to '/'),
1226 // collapsing the sitemap to the homepage — never
1227 // register it. A '*' in an 'exact' source is wildcard
1228 // input the redirect engine treats loosely; it is not
1229 // deterministic, so it is skipped like the other
1230 // non-deterministic pattern types.
1231 if ('/' !== $path && strpos($path, '*') === false) {
1232 $sources['exact'][$path] = true;
1233 }
1234 } elseif ('start' === $type) {
1235 $sources['prefix'][$path] = $path;
1236 }
1237 }
1238 }
1239 }
1240
1241 // Rank Math's Redirections module (free tier), when both the plugin
1242 // and its Redirections module are active. Rows can outlive the module
1243 // being switched off in Rank Math's settings, and a disabled module
1244 // serves no redirects — only honor the table while it is on.
1245 $rank_math_active = class_exists('RankMath') || defined('RANK_MATH_VERSION');
1246 if ($rank_math_active) {
1247 // Mirror Rank Math's own is_module_active(): rows can outlive the
1248 // module being switched off in its settings, and a disabled
1249 // module serves no redirects.
1250 $rank_math_modules = get_option('rank_math_modules', []);
1251 $rank_math_active = is_array($rank_math_modules) && in_array('redirections', $rank_math_modules, true);
1252 }
1253 if (apply_filters('metasync_sitemap_rank_math_active', $rank_math_active)) {
1254 $rank_math_table = $wpdb->prefix . 'rank_math_redirections';
1255 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $rank_math_table)) === $rank_math_table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sitemap build read — output is transient-cached with lifecycle bust hooks; these queries run only on cache rebuilds
1256 $rows = (array) $wpdb->get_results(
1257 "SELECT sources FROM {$wpdb->prefix}rank_math_redirections WHERE status = 'active'",
1258 ARRAY_A
1259 );
1260 foreach ($rows as $row) {
1261 $source_list = isset($row['sources']) ? maybe_unserialize($row['sources']) : null;
1262 if (is_string($source_list)) {
1263 // Defensive: some Rank Math builds stored JSON here.
1264 $decoded = json_decode($source_list, true);
1265 $source_list = is_array($decoded) ? $decoded : null;
1266 }
1267 if (!is_array($source_list)) {
1268 continue;
1269 }
1270 foreach ($source_list as $entry) {
1271 if (!is_array($entry) || empty($entry['pattern'])) {
1272 continue;
1273 }
1274 $comparison = isset($entry['comparison']) ? $entry['comparison'] : 'exact';
1275 $path = $this->normalize_redirect_path($entry['pattern']);
1276 if ('' === $path) {
1277 continue;
1278 }
1279 if ('exact' === $comparison) {
1280 // Same bare-root guard as our own table (query-
1281 // string permalinks all normalize to '/').
1282 if ('/' !== $path) {
1283 $sources['exact'][$path] = true;
1284 }
1285 } elseif ('start' === $comparison || 'start with' === $comparison) {
1286 // Rank Math stores the comparison token 'start';
1287 // 'start with' is tolerated defensively for forks.
1288 // Prefix rules are live-matched against a slash-
1289 // trimmed URI with the pattern compared as stored
1290 // (Str::starts_with), so a leading slash in the
1291 // stored pattern permanently disables the rule at
1292 // runtime — mirror that and skip it here too, or
1293 // URLs that still serve 200 would be excluded.
1294 // A scheme-bearing pattern (a pasted full URL) is
1295 // dead for the same reason: the runtime URI never
1296 // contains the scheme or host.
1297 $raw_pattern = (string) $entry['pattern'];
1298 if (0 === strpos($raw_pattern, '/') || strpos($raw_pattern, '://') !== false) {
1299 continue;
1300 }
1301 $sources['prefix'][$path] = $path;
1302 }
1303 }
1304 }
1305 }
1306 }
1307
1308 return $sources;
1309 }
1310
1311 /**
1312 * Normalize a URL or path for redirect-source comparison.
1313 *
1314 * Redirection stores hold paths with or without a leading slash, and
1315 * sometimes full URLs; generated sitemap entries are always full
1316 * permalinks. Reducing both sides to a bare path — leading slash, no
1317 * trailing slash — makes the comparison host- and scheme-agnostic.
1318 *
1319 * @param mixed $url_or_path Absolute URL or path; serialized third-party redirect data is not guaranteed to be a string, so non-strings are cast.
1320 * @return string Normalized path ('' when nothing usable remains, '/' for root).
1321 */
1322 private function normalize_redirect_path($url_or_path)
1323 {
1324 $path = $url_or_path;
1325 if (is_string($url_or_path) && strpos($url_or_path, 'http') === 0) {
1326 $parsed = wp_parse_url($url_or_path);
1327 // A bare domain ('https://example.com') is the site root.
1328 $path = isset($parsed['path']) ? $parsed['path'] : '/';
1329 }
1330 $path = (string) $path;
1331 if ('' === $path) {
1332 return '';
1333 }
1334 $path = '/' . ltrim($path, '/');
1335 $path = rtrim($path, '/');
1336 return ('' === $path) ? '/' : $path;
1337 }
1338
1339 /**
1340 * Whether a URL is the source of a known redirect.
1341 *
1342 * @param string $url URL as it would appear in the sitemap.
1343 * @param array $sources Redirect-source sets from get_redirect_source_paths().
1344 * @return bool True when the URL redirects (exact match or known prefix).
1345 */
1346 private function is_redirect_source($url, $sources)
1347 {
1348 if (empty($sources['exact']) && empty($sources['prefix'])) {
1349 return false;
1350 }
1351 $path = $this->normalize_redirect_path($url);
1352 if ('' === $path) {
1353 return false;
1354 }
1355 if (isset($sources['exact'][$path])) {
1356 return true;
1357 }
1358 foreach ($sources['prefix'] as $prefix) {
1359 // A root prefix would match every URL and empty the sitemap —
1360 // treat it as unusable rather than honoring it literally.
1361 if ('/' !== $prefix && 0 === strpos($path, $prefix)) {
1362 return true;
1363 }
1364 }
1365 return false;
1366 }
1367
1368 /**
1369 * Get SEO controls/indexation settings
1370 *
1371 * @return array The SEO controls settings
1372 */
1373 private function get_seo_controls()
1374 {
1375 if (class_exists('Metasync')) {
1376 return Metasync::get_option('seo_controls', []);
1377 }
1378 return get_option('metasync_seo_controls', []);
1379 }
1380
1381 /**
1382 * Check if a taxonomy is set to noindex in indexation controls
1383 *
1384 * @param string $taxonomy The taxonomy name
1385 * @param array $seo_controls The SEO controls settings
1386 * @return bool True if the taxonomy should be excluded from sitemap
1387 */
1388 private function is_taxonomy_noindex($taxonomy, $seo_controls)
1389 {
1390 // Map taxonomy names to their indexation control settings
1391 $taxonomy_settings_map = [
1392 'post_tag' => 'index_tag_archives',
1393 'category' => 'index_category_archives',
1394 'post_format' => 'index_format_archives',
1395 ];
1396
1397 // Check if this taxonomy has an indexation control setting
1398 if (isset($taxonomy_settings_map[$taxonomy])) {
1399 $setting_key = $taxonomy_settings_map[$taxonomy];
1400 $setting_value = $seo_controls[$setting_key] ?? false;
1401
1402 // If the setting is 'true' or true, it means noindex is enabled
1403 // (The setting name is "index_*" but when true, it means "disable indexing")
1404 if ($setting_value === 'true' || $setting_value === true) {
1405 return true;
1406 }
1407 }
1408
1409 return false;
1410 }
1411
1412 /**
1413 * Add URL to sitemap
1414 *
1415 * @param DOMDocument $xml The XML document
1416 * @param DOMElement $urlset The urlset element
1417 * @param string $loc The URL location
1418 * @param string $lastmod The last modified date
1419 * @param string $priority The priority
1420 * @param string $changefreq The change frequency
1421 */
1422 private function add_url_to_sitemap($xml, $urlset, $loc, $lastmod, $priority, $changefreq)
1423 {
1424 $url = $xml->createElement('url');
1425
1426 $loc_element = $xml->createElement('loc', esc_url($loc));
1427 $url->appendChild($loc_element);
1428
1429 if (!empty($lastmod)) {
1430 $lastmod_timestamp = strtotime($lastmod);
1431 // Legacy zero dates ("0000-00-00 00:00:00") make strtotime()
1432 // return a pre-epoch timestamp, which would serialise as a
1433 // year -0001 lastmod. lastmod is optional — skip it instead.
1434 if ($lastmod_timestamp !== false && $lastmod_timestamp >= 0) {
1435 $lastmod_formatted = gmdate('Y-m-d\TH:i:s+00:00', $lastmod_timestamp);
1436 $lastmod_element = $xml->createElement('lastmod', $lastmod_formatted);
1437 $url->appendChild($lastmod_element);
1438 }
1439 }
1440
1441 $changefreq_element = $xml->createElement('changefreq', $changefreq);
1442 $url->appendChild($changefreq_element);
1443
1444 $priority_element = $xml->createElement('priority', $priority);
1445 $url->appendChild($priority_element);
1446
1447 $urlset->appendChild($url);
1448 }
1449
1450 /**
1451 * Check if sitemap index exists
1452 *
1453 * @return bool
1454 */
1455 public function sitemap_exists()
1456 {
1457 // Check virtual content first (now the primary storage path).
1458 $virtual_content = $this->get_virtual_sitemap_file('sitemap_index.xml');
1459 if (false !== $virtual_content) {
1460 return true;
1461 }
1462
1463 // Check tracked sitemap files option (survives transient eviction).
1464 $tracked = get_option('metasync_sitemap_files', []);
1465 if (!empty($tracked)) {
1466 return true;
1467 }
1468
1469 // Fall back to physical file for legacy installs that haven't migrated yet.
1470 return file_exists($this->sitemap_index_path);
1471 }
1472
1473 /**
1474 * Get sitemap index content
1475 *
1476 * @return string|false
1477 */
1478 public function get_sitemap_content()
1479 {
1480 // Check virtual content first (now the primary storage path).
1481 $virtual_content = $this->get_virtual_sitemap_file('sitemap_index.xml');
1482 if (false !== $virtual_content) {
1483 return $virtual_content;
1484 }
1485
1486 // Fall back to physical file for legacy installs that haven't migrated yet.
1487 if (file_exists($this->sitemap_index_path)) {
1488 return file_get_contents($this->sitemap_index_path);
1489 }
1490
1491 return false;
1492 }
1493
1494 /**
1495 * Get sitemap index URL
1496 *
1497 * @return string
1498 */
1499 public function get_sitemap_url()
1500 {
1501 return $this->sitemap_index_url;
1502 }
1503
1504 /**
1505 * Get total size of all sitemap files
1506 *
1507 * @return int|false
1508 */
1509 public function get_sitemap_size()
1510 {
1511 $sitemap_files = get_option('metasync_sitemap_files', []);
1512 $filenames = ['sitemap_index.xml'];
1513 foreach (is_array($sitemap_files) ? $sitemap_files : [] as $sitemap) {
1514 if (!empty($sitemap['filename']) && is_string($sitemap['filename'])) {
1515 $filenames[] = $sitemap['filename'];
1516 }
1517 }
1518
1519 $total_size = 0;
1520 $found_document = false;
1521 foreach (array_unique($filenames) as $filename) {
1522 // The document accessor prefers virtual/transient content and only
1523 // falls back to legacy physical files when no virtual copy exists.
1524 $content = $this->get_sitemap_document($filename);
1525 if (false === $content) {
1526 continue;
1527 }
1528 $found_document = true;
1529 $total_size += strlen($content);
1530 }
1531
1532 return $found_document ? $total_size : false;
1533 }
1534
1535 /**
1536 * Get last generation time
1537 *
1538 * @return string|false
1539 */
1540 public function get_last_generated_time()
1541 {
1542 return get_option('metasync_sitemap_last_generated', false);
1543 }
1544
1545 /**
1546 * Count total URLs across all sitemaps
1547 *
1548 * @return int
1549 */
1550 public function count_urls()
1551 {
1552 return (int) get_option('metasync_sitemap_total_urls', 0);
1553 }
1554
1555 /**
1556 * Get the list of generated sitemap files
1557 *
1558 * @return array
1559 */
1560 public function get_sitemap_files()
1561 {
1562 return get_option('metasync_sitemap_files', []);
1563 }
1564
1565 /**
1566 * Get the number of sitemap files
1567 *
1568 * @return int
1569 */
1570 public function get_sitemap_count()
1571 {
1572 $files = $this->get_sitemap_files();
1573 return count($files);
1574 }
1575
1576 /**
1577 * Get the maximum URLs per sitemap file
1578 *
1579 * @return int
1580 */
1581 public function get_urls_per_sitemap()
1582 {
1583 return $this->urls_per_sitemap;
1584 }
1585
1586 /**
1587 * Delete sitemap files (physical and virtual)
1588 *
1589 * @param string $type One of 'all', 'general', 'news', 'video'. Defaults to 'all'.
1590 * @return bool
1591 */
1592 public function delete_sitemap($type = 'all')
1593 {
1594 if (!in_array($type, ['all', 'general', 'news', 'video'], true)) {
1595 return false;
1596 }
1597
1598 $deleted = false;
1599
1600 if ('general' === $type || 'all' === $type) {
1601 // Delete physical sitemap index
1602 if (file_exists($this->sitemap_index_path)) {
1603 @wp_delete_file($this->sitemap_index_path);
1604 $deleted = true;
1605 }
1606
1607 // Delete the virtual sitemap index transient by name. sitemap_exists()
1608 // reads this transient directly, so it must be cleared here even when
1609 // metasync_sitemap_virtual_index no longer tracks it (an out-of-sync
1610 // index leaves the transient orphaned, which made "Delete All Sitemaps"
1611 // report failure while the sitemap kept being served.
1612 $index_tkey = 'metasync_vsm_' . md5('sitemap_index.xml');
1613 if (false !== get_transient($index_tkey)) {
1614 delete_transient($index_tkey);
1615 $deleted = true;
1616 }
1617
1618 // Delete all physical sitemap files (sitemap.xml, sitemap2.xml, etc.)
1619 // and their virtual transients (chunks stored in memory are tracked
1620 // only by filename in metasync_sitemap_files).
1621 $sitemap_files = get_option('metasync_sitemap_files', []);
1622 foreach ($sitemap_files as $sitemap) {
1623 $filename = is_array($sitemap) && isset($sitemap['filename']) ? $sitemap['filename'] : null;
1624 if (null === $filename) {
1625 continue;
1626 }
1627 $path = ABSPATH . $filename;
1628 if (file_exists($path)) {
1629 @wp_delete_file($path);
1630 $deleted = true;
1631 }
1632 $chunk_tkey = 'metasync_vsm_' . md5($filename);
1633 if (false !== get_transient($chunk_tkey)) {
1634 delete_transient($chunk_tkey);
1635 $deleted = true;
1636 }
1637 }
1638
1639 // Delete only physical sitemap files tracked as MetaSync-owned.
1640 $sitemap_files = get_option('metasync_sitemap_files', []);
1641 $tracked_names = [];
1642 foreach ($sitemap_files as $sitemap) {
1643 if (is_array($sitemap) && !empty($sitemap['filename'])) {
1644 $tracked_names[] = basename($sitemap['filename']);
1645 }
1646 }
1647 foreach (array_unique($tracked_names) as $filename) {
1648 $file = ABSPATH . $filename;
1649 if (preg_match('/^sitemap\d*\.xml$/', $filename) && file_exists($file)) {
1650 @wp_delete_file($file);
1651 $deleted = true;
1652 }
1653 }
1654 }
1655
1656 if ('news' === $type || 'all' === $type) {
1657 $news_file = 'news-sitemap.xml';
1658 $tkey = 'metasync_vsm_' . md5($news_file);
1659 if (false !== get_transient($tkey)) {
1660 delete_transient($tkey);
1661 $deleted = true;
1662 }
1663 $physical = ABSPATH . $news_file;
1664 if (file_exists($physical)) {
1665 @wp_delete_file($physical);
1666 $deleted = true;
1667 }
1668
1669 // Clear the news sitemap "enabled" setting so the feature gate no
1670 // longer reports the sitemap feature as active once the file is gone.
1671 // Without this, is_feature_enabled() keeps returning true (because
1672 // metasync_news_sitemap_settings['enabled'] persists), so the
1673 // "Default Behavior" robots.txt template keeps re-adding a stale
1674 // Sitemap: line for a sitemap that no longer serves (WP-574).
1675 $news_settings = get_option('metasync_news_sitemap_settings', []);
1676 if (is_array($news_settings) && !empty($news_settings['enabled'])) {
1677 $news_settings['enabled'] = false;
1678 update_option('metasync_news_sitemap_settings', $news_settings);
1679 }
1680 }
1681
1682 if ('video' === $type || 'all' === $type) {
1683 $video_file = 'video-sitemap.xml';
1684 $tkey = 'metasync_vsm_' . md5($video_file);
1685 if (false !== get_transient($tkey)) {
1686 delete_transient($tkey);
1687 $deleted = true;
1688 }
1689 $physical = ABSPATH . $video_file;
1690 if (file_exists($physical)) {
1691 @wp_delete_file($physical);
1692 $deleted = true;
1693 }
1694
1695 // Clear the video sitemap "enabled" setting (see the news block above).
1696 $video_settings = get_option('metasync_video_sitemap_settings', []);
1697 if (is_array($video_settings) && !empty($video_settings['enabled'])) {
1698 $video_settings['enabled'] = false;
1699 update_option('metasync_video_sitemap_settings', $video_settings);
1700 }
1701 }
1702
1703 if ('general' === $type || 'all' === $type) {
1704 // Clear tracked virtual sitemap transients, preserving news/video
1705 // when deleting only the general sitemap.
1706 $preserved_filenames = ['news-sitemap.xml', 'video-sitemap.xml'];
1707 $virtual_index = get_option('metasync_sitemap_virtual_index', []);
1708 if (is_array($virtual_index)) {
1709 $remaining_index = [];
1710 foreach ($virtual_index as $filename => $tkey) {
1711 if ('general' === $type && in_array($filename, $preserved_filenames, true)) {
1712 $remaining_index[$filename] = $tkey;
1713 continue;
1714 }
1715 if (is_string($tkey) && $tkey !== '') {
1716 delete_transient($tkey);
1717 $deleted = true;
1718 }
1719 }
1720 if (!empty($remaining_index)) {
1721 update_option('metasync_sitemap_virtual_index', $remaining_index, false);
1722 } else {
1723 delete_option('metasync_sitemap_virtual_index');
1724 }
1725 }
1726
1727 // Clear virtual content if it exists.
1728 // Only fully disable virtual mode when deleting all — news/video
1729 // still need it when only the general sitemap is removed.
1730 if ('all' === $type) {
1731 $virtual_mode = get_option('metasync_sitemap_virtual_mode', false);
1732 $virtual_files = get_option('metasync_sitemap_virtual', array());
1733 if ($virtual_mode || !empty($virtual_files)) {
1734 delete_option('metasync_sitemap_virtual');
1735 delete_option('metasync_sitemap_virtual_mode');
1736 $deleted = true;
1737 }
1738 }
1739
1740 // Clear stored options. Clearing a tracking option that was actually
1741 // present counts as a successful deletion — otherwise the caller shows
1742 // a false-negative "Failed to delete sitemaps" after the state was in
1743 // fact cleared.
1744 foreach (['metasync_sitemap_files', 'metasync_sitemap_total_urls', 'metasync_sitemap_last_generated'] as $opt_name) {
1745 if (false !== get_option($opt_name, false)) {
1746 $deleted = true;
1747 }
1748 delete_option($opt_name);
1749 }
1750 }
1751
1752 // Keep robots.txt in sync with the sitemap state. Deleting a sitemap is
1753 // the inverse of generating one, so undo the robots.txt `Sitemap:` line
1754 // the generator added at generation time (see generate_news_sitemap(),
1755 // generate_video_sitemap() and update_robots_txt_sitemap()). Routing the
1756 // change back through the same Metasync_Robots_Txt functions means the
1757 // regenerated robots.txt is captured in the backup history exactly like a
1758 // manual save (WP-574).
1759 if ('general' === $type || 'all' === $type) {
1760 $this->remove_robots_sitemap_line($this->sitemap_index_url);
1761 }
1762 if ('news' === $type || 'all' === $type) {
1763 $this->remove_robots_sitemap_line(home_url('/news-sitemap.xml'));
1764 }
1765 if ('video' === $type || 'all' === $type) {
1766 $this->remove_robots_sitemap_line(home_url('/video-sitemap.xml'));
1767 }
1768
1769 return $deleted;
1770 }
1771
1772 /**
1773 * Remove a single MetaSync `Sitemap:` line from robots.txt, reusing the
1774 * robots.txt manager's own removal path.
1775 *
1776 * This is the inverse of the add/update performed when a sitemap is
1777 * generated. It delegates to {@see Metasync_Robots_Txt::remove_sitemap_url()},
1778 * which persists via write_robots_file() — so the current robots.txt is
1779 * backed up before the stale line is dropped, and the change shows up in the
1780 * backup history. A no-op (no write, no backup) when the line is not present
1781 * or the robots.txt manager class is unavailable.
1782 *
1783 * @param string $sitemap_url Absolute sitemap URL to remove.
1784 * @return void
1785 */
1786 private function remove_robots_sitemap_line($sitemap_url)
1787 {
1788 if (!class_exists('Metasync_Robots_Txt')) {
1789 $robots_txt_file = plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
1790 if (!file_exists($robots_txt_file)) {
1791 return;
1792 }
1793 require_once $robots_txt_file;
1794 }
1795
1796 Metasync_Robots_Txt::get_instance()->remove_sitemap_url($sitemap_url);
1797 }
1798
1799 /**
1800 * Check if other sitemap plugins are active
1801 *
1802 * @return array Array of active sitemap plugins
1803 */
1804 public function check_active_sitemap_plugins()
1805 {
1806 // Ensure plugin.php is loaded for is_plugin_active()
1807 if (!function_exists('is_plugin_active')) {
1808 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1809 }
1810
1811 $sitemap_plugins = [
1812 'wordpress-seo/wp-seo.php' => 'Yoast SEO',
1813 'all-in-one-seo-pack/all_in_one_seo_pack.php' => 'All in One SEO',
1814 'google-sitemap-generator/sitemap.php' => 'Google XML Sitemaps',
1815 'better-wp-google-xml-sitemaps/bwp-simple-gxs.php' => 'Better WordPress Google XML Sitemaps',
1816 'xml-sitemap-feed/xml-sitemap.php' => 'XML Sitemap & Google News',
1817 'sitemap/sitemap.php' => 'Google Sitemap Plugin',
1818 'rank-math/rank-math.php' => 'Rank Math SEO',
1819 'seo-by-rank-math/rank-math.php' => 'Rank Math SEO (Free)',
1820 'wordpress-seo-premium/wp-seo-premium.php' => 'Yoast SEO Premium',
1821 ];
1822
1823 $active_plugins = [];
1824 foreach ($sitemap_plugins as $plugin_path => $plugin_name) {
1825 if (is_plugin_active($plugin_path)) {
1826 $active_plugins[$plugin_path] = $plugin_name;
1827 }
1828 }
1829
1830 return $active_plugins;
1831 }
1832
1833 /**
1834 * Disable sitemap generation from other plugins
1835 *
1836 * @return bool True if any changes were made
1837 */
1838 public function disable_other_sitemap_generators()
1839 {
1840 $changes_made = false;
1841
1842 // Disable Yoast SEO sitemap
1843 if (class_exists('WPSEO_Options')) {
1844 $yoast_options = get_option('wpseo');
1845 if (isset($yoast_options['enable_xml_sitemap']) && $yoast_options['enable_xml_sitemap'] === true) {
1846 $yoast_options['enable_xml_sitemap'] = false;
1847 update_option('wpseo', $yoast_options);
1848 $changes_made = true;
1849 }
1850 }
1851
1852 // Disable All in One SEO sitemap (options stored as JSON string)
1853 if (function_exists('aioseo')) {
1854 $aioseo_raw = get_option('aioseo_options');
1855 $aioseo_options = is_string($aioseo_raw) ? json_decode($aioseo_raw, true) : $aioseo_raw;
1856 if (is_array($aioseo_options) && !empty($aioseo_options['sitemap']['general']['enable'])) {
1857 $aioseo_options['sitemap']['general']['enable'] = false;
1858 update_option('aioseo_options', is_string($aioseo_raw) ? wp_json_encode($aioseo_options) : $aioseo_options);
1859 $changes_made = true;
1860 }
1861 }
1862
1863 // Disable Rank Math sitemap
1864 if (class_exists('RankMath')) {
1865 $rank_math_modules = get_option('rank_math_modules', []);
1866 if (is_array($rank_math_modules) && in_array('sitemap', $rank_math_modules, true)) {
1867 $rank_math_modules = array_values(array_diff($rank_math_modules, ['sitemap']));
1868 update_option('rank_math_modules', $rank_math_modules);
1869 $changes_made = true;
1870 }
1871 }
1872
1873 // Disable WordPress core sitemap (WP 5.5+) - Store in option for persistence
1874 $wp_core_disabled = get_option('metasync_disable_wp_sitemap', false);
1875 if (!$wp_core_disabled) {
1876 update_option('metasync_disable_wp_sitemap', true);
1877 $changes_made = true;
1878 }
1879
1880 return $changes_made;
1881 }
1882
1883 /**
1884 * Re-enable sitemap generation from other plugins
1885 *
1886 * @return bool True if any changes were made
1887 */
1888 public function enable_other_sitemap_generators()
1889 {
1890 $changes_made = false;
1891
1892 // Re-enable Yoast SEO sitemap
1893 if (class_exists('WPSEO_Options')) {
1894 $yoast_options = get_option('wpseo');
1895 if (isset($yoast_options['enable_xml_sitemap']) && $yoast_options['enable_xml_sitemap'] === false) {
1896 $yoast_options['enable_xml_sitemap'] = true;
1897 update_option('wpseo', $yoast_options);
1898 $changes_made = true;
1899 }
1900 }
1901
1902 // Re-enable All in One SEO sitemap (options stored as JSON string)
1903 if (function_exists('aioseo')) {
1904 $aioseo_raw = get_option('aioseo_options');
1905 $aioseo_options = is_string($aioseo_raw) ? json_decode($aioseo_raw, true) : $aioseo_raw;
1906 if (is_array($aioseo_options) && isset($aioseo_options['sitemap']['general']['enable']) && !$aioseo_options['sitemap']['general']['enable']) {
1907 $aioseo_options['sitemap']['general']['enable'] = true;
1908 update_option('aioseo_options', is_string($aioseo_raw) ? wp_json_encode($aioseo_options) : $aioseo_options);
1909 $changes_made = true;
1910 }
1911 }
1912
1913 // Re-enable Rank Math sitemap
1914 if (class_exists('RankMath')) {
1915 $rank_math_modules = get_option('rank_math_modules', []);
1916 if (is_array($rank_math_modules) && !in_array('sitemap', $rank_math_modules, true)) {
1917 $rank_math_modules[] = 'sitemap';
1918 update_option('rank_math_modules', $rank_math_modules);
1919 $changes_made = true;
1920 }
1921 }
1922
1923 // Re-enable WordPress core sitemap (WP 5.5+)
1924 $wp_core_disabled = get_option('metasync_disable_wp_sitemap', false);
1925 if ($wp_core_disabled) {
1926 delete_option('metasync_disable_wp_sitemap');
1927 $changes_made = true;
1928 }
1929
1930 return $changes_made;
1931 }
1932
1933 /**
1934 * Setup automatic sitemap updates on post changes
1935 */
1936 public function setup_auto_update_hooks()
1937 {
1938 // Post hooks
1939 add_action('save_post', [$this, 'auto_update_sitemap'], 10, 2);
1940 add_action('delete_post', [$this, 'auto_update_sitemap_simple']);
1941 add_action('trashed_post', [$this, 'auto_update_sitemap_simple']);
1942 add_action('untrashed_post', [$this, 'auto_update_sitemap_simple']);
1943
1944 // Term hooks
1945 add_action('created_term', [$this, 'auto_update_sitemap_simple']);
1946 add_action('edited_term', [$this, 'auto_update_sitemap_simple']);
1947 add_action('delete_term', [$this, 'auto_update_sitemap_simple']);
1948
1949 // News sitemap auto-update on post save
1950 add_action('save_post', [$this, 'auto_update_news_sitemap'], 10, 2);
1951
1952 // Video sitemap auto-update on post save
1953 add_action('save_post', [$this, 'auto_update_video_sitemap'], 10, 2);
1954 }
1955
1956 /**
1957 * Auto update sitemap on post save
1958 *
1959 * @param int $post_id The post ID
1960 * @param WP_Post $post The post object
1961 */
1962 public function auto_update_sitemap($post_id, $post)
1963 {
1964 // Check if auto-update is enabled
1965 if (!get_option('metasync_sitemap_auto_update', false)) {
1966 return;
1967 }
1968
1969 // Avoid auto-updates during autosave, revisions, or drafts
1970 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
1971 return;
1972 }
1973
1974 // Get post object if not provided (some hooks don't pass it)
1975 if (empty($post)) {
1976 $post = get_post($post_id);
1977 }
1978
1979 // Validate post object exists
1980 if (!$post || !is_object($post)) {
1981 return;
1982 }
1983
1984 // Only update for published posts
1985 if ($post->post_status !== 'publish') {
1986 return;
1987 }
1988
1989 // Regenerate sitemap
1990 $this->generate_sitemap();
1991 }
1992
1993 /**
1994 * Auto update sitemap (simple version for hooks without post object)
1995 */
1996 public function auto_update_sitemap_simple()
1997 {
1998 // Check if auto-update is enabled
1999 if (!get_option('metasync_sitemap_auto_update', false)) {
2000 return;
2001 }
2002
2003 // Regenerate sitemap
2004 $this->generate_sitemap();
2005 }
2006
2007 /**
2008 * Update robots.txt with the sitemap index URL
2009 *
2010 * @return array Result with 'success' boolean and 'action' string
2011 */
2012 public function update_robots_txt_sitemap()
2013 {
2014 // Load the robots.txt class if not already loaded
2015 if (!class_exists('Metasync_Robots_Txt')) {
2016 $robots_txt_file = plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
2017 if (file_exists($robots_txt_file)) {
2018 require_once $robots_txt_file;
2019 } else {
2020 return [
2021 'success' => false,
2022 'action' => 'error',
2023 'message' => esc_html__('Robots.txt management class not found.', 'metasync')
2024 ];
2025 }
2026 }
2027
2028 // Get the robots.txt instance and update the sitemap index URL
2029 $robots_txt = Metasync_Robots_Txt::get_instance();
2030 return $robots_txt->update_sitemap_url($this->sitemap_index_url);
2031 }
2032
2033 /**
2034 * Check if robots.txt contains the correct sitemap URL
2035 *
2036 * @return bool True if robots.txt has the correct sitemap URL
2037 */
2038 public function robots_has_sitemap_url()
2039 {
2040 // Load the robots.txt class if not already loaded
2041 if (!class_exists('Metasync_Robots_Txt')) {
2042 $robots_txt_file = plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
2043 if (file_exists($robots_txt_file)) {
2044 require_once $robots_txt_file;
2045 } else {
2046 return false;
2047 }
2048 }
2049
2050 $robots_txt = Metasync_Robots_Txt::get_instance();
2051 return $robots_txt->has_sitemap_url($this->sitemap_index_url);
2052 }
2053
2054 /**
2055 * Generate the news sitemap.
2056 *
2057 * @return bool True on success, false on failure.
2058 */
2059 public function generate_news_sitemap()
2060 {
2061 require_once plugin_dir_path(__FILE__) . 'class-metasync-sitemap-news.php';
2062
2063 $news = new Metasync_Sitemap_News();
2064 $xml = $news->generate();
2065
2066 if (false === $xml) {
2067 return false;
2068 }
2069
2070 // Only advertise the sitemap once it is actually stored — otherwise
2071 // robots.txt and the Google ping point at content that cannot be served.
2072 if (false === $this->store_virtual_sitemap_file('news-sitemap.xml', $xml)) {
2073 return false;
2074 }
2075
2076 // Add to robots.txt
2077 if (!class_exists('Metasync_Robots_Txt')) {
2078 $robots_txt_file = plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
2079 if (file_exists($robots_txt_file)) {
2080 require_once $robots_txt_file;
2081 }
2082 }
2083
2084 if (class_exists('Metasync_Robots_Txt')) {
2085 $robots_txt = Metasync_Robots_Txt::get_instance();
2086 $robots_txt->add_sitemap_url(home_url('/news-sitemap.xml'));
2087 }
2088
2089 // Ping Google
2090 $news->ping_google(home_url('/news-sitemap.xml'));
2091
2092 return true;
2093 }
2094
2095 /**
2096 * Generate the video sitemap.
2097 *
2098 * @return bool True on success, false on failure.
2099 */
2100 public function generate_video_sitemap()
2101 {
2102 require_once plugin_dir_path(__FILE__) . 'class-metasync-sitemap-video.php';
2103
2104 $video = new Metasync_Sitemap_Video();
2105 $xml = $video->generate();
2106
2107 if (false === $xml) {
2108 return false;
2109 }
2110
2111 // Same as the news sitemap: only advertise it once it is actually stored.
2112 if (false === $this->store_virtual_sitemap_file('video-sitemap.xml', $xml)) {
2113 return false;
2114 }
2115
2116 // Record why entries were left out, so the admin screen can name the
2117 // actual cause instead of reporting a bare "0 URLs", which reads as
2118 // "this is normal" even on a site full of videos.
2119 //
2120 // Written AFTER the store succeeds: diagnostics that describe a
2121 // sitemap which was never published would be reported against the
2122 // older document still being served.
2123 update_option(
2124 'metasync_video_sitemap_diagnostics',
2125 [
2126 'skipped_no_thumbnail' => $video->get_skipped_no_thumbnail_count(),
2127 'skipped_no_thumbnail_ids' => $video->get_skipped_no_thumbnail_post_ids(),
2128 'skipped_no_thumbnail_videos' => $video->get_skipped_no_thumbnail_video_count(),
2129 'skipped_duplicate_thumbnail' => $video->get_skipped_duplicate_thumbnail_count(),
2130 'generated_at' => current_time('mysql'),
2131 ],
2132 false
2133 );
2134
2135 // Add to robots.txt
2136 if (!class_exists('Metasync_Robots_Txt')) {
2137 $robots_txt_file = plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
2138 if (file_exists($robots_txt_file)) {
2139 require_once $robots_txt_file;
2140 }
2141 }
2142
2143 if (class_exists('Metasync_Robots_Txt')) {
2144 $robots_txt = Metasync_Robots_Txt::get_instance();
2145 $robots_txt->add_sitemap_url(home_url('/video-sitemap.xml'));
2146 }
2147
2148 return true;
2149 }
2150
2151 /**
2152 * Auto-update news sitemap on post save.
2153 *
2154 * @param int $post_id The post ID.
2155 * @param WP_Post $post The post object.
2156 */
2157 public function auto_update_news_sitemap($post_id, $post)
2158 {
2159 $settings = get_option('metasync_news_sitemap_settings', []);
2160 if (empty($settings['enabled'])) {
2161 return;
2162 }
2163
2164 // Skip autosave and revisions
2165 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
2166 return;
2167 }
2168
2169 if (empty($post)) {
2170 $post = get_post($post_id);
2171 }
2172
2173 if (!$post || !is_object($post)) {
2174 return;
2175 }
2176
2177 if ($post->post_status !== 'publish') {
2178 return;
2179 }
2180
2181 // Only regenerate if the post was modified within the last 2 days
2182 $post_time = strtotime($post->post_modified_gmt);
2183 $cutoff = strtotime('-2 days');
2184 if (false === $post_time || false === $cutoff || $post_time < $cutoff) {
2185 return;
2186 }
2187
2188 $this->generate_news_sitemap();
2189 }
2190
2191 /**
2192 * Auto-update video sitemap on post save.
2193 *
2194 * @param int $post_id The post ID.
2195 * @param WP_Post $post The post object.
2196 */
2197 public function auto_update_video_sitemap($post_id, $post)
2198 {
2199 $settings = get_option('metasync_video_sitemap_settings', []);
2200 if (empty($settings['enabled'])) {
2201 return;
2202 }
2203
2204 // Skip autosave and revisions
2205 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
2206 return;
2207 }
2208
2209 if (empty($post)) {
2210 $post = get_post($post_id);
2211 }
2212
2213 if (!$post || !is_object($post)) {
2214 return;
2215 }
2216
2217 if ($post->post_status !== 'publish') {
2218 return;
2219 }
2220
2221 // Only regenerate if the post type is included in video sitemap settings
2222 $post_types = !empty($settings['post_types']) ? (array) $settings['post_types'] : ['post', 'page'];
2223 if (!in_array($post->post_type, $post_types, true)) {
2224 return;
2225 }
2226
2227 $this->generate_video_sitemap();
2228 }
2229
2230 /**
2231 * How long a stored sitemap document stays valid.
2232 *
2233 * Most sitemaps describe content that only changes when the site changes,
2234 * and bust_sitemap_cache() already clears them on any post or term edit,
2235 * so a long TTL is right for them.
2236 *
2237 * The news sitemap is different: Google News only accepts entries from the
2238 * last 48 hours, and the generator applies that window once, at build
2239 * time, via a `date_query`. Nothing re-evaluates it as the clock advances.
2240 * On a quiet site — a publisher pausing over a weekend, or any stretch
2241 * with no content edits to trigger a bust — a 30-day cache kept
2242 * advertising Friday's articles as news well past the window Google
2243 * accepts. Expiring inside that window hands the next request to the
2244 * existing regenerate-on-miss path, which rebuilds with a fresh
2245 * `date_query`. No cron needed.
2246 *
2247 * @param string $filename The sitemap filename.
2248 * @return int TTL in seconds.
2249 */
2250 private function get_sitemap_cache_ttl($filename)
2251 {
2252 if ('news-sitemap.xml' === $filename) {
2253 // Comfortably inside the 48-hour window, so a served document is
2254 // always rebuilt while its own entries are still eligible.
2255 // Expressed in DAY_IN_SECONDS because that is the only time
2256 // constant this class relies on being defined.
2257 return (int) (DAY_IN_SECONDS / 4);
2258 }
2259
2260 return 30 * DAY_IN_SECONDS;
2261 }
2262
2263 /**
2264 * Store virtual sitemap file content using individual transients.
2265 *
2266 * The transient is the only durable copy of a virtual sitemap, so a failed
2267 * write means the site has no sitemap to serve. Report that to the caller
2268 * instead of claiming success.
2269 *
2270 * @param string $filename The sitemap filename (e.g., 'sitemap.xml', 'sitemap_index.xml')
2271 * @param string $content The XML content
2272 * @return bool True on success, false when the content could not be stored
2273 */
2274 public function store_virtual_sitemap_file($filename, $content)
2275 {
2276 $cache_key = 'metasync_vsm_' . md5($filename);
2277 $stored = set_transient($cache_key, $content, $this->get_sitemap_cache_ttl($filename));
2278
2279 if (false === $stored) {
2280 // set_transient() also returns false when the stored value is
2281 // unchanged, because update_option() short-circuits identical
2282 // values. Only treat this as a failure when the content cannot be
2283 // read back.
2284 $stored = (get_transient($cache_key) === $content);
2285 }
2286
2287 if (false === $stored) {
2288 error_log('MetaSync: failed to store virtual sitemap "' . $filename . '" — the sitemap will not be served.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
2289 return false;
2290 }
2291
2292 // Track which virtual sitemaps exist
2293 $index = get_option('metasync_sitemap_virtual_index', []);
2294 $index[$filename] = $cache_key;
2295 update_option('metasync_sitemap_virtual_index', $index, false);
2296 update_option('metasync_sitemap_virtual_mode', true, false);
2297 return true;
2298 }
2299
2300 /**
2301 * Get virtual sitemap file content from transient.
2302 *
2303 * @param string $filename The sitemap filename
2304 * @return string|false Content or false if not found
2305 */
2306 private function get_virtual_sitemap_file($filename)
2307 {
2308 $cache_key = 'metasync_vsm_' . md5($filename);
2309 $content = get_transient($cache_key);
2310
2311 if (false !== $content) {
2312 return $content;
2313 }
2314
2315 // Fallback: check legacy option storage for backward compatibility
2316 $virtual_sitemaps = get_option('metasync_sitemap_virtual', []);
2317 if (isset($virtual_sitemaps[$filename])) {
2318 // Migrate to transient
2319 $this->store_virtual_sitemap_file($filename, $virtual_sitemaps[$filename]);
2320 unset($virtual_sitemaps[$filename]);
2321 if (empty($virtual_sitemaps)) {
2322 delete_option('metasync_sitemap_virtual');
2323 } else {
2324 update_option('metasync_sitemap_virtual', $virtual_sitemaps, false);
2325 }
2326 return get_transient($cache_key);
2327 }
2328
2329 return false;
2330 }
2331
2332 /**
2333 * Single source of truth for a sitemap document's content.
2334 *
2335 * Every consumer — the served route, the admin status tiles, the MCP
2336 * tools — must read through this accessor. They used to each implement
2337 * their own precedence and disagreed: the served route preferred the
2338 * physical file while the admin screen preferred the transient, so a site
2339 * holding both counted one document in the dashboard and handed crawlers
2340 * the other. No amount of regeneration reconciles that, because each side
2341 * keeps reading its own preferred copy.
2342 *
2343 * Transients win. Virtual mode is the supported storage model — the
2344 * generator has written nothing to disk since the physical writer was
2345 * retired — so a file at ABSPATH is always a foreign artifact: a host
2346 * backup/restore, a rollback to an older build, or a hand-placed copy.
2347 * Rather than let it shadow live content, absorb it the way activation's
2348 * migration would have: adopt it only when we have no transient, and
2349 * remove it once its content is safely stored.
2350 *
2351 * @param string $filename The sitemap filename (e.g. 'news-sitemap.xml').
2352 * @return string|false Content, or false when no source holds this file.
2353 */
2354 public function get_sitemap_document($filename)
2355 {
2356 $content = $this->get_virtual_sitemap_file($filename);
2357 if (false !== $content) {
2358 // A physical copy alongside live virtual content is exactly the
2359 // divergence that made the dashboard and crawlers disagree. The
2360 // transient is authoritative, so retire the file.
2361 $this->absorb_physical_sitemap($filename, false);
2362 return $content;
2363 }
2364
2365 // No transient: a physical file is the only copy left, so adopt it
2366 // instead of reporting the sitemap missing.
2367 return $this->absorb_physical_sitemap($filename, true);
2368 }
2369
2370 /**
2371 * Reconcile a stray physical sitemap file with virtual storage.
2372 *
2373 * Activation runs metasync_migrate_physical_sitemaps() once, so any file
2374 * appearing afterwards used to shadow the transient forever. Calling this
2375 * from the read path closes that window: the file is either adopted (when
2376 * it is the only copy) or removed (when a transient already holds the
2377 * live document).
2378 *
2379 * @param string $filename The sitemap filename.
2380 * @param bool $adopt Store the file's content before removing it.
2381 * @return string|false Adopted content, or false when nothing was adopted.
2382 */
2383 private function absorb_physical_sitemap($filename, $adopt)
2384 {
2385 // Reject traversal before touching the filesystem: $filename reaches
2386 // the served route from a query var.
2387 $basename = basename($filename);
2388 if ($basename !== $filename || '' === $basename) {
2389 return false;
2390 }
2391
2392 $physical_path = ABSPATH . $basename;
2393 if (!file_exists($physical_path) || !is_readable($physical_path)) {
2394 return false;
2395 }
2396
2397 if (!$adopt) {
2398 $this->safe_unlink($physical_path);
2399 return false;
2400 }
2401
2402 $content = file_get_contents($physical_path);
2403 if (false === $content) {
2404 return false;
2405 }
2406
2407 // Only drop the file once its content is durably stored, so a failed
2408 // write can never destroy the site's last copy of the sitemap.
2409 if ($this->store_virtual_sitemap_file($basename, $content)) {
2410 $this->safe_unlink($physical_path);
2411 }
2412
2413 return $content;
2414 }
2415
2416 /**
2417 * Absorb stray physical sitemap files, from a hook that always runs PHP.
2418 *
2419 * get_sitemap_document() reconciles a stray file whenever something reads
2420 * that document, but on Apache the one request that most needs it never
2421 * arrives: WordPress' rewrite block ends with
2422 * `RewriteCond %{REQUEST_FILENAME} !-f`, so a physical news-sitemap.xml at
2423 * ABSPATH is served straight off disk and index.php is never loaded. The
2424 * file would then shadow live content until an admin happened to open the
2425 * XML Sitemap screen. Running from `init` means any page view on the site
2426 * closes the window instead.
2427 *
2428 * Kept deliberately cheap, because this runs on ordinary requests:
2429 *
2430 * - throttled to once per hour per site via a transient, so the common
2431 * case costs one cache read and nothing else;
2432 * - only ever looks at the handful of filenames MetaSync itself owns —
2433 * never a glob, and never a third-party provider's sitemap;
2434 * - stats the filesystem before reading any transient, so a site with no
2435 * stray files (the normal state) does a few file_exists() calls an hour
2436 * and no document reads at all;
2437 * - self-limiting, since absorbing a file removes it.
2438 *
2439 * @return void
2440 */
2441 public function absorb_stray_physical_sitemaps()
2442 {
2443 // This sweep is deliberately read/reconcile-only: it only handles
2444 // filenames already owned by MetaSync and is throttled below.
2445 // It must remain available on frontend requests because Apache may
2446 // otherwise serve a stale physical sitemap before WordPress routing.
2447
2448 // Cheap cross-request throttle. A miss here is the only path that
2449 // touches the filesystem. Expressed in DAY_IN_SECONDS because that is
2450 // the only time constant this class relies on being defined.
2451 $throttle_key = 'metasync_sitemap_absorb_sweep';
2452 if (false !== get_transient($throttle_key)) {
2453 return;
2454 }
2455 set_transient($throttle_key, 1, (int) (DAY_IN_SECONDS / 24));
2456
2457 foreach ($this->get_owned_sitemap_filenames() as $filename) {
2458 // Stat first: the overwhelmingly common case is that no stray file
2459 // exists, and that must not cost a transient read per filename.
2460 if (!file_exists(ABSPATH . $filename)) {
2461 continue;
2462 }
2463
2464 // Reading through the accessor is what performs the reconciliation:
2465 // it adopts the file when it is the only copy and removes it when a
2466 // transient already holds the live document.
2467 $this->get_sitemap_document($filename);
2468 }
2469 }
2470
2471 /**
2472 * The sitemap filenames MetaSync itself serves.
2473 *
2474 * Used by the absorption sweep so it can never disturb a file belonging to
2475 * another sitemap provider. News and video are fixed; the general sitemap's
2476 * chunk names come from our own tracking option and the virtual index,
2477 * which together describe every document this plugin has produced.
2478 *
2479 * @return string[] Unique basenames, no paths.
2480 */
2481 private function get_owned_sitemap_filenames()
2482 {
2483 $names = array('news-sitemap.xml', 'video-sitemap.xml', 'sitemap.xml', 'sitemap_index.xml');
2484
2485 $tracked = get_option('metasync_sitemap_files', array());
2486 if (is_array($tracked)) {
2487 foreach ($tracked as $file) {
2488 if (is_array($file) && !empty($file['filename']) && is_string($file['filename'])) {
2489 $names[] = $file['filename'];
2490 }
2491 }
2492 }
2493
2494 $virtual_index = get_option('metasync_sitemap_virtual_index', array());
2495 if (is_array($virtual_index)) {
2496 foreach (array_keys($virtual_index) as $filename) {
2497 if (is_string($filename) && '' !== $filename) {
2498 $names[] = $filename;
2499 }
2500 }
2501 }
2502
2503 // basename() guards against a tracking option that somehow carries a
2504 // path; absorb_physical_sitemap() rejects those anyway.
2505 return array_unique(array_map('basename', $names));
2506 }
2507
2508 /**
2509 * Check if virtual mode is active
2510 *
2511 * @return bool True if virtual mode is active
2512 */
2513 public function is_virtual_mode()
2514 {
2515 return get_option('metasync_sitemap_virtual_mode', false) === true;
2516 }
2517
2518 /**
2519 * Serve sitemap files via template_redirect.
2520 *
2521 * Resolves the requested sitemap filename in two ways: first via the
2522 * `metasync_sitemap` query var populated by our rewrite rules (the primary
2523 * path), and second via REQUEST_URI parsing (kept as a fallback for sites
2524 * with rewrites disabled). Content is read through get_sitemap_document(),
2525 * the shared accessor the admin screen and MCP tools also use, so every
2526 * consumer reports the same document. It streams from PHP rather than
2527 * leaving static files to the web server, which is unsafe because some
2528 * nginx configurations 403 static .xml files.
2529 */
2530 public function serve_virtual_sitemap()
2531 {
2532 if (is_admin()) {
2533 return;
2534 }
2535
2536 $filename = '';
2537
2538 // Whether our own rewrite rule claimed this URL. Only those requests may
2539 // be hard-404'd when no sitemap exists — see the miss handling below.
2540 $matched_rewrite = false;
2541
2542 // Primary path: query var set by our rewrite rule.
2543 $qv = get_query_var('metasync_sitemap');
2544 if (!empty($qv)) {
2545 $filename = sanitize_file_name($qv);
2546 if (!preg_match('/^(news-|video-)?sitemap(_index)?\d*\.xml$/', $filename)) {
2547 return;
2548 }
2549 $matched_rewrite = true;
2550 } else {
2551 // Fallback path: REQUEST_URI regex for sites where rewrite rules are
2552 // not engaged (e.g. pretty permalinks disabled or pre-flush state).
2553 $request_uri = isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])) : '';
2554 $request_uri = strtok($request_uri, '?');
2555
2556 $sitemap_pattern = '/\/((news-|video-)?sitemap(_index)?\d*\.xml)$/';
2557 if (!preg_match($sitemap_pattern, $request_uri, $matches)) {
2558 return;
2559 }
2560
2561 $filename = $matches[1];
2562 }
2563
2564 if (empty($filename)) {
2565 return;
2566 }
2567
2568 // Read through the shared accessor so the served document and the
2569 // admin status tiles can never derive from different sources. It also
2570 // absorbs a stray physical file at ABSPATH — we cannot rely on the web
2571 // server to serve one anyway: on some nginx configurations direct .xml
2572 // requests are 403'd before PHP is involved.
2573 $virtual_content = $this->get_sitemap_document($filename);
2574
2575 // Regenerate-on-miss: if the transient expired, rebuild on the spot
2576 // instead of returning 404. Only regenerate for known sitemaps to
2577 // prevent DoS via requests to non-existent sitemap numbers.
2578 //
2579 // The rebuild walks the whole content base, so a crawler fetching the
2580 // index and its children in parallel right after the transients
2581 // expire used to trigger one full regeneration per concurrent
2582 // request. A cross-request lock lets a single request rebuild while
2583 // the others briefly poll for its result instead of racing it.
2584 if (false === $virtual_content) {
2585 $lock_name = 'metasync_vsm_regenlock_' . md5($filename);
2586 if ($this->acquire_regen_lock($lock_name)) {
2587 // Double-check inside the lock: another request may have
2588 // rebuilt this file between our miss and the acquire.
2589 $virtual_content = $this->get_virtual_sitemap_file($filename);
2590 if (false === $virtual_content) {
2591 if ($filename === 'news-sitemap.xml') {
2592 $this->generate_news_sitemap();
2593 } elseif ($filename === 'video-sitemap.xml') {
2594 $this->generate_video_sitemap();
2595 } elseif ($this->is_sitemap_enabled()) {
2596 // only rebuild the general sitemap when the feature is
2597 // actually in use. Never fabricate a sitemap the site has not
2598 // opted into — the previous `empty($known_files)` fallback
2599 // regenerated our sitemap on ANY request, which silently undid
2600 // "Delete All Sitemaps" and shadowed a third-party sitemap.
2601 $known_files = get_option('metasync_sitemap_files', []);
2602 $known_names = array_column($known_files, 'filename');
2603 $known_names[] = 'sitemap_index.xml';
2604 if (in_array($filename, $known_names, true)) {
2605 $this->generate_sitemap();
2606 }
2607 }
2608 $virtual_content = $this->get_virtual_sitemap_file($filename);
2609 }
2610 delete_option($lock_name);
2611 } else {
2612 // Another request is rebuilding this sitemap. Poll briefly
2613 // for its result instead of starting a duplicate full
2614 // rebuild; on timeout fall through to the normal miss
2615 // handling below.
2616 $deadline = microtime(true) + 5;
2617 do {
2618 usleep(250000);
2619 $virtual_content = $this->get_virtual_sitemap_file($filename);
2620 } while (false === $virtual_content && microtime(true) < $deadline);
2621 }
2622 }
2623
2624 if (false === $virtual_content) {
2625 // Our rewrite rule claimed this URL but the site has no sitemap for
2626 // it — e.g. /sitemap7.xml on a site with a single sitemap. Returning
2627 // here hands the request back to the template loader, which renders
2628 // the home page under a 200 because `metasync_sitemap` is not a
2629 // content query var. That turns `^sitemap(\d*)\.xml$` into an
2630 // unlimited supply of soft-404 duplicate-content URLs. Send a real
2631 // 404 with no body instead.
2632 //
2633 // Only the rewrite-matched path is 404'd. The REQUEST_URI fallback
2634 // above is unanchored and also matches nested paths such as
2635 // /blog/sitemap.xml, plus sitemaps owned by another provider on
2636 // installs where our rules are not engaged; those must keep falling
2637 // through to whoever can serve them.
2638 if ($matched_rewrite) {
2639 metasync_discard_buffered_output();
2640 status_header(404);
2641 nocache_headers();
2642 header('X-Robots-Tag: noindex');
2643 exit;
2644 }
2645
2646 return;
2647 }
2648
2649 metasync_discard_buffered_output();
2650 header('Content-Type: application/xml; charset=utf-8');
2651 header('X-Robots-Tag: noindex');
2652 status_header(200);
2653 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- XML sitemap document served as application/xml; entries are escaped upstream by the sitemap builder
2654 echo $virtual_content;
2655 exit;
2656 }
2657
2658 /**
2659 * Atomically acquire the regenerate-on-miss lock for a sitemap file.
2660 *
2661 * A plain set_transient() cannot arbitrate a race: whether it adds or
2662 * updates is not atomic, so simultaneous requests can all believe they
2663 * won. add_option() performs a unique-key INSERT, which the database
2664 * serializes, so exactly one request acquires the lock even on installs
2665 * without a persistent object cache.
2666 *
2667 * The option value is the acquisition timestamp. A rebuild that crashes
2668 * without releasing would otherwise pin the sitemap forever, so an
2669 * attempt against a lock older than the TTL clears it and retries.
2670 *
2671 * @param string $lock_name Full option name for the lock.
2672 * @return bool True when this request acquired the lock.
2673 */
2674 private function acquire_regen_lock($lock_name)
2675 {
2676 $existing = get_option($lock_name);
2677 if (false !== $existing) {
2678 if ((int) $existing >= time() - 300) {
2679 return false;
2680 }
2681 // Expired lock from a crashed rebuild: clear it and try to win
2682 // the INSERT below.
2683 delete_option($lock_name);
2684 }
2685 return (bool) add_option($lock_name, time(), '', false);
2686 }
2687 }
2688