PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
thinkrank / includes / seo / class-sitemap-generator.php

class-sitemap-generator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.11.0, at includes/seo/class-sitemap-generator.php

4,606 lines 178.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sitemap Generator Class
4 *
5 * XML sitemap generation and management for search engine optimization.
6 * Implements 2025 SEO best practices with real sitemap specifications,
7 * dynamic content inclusion, and performance optimization.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 use InvalidArgumentException;
19
20 // Prevent direct access.
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit;
23 }
24
25 // Filename derivation and web-root removal are shared with the deactivator and
26 // with uninstall.php, which runs without an autoloader — so they live in a
27 // plain function file both can require. See includes/cleanup-webroot.php.
28 require_once __DIR__ . '/../cleanup-webroot.php';
29
30 /**
31 * Sitemap Generator Class
32 *
33 * Generates and manages XML sitemaps for search engine optimization.
34 * Provides dynamic sitemap generation with content filtering, priority
35 * calculation, and change frequency optimization.
36 *
37 * @since 1.0.0
38 */
39 class Sitemap_Generator extends Abstract_SEO_Manager {
40
41 /**
42 * Memoised WooCommerce page IDs kept out of the sitemap. Null until resolved.
43 *
44 * @since 2.0.1
45 * @var int[]|null
46 */
47 private ?array $woocommerce_excluded_page_ids = null;
48
49 /**
50 * Inclusion flag -> the child sitemap it controls.
51 *
52 * Shared by the child-list builder and by the "did the caller change what
53 * the sitemap includes?" check in maybe_promote_to_index().
54 *
55 * @since 1.31.0
56 * @var array<string, string>
57 */
58 private const INCLUSION_CHILD_TYPES = [
59 'include_posts' => 'posts',
60 'include_pages' => 'pages',
61 'include_categories' => 'categories',
62 'include_tags' => 'tags',
63 ];
64
65 /**
66 * Child sitemap type -> the object that actually supplies its entries.
67 *
68 * A child normally carries its object's own slug, but the sitemap presets UI
69 * writes a display name for the WooCommerce taxonomy child
70 * ('product_categories', not 'product_cat'), so a saved child list can name a
71 * type no post type or taxonomy answers to. Resolving through here lets those
72 * children take the same generic path as every other custom taxonomy instead
73 * of needing a case of their own (#690).
74 *
75 * @since 2.7.0
76 * @var array<string, string>
77 */
78 private const CHILD_TYPE_ALIASES = [
79 'product_categories' => 'product_cat',
80 ];
81
82 /**
83 * Supported sitemap types
84 *
85 * @since 1.0.0
86 * @var array
87 */
88 /**
89 * How many post IDs to hydrate at a time while walking the sitemap set.
90 *
91 * @since 2.0.1
92 * @var int
93 */
94 private const ID_WALK_CHUNK = 500;
95
96 /**
97 * How long a content or settings change is debounced before the sitemap is
98 * rebuilt, in seconds. Coalesces bulk edits into a single regeneration.
99 *
100 * @since 2.2.1
101 * @var int
102 */
103 private const REGENERATION_DEBOUNCE = 30;
104
105 /**
106 * How far past its due time the scheduled rebuild may sit before a request
107 * takes it over, in seconds.
108 *
109 * WP-Cron is request-driven, so on a site running DISABLE_WP_CRON, blocking
110 * loopback requests, or seeing very little traffic the event never fires and
111 * the sitemap silently stops updating (#629). The grace keeps the fast path
112 * (cron) in charge under normal conditions.
113 *
114 * @since 2.2.1
115 * @var int
116 */
117 private const REGENERATION_TAKEOVER_GRACE = 120;
118
119 /**
120 * Longest backoff between takeover attempts after a failed rebuild, so a
121 * persistently failing generation cannot run on every admin request.
122 *
123 * @since 2.2.1
124 * @var int
125 */
126 private const REGENERATION_MAX_BACKOFF = 3600;
127
128 /**
129 * Option holding the rebuild that content/settings changes are still waiting
130 * on: `since`, `source` ('content'|'settings'), `attempts`, `next_attempt`.
131 * Absent means the served sitemap is up to date with what triggered it.
132 *
133 * @since 2.2.1
134 * @var string
135 */
136 public const REGENERATION_PENDING_OPTION = 'thinkrank_sitemap_regeneration_pending';
137
138 /**
139 * Option holding the last automatic-regeneration failure (`message`,
140 * `source`, `time`), so the failure is visible instead of swallowed.
141 *
142 * @since 2.2.1
143 * @var string
144 */
145 public const REGENERATION_ERROR_OPTION = 'thinkrank_sitemap_regeneration_error';
146
147 /**
148 * Delivery modes accepted by the `delivery_mode` setting.
149 *
150 * Mirrors LLMs_Txt_Manager::DELIVERY_MODES, which solved the same problem
151 * for llms.txt. `auto` is the only value a site should normally need.
152 *
153 * @since 2.9.0
154 * @var string[]
155 */
156 public const DELIVERY_MODES = ['auto', 'static', 'dynamic'];
157
158 /**
159 * Cache group for documents rendered on the dynamic path.
160 *
161 * @since 2.9.0
162 * @var string
163 */
164 private const DYNAMIC_CACHE_PREFIX = 'thinkrank_sitemap_doc_';
165
166 /**
167 * How long a dynamically rendered document is cached.
168 *
169 * Invalidated by content and settings changes through
170 * {@see self::flush_dynamic_cache()}, so this is only the backstop for a
171 * change nothing hooked.
172 *
173 * @since 2.9.0
174 * @var int
175 */
176 private const DYNAMIC_CACHE_TTL = 12 * HOUR_IN_SECONDS;
177
178 /**
179 * How long the render lock is held before it is assumed abandoned.
180 *
181 * Long enough for a large site's full build, short enough that a request
182 * killed mid-build does not lock the endpoint out for meaningfully long.
183 *
184 * @since 2.9.0
185 * @var int
186 */
187 private const RENDER_LOCK_TTL = 60;
188
189 /**
190 * Cached stand-in for "this site does not publish that name".
191 *
192 * published_document_names() lists what the configuration *could* produce,
193 * but a child whose type is excluded produces nothing. Without a negative
194 * entry those names miss the cache forever, so every request for one
195 * rebuilt the entire sitemap — the same cost the positive cache exists to
196 * avoid, on a public endpoint (#754 review).
197 *
198 * @since 2.9.0
199 * @var string
200 */
201 private const ABSENT_MARKER = "\0thinkrank-absent";
202
203 /**
204 * How many times, and how long, a losing request waits for the winner.
205 *
206 * Bounded at roughly a second in total: past that, building a second copy
207 * costs less than making a crawler wait.
208 *
209 * @since 2.9.0
210 * @var int
211 */
212 private const RENDER_LOCK_WAIT_ATTEMPTS = 4;
213
214 /**
215 * @since 2.9.0
216 * @var int
217 */
218 private const RENDER_LOCK_WAIT_MICROSECONDS = 250000;
219
220 /**
221 * Where generated documents go instead of disk, when set.
222 *
223 * Every sitemap document this class produces — segments, the index, the
224 * single flat file and local-sitemap.xml — is published through the one
225 * writer, {@see self::save_sitemap_to_file()}. Swapping that writer for a
226 * collector is therefore all it takes to render the same bytes without a
227 * filesystem, which is what dynamic delivery needs (#752). Doing it here
228 * rather than duplicating the build pipeline is deliberate: a second
229 * pipeline would drift from this one, and the index in particular is
230 * assembled from whatever the children actually produced.
231 *
232 * @since 2.9.0
233 * @var callable|null
234 */
235 private $document_sink = null;
236
237 /**
238 * Transient guarding against two generations running at once. Shared with
239 * Sitemap_Endpoint's manual generate route so an automatic rebuild and a
240 * manual one cannot write the same files concurrently.
241 *
242 * @since 2.2.1
243 * @var string
244 */
245 public const GENERATION_LOCK_TRANSIENT = 'thinkrank_sitemap_generation_lock';
246
247 /**
248 * How many term IDs to hydrate at a time while walking a taxonomy.
249 *
250 * @since 2.0.1
251 * @var int
252 */
253 private const TERM_WALK_CHUNK = 1000;
254
255 /**
256 * Exception code for an automatic rebuild stopped short of the memory limit.
257 *
258 * @since 2.10.1
259 * @var int
260 */
261 private const MEMORY_ABORT_CODE = 4290;
262
263 /**
264 * Whether the chunked walks should stop before the memory limit. Only on
265 * for automatic rebuilds, whose failure is recorded and retried.
266 *
267 * @since 2.10.1
268 * @var bool
269 */
270 private bool $memory_guard = false;
271
272 /**
273 * Largest memory cost of one walked chunk in this rebuild, in bytes.
274 *
275 * @since 2.10.1
276 * @var int
277 */
278 private int $walk_chunk_cost = 0;
279
280 private array $sitemap_types = [
281 'posts' => [
282 'name' => 'Posts',
283 'post_types' => ['post'],
284 'priority' => 0.8,
285 'changefreq' => 'weekly'
286 ],
287 'pages' => [
288 'name' => 'Pages',
289 'post_types' => ['page'],
290 'priority' => 0.9,
291 'changefreq' => 'monthly'
292 ],
293 'categories' => [
294 'name' => 'Categories',
295 'taxonomy' => 'category',
296 'priority' => 0.6,
297 'changefreq' => 'weekly'
298 ],
299 'tags' => [
300 'name' => 'Tags',
301 'taxonomy' => 'post_tag',
302 'priority' => 0.4,
303 'changefreq' => 'monthly'
304 ]
305 ];
306
307 /**
308 * The generator the content-change listeners share, built on the first
309 * change of a request.
310 *
311 * @since 2.10.1
312 * @var self|null
313 */
314 private static ?self $listener = null;
315
316 /**
317 * Constructor
318 *
319 * @since 1.0.0
320 * @since 2.10.1 Registers no hooks, whatever `$register_hooks` says. The
321 * content-change listeners are registered once, at bootstrap,
322 * by register_content_listeners().
323 *
324 * @param bool $register_hooks Unused since 2.10.1; kept so existing callers,
325 * Pro's included, keep working.
326 */
327 public function __construct(bool $register_hooks = true) {
328 parent::__construct('sitemap');
329 }
330
331 /**
332 * Filter the args of a sitemap post query.
333 *
334 * Exists so integrations can widen what the sitemap sees — the multilingual
335 * manager uses it to include every language, since these queries otherwise
336 * run in whichever language happened to be active at generation time.
337 *
338 * @since 1.23.0
339 *
340 * @param array $args get_posts() arguments.
341 * @return array Filtered arguments.
342 */
343 private function filter_query_args(array $args): array {
344 /**
345 * Filter the arguments of a sitemap post query.
346 *
347 * @since 1.23.0
348 *
349 * @param array $args get_posts() arguments.
350 */
351 return (array) apply_filters('thinkrank_sitemap_query_args', $args);
352 }
353
354 /**
355 * Filter the args of a sitemap term query.
356 *
357 * @since 1.23.0
358 *
359 * @param array $args get_terms() arguments.
360 * @return array Filtered arguments.
361 */
362 private function filter_term_query_args(array $args): array {
363 /**
364 * Filter the arguments of a sitemap term query.
365 *
366 * @since 1.23.0
367 *
368 * @param array $args get_terms() arguments.
369 */
370 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
371 }
372
373 /**
374 * Register the content-change listeners that queue an automatic rebuild.
375 *
376 * Called once per request, at plugin bootstrap, next to the WP-Cron
377 * listeners that run the rebuild these queue (both in
378 * Plugin::register_sitemap_cron_listeners(), on plugins_loaded). The
379 * constructor used to register them, so they existed only in a request
380 * that happened to build a generator: the REST endpoint, the setup wizard,
381 * an MCP ability. Block-editor saves go through REST and were heard. A
382 * scheduled post published by WP-Cron, Quick Edit, the classic editor and
383 * WP-CLI were not, so the post stayed out of the sitemap with nothing
384 * pending to recover it (#824). Each instance also added its own set, so
385 * one REST save ran the handlers once per generator built.
386 *
387 * The generator is built on the first change and shared for the rest of
388 * the request, so a bulk edit does not construct one per post.
389 *
390 * @since 2.10.1
391 * @return void
392 */
393 public static function register_content_listeners(): void {
394 // Priority 20, to run after other plugins.
395 add_action('save_post', static function (int $post_id, \WP_Post $post): void {
396 self::listener()->handle_content_change($post_id, $post);
397 }, 20, 2);
398 add_action('delete_post', static function (int $post_id): void {
399 self::listener()->handle_content_deletion($post_id);
400 }, 20);
401 add_action('wp_trash_post', static function (int $post_id): void {
402 self::listener()->handle_content_deletion($post_id);
403 }, 20);
404 add_action('untrash_post', static function (int $post_id): void {
405 self::listener()->handle_content_change_by_id($post_id);
406 }, 20);
407
408 foreach (['created_term', 'edited_term', 'delete_term'] as $hook) {
409 add_action($hook, static function (int $term_id, int $tt_id, string $taxonomy): void {
410 self::listener()->handle_taxonomy_change($term_id, $tt_id, $taxonomy);
411 }, 20, 3);
412 }
413 }
414
415 /**
416 * The generator the content-change listeners share.
417 *
418 * @since 2.10.1
419 * @return self
420 */
421 private static function listener(): self {
422 return self::$listener ??= new self(false);
423 }
424
425 /**
426 * Generate XML sitemap
427 *
428 * @since 1.0.0
429 *
430 * @param array $options Sitemap generation options
431 * @return string XML sitemap content
432 */
433 public function generate_sitemap(array $options = []): string {
434 $settings = $this->get_settings('site');
435
436 $xml = $this->xml_prolog($settings, 'sitemap');
437
438 // Add image namespace if images are enabled
439 if (!empty($settings['include_images'])) {
440 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
441 } else {
442 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
443 }
444
445 // Add homepage. Use the static front page's real modified time when set,
446 // so lastmod reflects real changes rather than the generation time.
447 $xml .= $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
448
449 // MEMORY-EFFICIENT: Generate posts using chunked processing.
450 // Note: the single "general" sitemap includes every URL (no per-file cap);
451 // per-file chunking applies to the segmented/index model where each type
452 // gets its own paginated file (see generate_multiple_sitemaps).
453 $enabled_post_types = $this->get_enabled_post_types($settings);
454 if (!empty($enabled_post_types)) {
455 $xml .= implode('', $this->collect_post_entries($enabled_post_types, $settings));
456 }
457
458 // OPTIMIZED: Get all enabled taxonomies and fetch in single query
459 $enabled_taxonomies = $this->get_enabled_taxonomies($settings);
460 if (!empty($enabled_taxonomies)) {
461 $all_taxonomy_data = $this->fetch_taxonomies_optimized($enabled_taxonomies, $settings);
462
463 // Process each taxonomy's data
464 foreach ($enabled_taxonomies as $taxonomy) {
465 if (!empty($all_taxonomy_data[$taxonomy])) {
466 $xml .= $this->process_taxonomies_to_xml($all_taxonomy_data[$taxonomy], $taxonomy, $settings);
467 }
468 }
469 }
470
471 $xml .= '</urlset>';
472
473 return $xml;
474 }
475
476 /**
477 * Validate SEO settings (implements interface)
478 *
479 * @since 1.0.0
480 *
481 * @param array $settings Settings array to validate
482 * @return array Validation results
483 */
484 public function validate_settings(array $settings): array {
485 $validation = [
486 'valid' => true,
487 'errors' => [],
488 'warnings' => [],
489 'suggestions' => []
490 ];
491
492 try {
493 // Validate exclude_posts
494 if (!empty($settings['exclude_posts'])) {
495 $this->validate_exclude_posts($settings['exclude_posts']);
496 }
497
498 // Validate exclude_terms
499 if (!empty($settings['exclude_terms'])) {
500 $this->validate_exclude_terms($settings['exclude_terms']);
501 }
502
503 // Validate custom_url_pattern
504 if (!empty($settings['custom_url_pattern'])) {
505 $this->validate_custom_url_pattern($settings['custom_url_pattern']);
506 }
507
508 // Validate sitemap_urls
509 if (!empty($settings['sitemap_urls']) && is_array($settings['sitemap_urls'])) {
510 foreach ($settings['sitemap_urls'] as $sitemap) {
511 if (!empty($sitemap['url'])) {
512 $this->validate_sitemap_url($sitemap['url']);
513 }
514 }
515 }
516
517 // Validate numeric settings
518 if (isset($settings['links_per_sitemap'])) {
519 $this->validate_links_per_sitemap($settings['links_per_sitemap']);
520 }
521
522 } catch (InvalidArgumentException $e) {
523 $validation['valid'] = false;
524 $validation['errors'][] = $e->getMessage();
525 }
526
527 return $validation;
528 }
529
530 /**
531 * Get output data for frontend rendering (implements interface)
532 *
533 * @since 1.0.0
534 *
535 * @param string $context_type The context type
536 * @param int|null $context_id Optional. Context ID
537 * @return array Output data ready for frontend rendering
538 */
539 public function get_output_data(string $context_type, ?int $context_id): array {
540 $settings = $this->get_settings($context_type, $context_id);
541
542 return [
543 'sitemap_url' => home_url('/sitemap.xml'),
544 'enabled' => $settings['enabled'] ?? true,
545 'last_generated' => $settings['last_generated'] ?? '',
546 'total_urls' => $this->count_sitemap_urls($settings)
547 ];
548 }
549
550 /**
551 * Sitemap keys outside the defaults.
552 *
553 * @since 2.0.1
554 *
555 * @return string[]
556 */
557 protected function additional_setting_keys(): array {
558 return ['selected_preset'];
559 }
560
561 /**
562 * Inclusion flags are per post type and per taxonomy.
563 *
564 * A site registering a `product` post type stores `include_product`; an
565 * enumerated list would go stale on the next registration, so the family
566 * is matched instead.
567 *
568 * @since 2.0.1
569 *
570 * @return string[]
571 */
572 protected function dynamic_setting_key_patterns(): array {
573 return ['/^include_[a-z0-9_]+$/', '/^exclude_[a-z0-9_]+$/'];
574 }
575
576 /**
577 * Get default settings for a context type (implements interface)
578 *
579 * @since 1.0.0
580 *
581 * @param string $context_type The context type to get defaults for
582 * @return array Default settings array
583 */
584 public function get_default_settings(string $context_type): array {
585 return [
586 // Core settings
587 'enabled' => true,
588
589 // Multiple Sitemap URLs
590 'sitemap_urls' => [
591 [
592 'url' => '/sitemap.xml',
593 'type' => 'general',
594 'enabled' => true,
595 'last_checked' => null,
596 'status' => 'unknown'
597 ]
598 ],
599 'use_sitemap_index' => false,
600
601 // How the sitemap reaches crawlers. 'auto' keeps the historical
602 // behaviour wherever the web root is writable, and only falls back
603 // to serving the sitemap from PHP where writing a file is
604 // impossible — previously a hard failure with nothing served
605 // (#752).
606 'delivery_mode' => 'auto',
607
608 // General Settings
609 'links_per_sitemap' => 1000,
610 'include_images' => true,
611 'include_featured_images' => false,
612 'auto_generate' => true,
613 'ping_search_engines' => true,
614
615 // Content Inclusion (backward compatibility)
616 'include_posts' => true,
617 'include_pages' => true,
618 'include_categories' => true,
619 'include_tags' => false,
620
621 // Content Filtering
622 'exclude_posts' => '',
623 'exclude_terms' => '',
624 'exclude_password_protected' => true,
625 'exclude_private_posts' => true,
626
627 // Advanced Options
628 'enable_styling' => true,
629 'custom_url_pattern' => 'sitemap-{type}.xml',
630
631 // Stylesheet branding (#639). Both colours default to empty, not
632 // to the stock hexes: empty means the stylesheet's own value
633 // stands, so a site that never opens this screen renders exactly
634 // as it did before the setting existed.
635 'styling_logo' => false,
636 'styling_logo_url' => '',
637 'styling_color_main' => '',
638 'styling_color_accent' => '',
639
640 // Generation tracking
641 'last_generated' => ''
642 ];
643 }
644
645 /**
646 * Normalize the stylesheet branding values on the way into the store.
647 *
648 * The generic sanitizer only runs `sanitize_text_field()` over a string,
649 * which happily keeps "red" or "rebeccapurple" as a colour. Nothing
650 * downstream can use those — {@see Sitemap_Stylesheet::render()} skips any
651 * value it cannot read as a hex colour — so storing them would report a
652 * successful save of a setting that changes nothing, and `get-sitemap-
653 * settings` would hand an agent back a colour the sitemap does not use.
654 * Reducing here instead keeps the store and the rendering in agreement.
655 *
656 * @since 2.7.0
657 *
658 * @param array $settings Settings to sanitize.
659 * @param string $context_type Context the save is for.
660 * @return array
661 */
662 protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
663 $sanitized = parent::sanitize_settings($settings, $context_type);
664
665 foreach (['styling_color_main', 'styling_color_accent'] as $key) {
666 if (array_key_exists($key, $sanitized)) {
667 $sanitized[$key] = Sitemap_Stylesheet::hex($sanitized[$key]);
668 }
669 }
670
671 if (array_key_exists('styling_logo_url', $sanitized)) {
672 $sanitized['styling_logo_url'] = esc_url_raw((string) $sanitized['styling_logo_url']);
673 }
674
675 // A mode this build cannot act on has to be stored as the fallback
676 // rather than kept verbatim, or get-sitemap-settings reports a delivery
677 // mode the site does not actually apply.
678 if (array_key_exists('delivery_mode', $sanitized)) {
679 $mode = sanitize_key((string) $sanitized['delivery_mode']);
680 $sanitized['delivery_mode'] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
681 }
682
683 return $sanitized;
684 }
685
686 /**
687 * Get settings schema definition (implements interface)
688 *
689 * @since 1.0.0
690 *
691 * @param string $context_type The context type to get schema for
692 * @return array Settings schema definition
693 */
694 public function get_settings_schema(string $context_type): array {
695 return [
696 'enabled' => [
697 'type' => 'boolean',
698 'title' => 'Enable Sitemap',
699 'description' => 'Generate XML sitemap for search engines',
700 'default' => true
701 ],
702 'delivery_mode' => [
703 'type' => 'string',
704 'title' => 'Sitemap Delivery',
705 'description' => 'How the sitemap is served: auto picks static when the WordPress root is writable and dynamic when it is not, static writes files to the web root, dynamic serves the sitemap from WordPress with no files written',
706 'enum' => self::DELIVERY_MODES,
707 'default' => 'auto'
708 ],
709 'include_posts' => [
710 'type' => 'boolean',
711 'title' => 'Include Posts',
712 'description' => 'Include blog posts in sitemap',
713 'default' => true
714 ],
715 'include_pages' => [
716 'type' => 'boolean',
717 'title' => 'Include Pages',
718 'description' => 'Include static pages in sitemap',
719 'default' => true
720 ],
721 'include_categories' => [
722 'type' => 'boolean',
723 'title' => 'Include Categories',
724 'description' => 'Include category pages in sitemap',
725 'default' => true
726 ],
727 'include_tags' => [
728 'type' => 'boolean',
729 'title' => 'Include Tags',
730 'description' => 'Include tag pages in sitemap',
731 'default' => false
732 ],
733
734 'auto_generate' => [
735 'type' => 'boolean',
736 'title' => 'Auto Generate',
737 'description' => 'Automatically regenerate sitemap when content changes',
738 'default' => true
739 ],
740 'ping_search_engines' => [
741 'type' => 'boolean',
742 'title' => 'Ping Search Engines',
743 'description' => 'Notify Google and Bing when sitemap is updated',
744 'default' => true
745 ],
746 'last_generated' => [
747 'type' => 'string',
748 'title' => 'Last Generated',
749 'description' => 'Timestamp of last sitemap generation',
750 'default' => ''
751 ],
752 'styling_logo' => [
753 'type' => 'boolean',
754 'title' => 'Show Logo On Sitemap',
755 'description' => 'Show a logo above the sitemap heading',
756 'default' => false
757 ],
758 'styling_logo_url' => [
759 'type' => 'string',
760 'title' => 'Sitemap Logo',
761 'description' => 'Logo image URL. Empty falls back to the site icon',
762 'default' => ''
763 ],
764 'styling_color_main' => [
765 'type' => 'string',
766 'title' => 'Sitemap Main Color',
767 'description' => 'Hex color for the sitemap header, links and table head. Empty keeps the stock palette',
768 'default' => ''
769 ],
770 'styling_color_accent' => [
771 'type' => 'string',
772 'title' => 'Sitemap Accent Color',
773 'description' => 'Hex color for the header gradient and link hovers. Empty keeps the stock palette',
774 'default' => ''
775 ]
776 ];
777 }
778
779 /**
780 * Generate URL entry for sitemap
781 *
782 * @since 1.0.0
783 *
784 * @param string $url URL
785 * @param string $lastmod Last modification date
786 * @param float $priority Priority (0.0 to 1.0)
787 * @param string $changefreq Change frequency
788 * @param array $images Optional array of image data
789 * @return string XML URL entry
790 */
791 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
792 $xml = " <url>\n";
793 // Every <loc> in every sitemap passes through here, which is why the
794 // scheme preference is applied at this one point rather than at each
795 // of the dozen collectors that build URLs (#638). An http sitemap on
796 // an https site hands search engines the wrong address for the whole
797 // site at once.
798 $xml .= " <loc>" . esc_url(Url_Scheme::apply($url)) . "</loc>\n";
799 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
800 // than no timestamp, and an absent lastmod is valid per the spec.
801 if (!empty($lastmod)) {
802 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
803 }
804 $xml .= " <priority>" . number_format($priority, 1) . "</priority>\n";
805 $xml .= " <changefreq>" . esc_html($changefreq) . "</changefreq>\n";
806
807 // Add image entries if provided
808 foreach ($images as $image) {
809 $xml .= " <image:image>\n";
810 $xml .= " <image:loc>" . esc_url(Url_Scheme::apply((string) $image['url'])) . "</image:loc>\n";
811
812 if (!empty($image['title'])) {
813 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
814 }
815
816 if (!empty($image['alt'])) {
817 $xml .= " <image:caption>" . esc_html($image['alt']) . "</image:caption>\n";
818 }
819
820 $xml .= " </image:image>\n";
821 }
822
823 $xml .= " </url>\n";
824
825 return $xml;
826 }
827
828 /**
829 * Collect every post <url> entry for the given post type(s) as an array of
830 * XML strings, using memory-efficient chunked fetching.
831 *
832 * Unlike the old capped generator, this returns ALL matching entries — the
833 * links-per-sitemap limit is applied later by paginating this array into
834 * separate files (see generate_multiple_sitemaps), matching how Rank Math
835 * splits large sitemaps instead of truncating them.
836 *
837 * @since 1.14.0
838 *
839 * @param array $post_types Array of post types to fetch
840 * @param array $settings Sitemap settings
841 * @return array<string> Individual <url>…</url> entry strings
842 */
843 /**
844 * lastmod for the homepage <url> entry: the static front page's real
845 * modification time when one is set, otherwise the generation time.
846 *
847 * @return string ISO-8601 date.
848 */
849 private function get_homepage_lastmod(): string {
850 if (get_option('show_on_front') === 'page') {
851 $front_id = (int) get_option('page_on_front');
852 if ($front_id) {
853 $modified = get_post_field('post_modified_gmt', $front_id);
854 if (!empty($modified) && $modified !== '0000-00-00 00:00:00') {
855 return gmdate('c', strtotime($modified));
856 }
857 }
858 }
859 return gmdate('c');
860 }
861
862 private function collect_post_entries(array $post_types, array $settings): array {
863 // Materialize the streaming source for callers that need the full set
864 // (e.g. the single un-paginated "general" sitemap). The paginated index
865 // path streams collect_post_entries_iter() directly to bound memory.
866 return iterator_to_array($this->collect_post_entries_iter($post_types, $settings), false);
867 }
868
869 /**
870 * Stream <url> entries for the given post types, yielding one at a time.
871 *
872 * Same chunked query, filtering, and ordering as before, but yields each
873 * entry instead of accumulating the whole set — so the paginated index
874 * generator never holds every URL of a large post type in memory at once.
875 *
876 * @param array $post_types Post types to include.
877 * @param array $settings Sitemap settings.
878 * @return \Generator<string> <url> entry strings.
879 */
880 private function collect_post_entries_iter(array $post_types, array $settings): \Generator {
881 if (empty($post_types)) {
882 return;
883 }
884
885 // Parse and validate exclude_posts setting
886 $exclude_ids = [];
887 if (!empty($settings['exclude_posts'])) {
888 try {
889 $exclude_ids = $this->validate_exclude_posts($settings['exclude_posts']);
890 } catch (InvalidArgumentException $e) {
891 // Continue with empty array on validation failure - error details available in exception
892 }
893 }
894
895 // The static front page is emitted once as the explicit homepage entry,
896 // so exclude it here to avoid a duplicate <loc> (its permalink equals
897 // home_url('/')).
898 if (get_option('show_on_front') === 'page') {
899 $front_id = (int) get_option('page_on_front');
900 if ($front_id) {
901 $exclude_ids[] = $front_id;
902 }
903 }
904
905 // Resolve the ordered ID list in one indexed query, then hydrate in
906 // chunks via post__in. This avoids large OFFSET windows (which MySQL
907 // must scan-and-discard, making a full walk O(n^2)) while still loading
908 // only one chunk of full post objects into memory at a time. The
909 // original order is preserved (the id query and post__in hydration both
910 // use it).
911 $all_ids = get_posts($this->filter_query_args([
912 'post_type' => $post_types,
913 'post_status' => 'publish',
914 'numberposts' => -1,
915 'exclude' => $exclude_ids,
916 'orderby' => 'post_type post_date',
917 'order' => 'ASC DESC',
918 'fields' => 'ids',
919 ]));
920
921 if (empty($all_ids)) {
922 return;
923 }
924
925 // Walk the ID list with a moving window rather than array_chunk().
926 // array_chunk() builds a second array holding every element again, so
927 // peak memory was twice the ID list — on a 100k-post site that is ~16MB
928 // where ~8MB is needed, and this walk is the one part of an otherwise
929 // well-bounded routine with no ceiling (#402).
930 $total = count($all_ids);
931
932 for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
933 $this->assert_memory_headroom();
934 $chunk_start = memory_get_usage(true);
935
936 $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
937
938 $posts = get_posts($this->filter_query_args([
939 'post_type' => $post_types,
940 'post_status' => 'publish',
941 'numberposts' => count($chunk),
942 'post__in' => $chunk,
943 'orderby' => 'post__in', // preserve the resolved order
944 ]));
945
946 // get_featured_image() hydrates each featured image under the
947 // attachment's own ID, which the chunk's post IDs do not reach.
948 $attachment_ids = [];
949
950 foreach ($posts as $post) {
951 if ($this->should_include_in_sitemap($post, $settings)) {
952 /**
953 * Filter a sitemap entry's permalink.
954 *
955 * The multilingual manager uses this to generate each
956 * translation's URL in its OWN language: the sitemap query
957 * deliberately runs with suppress_filters, and the cron
958 * rebuild runs with no language context at all, so a bare
959 * get_permalink() resolved every translation to the
960 * default-language URL — N entries sharing one <loc> (#409).
961 *
962 * @since 2.0.1
963 * @param string $url Permalink as WordPress resolved it.
964 * @param \WP_Post $post Post the entry describes.
965 */
966 $url = apply_filters('thinkrank_sitemap_post_permalink', get_permalink($post), $post);
967 $lastmod = gmdate('c', strtotime($post->post_modified_gmt));
968 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
969 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
970 $images = $this->extract_post_images($post, $settings);
971
972 $thumbnail_id = (int) get_post_thumbnail_id($post);
973 if ($thumbnail_id > 0) {
974 $attachment_ids[] = $thumbnail_id;
975 }
976
977 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
978 }
979 }
980
981 // Free the hydrated chunk before loading the next one — including
982 // the copies get_posts() left in the runtime object cache.
983 unset($posts);
984 $this->release_walk_memory(
985 $chunk_start,
986 $this->chunk_post_cache_groups($post_types),
987 array_merge($chunk, $attachment_ids)
988 );
989 }
990 }
991
992 /**
993 * Resolve and bound the configured links-per-sitemap limit.
994 *
995 * @since 1.14.0
996 *
997 * @param array $settings Sitemap settings
998 * @return int Links per sitemap file (1–50000)
999 */
1000 private function get_links_per_sitemap(array $settings): int {
1001 $limit = !empty($settings['links_per_sitemap']) ? intval($settings['links_per_sitemap']) : 1000;
1002
1003 return max(1, min(50000, $limit));
1004 }
1005
1006 /**
1007 * Stream <url> entries for a taxonomy's terms, yielding one at a time and
1008 * fetching terms in bounded chunks (number/offset) — so the paginated index
1009 * generator never holds every term of a large taxonomy in memory at once.
1010 *
1011 * @param string $taxonomy Taxonomy name.
1012 * @param array $settings Sitemap settings.
1013 * @return \Generator<string> <url> entry strings.
1014 */
1015 private function collect_taxonomy_entries_iter(string $taxonomy, array $settings): \Generator {
1016 $exclude_term_ids = [];
1017 if (!empty($settings['exclude_terms'])) {
1018 try {
1019 $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
1020 } catch (InvalidArgumentException $e) {
1021 // Continue with an empty exclude list on validation failure.
1022 }
1023 }
1024
1025 // product_cat may legitimately have empty terms (products added later);
1026 // every other taxonomy hides empties — matching fetch_taxonomies_optimized().
1027 $hide_empty = $taxonomy !== 'product_cat';
1028 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
1029
1030 // Resolve the ordered term IDs in one query, then hydrate in chunks via
1031 // include. Avoids large OFFSET windows (O(n^2) over a full walk) while
1032 // holding only one chunk of full term objects at a time.
1033 $all_ids = get_terms($this->filter_term_query_args([
1034 'taxonomy' => $taxonomy,
1035 'hide_empty' => $hide_empty,
1036 'exclude' => $exclude_term_ids,
1037 'orderby' => 'count',
1038 'order' => 'DESC',
1039 'fields' => 'ids',
1040 ]));
1041
1042 if (is_wp_error($all_ids) || empty($all_ids)) {
1043 return;
1044 }
1045
1046 // Same moving window as the post walk above, for the same reason.
1047 $total = count($all_ids);
1048
1049 for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
1050 $this->assert_memory_headroom();
1051 $chunk_start = memory_get_usage(true);
1052
1053 $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
1054
1055 $terms = get_terms($this->filter_term_query_args([
1056 'taxonomy' => $taxonomy,
1057 'include' => $chunk,
1058 'orderby' => 'include', // preserve the resolved order
1059 'hide_empty' => false, // already filtered by the id query
1060 ]));
1061
1062 if (is_wp_error($terms) || empty($terms)) {
1063 continue;
1064 }
1065
1066 foreach ($terms as $term) {
1067 // A term the user marked noindex must not be advertised in the
1068 // sitemap: the robots tag now honours term meta, so listing it
1069 // here would have the sitemap contradict the page's own tag.
1070 if ($this->term_is_noindexed((int) $term->term_id)) {
1071 continue;
1072 }
1073
1074 $url = get_term_link($term);
1075 if (!is_wp_error($url)) {
1076 // Omit lastmod for terms — the generation time is not a real
1077 // modification time and would mislabel every term as just-changed.
1078 yield $this->generate_url_entry($url, '', $priority, 'weekly');
1079 }
1080 }
1081
1082 unset($terms);
1083 $this->release_walk_memory($chunk_start, ['terms', 'term_meta'], $chunk);
1084 }
1085 }
1086
1087 /**
1088 * The XML declaration, ownership marker and optional stylesheet every
1089 * sitemap document opens with.
1090 *
1091 * The marker is written unconditionally, and that is the point: removal on
1092 * deactivate and uninstall deletes a web-root sitemap only when the file
1093 * says it is ours, and our filenames are the canonical ones another SEO
1094 * plugin writes too (#515). Tying the proof to `enable_styling` — the one
1095 * marker older versions left — would mean a site with styling off either
1096 * kept a shadowing file behind (#510) or had a competitor's deleted.
1097 *
1098 * @since 2.1.1
1099 *
1100 * The stylesheet URL is served by {@see Sitemap_Stylesheet}, not read off
1101 * disk by the web server, because a static file cannot carry the site's own
1102 * logo and colours (#639). It is a fixed URL: the palette is applied per
1103 * request, so changing a brand colour needs no regeneration and shows up on
1104 * sitemaps published long before.
1105 *
1106 * @param array $settings Sitemap settings (read for `enable_styling`).
1107 * @param string $variant Stylesheet variant, `sitemap` or `index`.
1108 * @return string Prolog lines, newline-terminated.
1109 */
1110 private function xml_prolog(array $settings, string $variant): string {
1111 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1112 $xml .= THINKRANK_SITEMAP_MARKER . "\n";
1113
1114 // The stylesheet is presentation only, so it stays opt-in.
1115 if (!empty($settings['enable_styling'])) {
1116 $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url(Sitemap_Stylesheet::url($variant)) . '"?>' . "\n";
1117 }
1118
1119 return $xml;
1120 }
1121
1122 /**
1123 * Wrap a set of <url> entry strings in a complete <urlset> document.
1124 *
1125 * @since 1.14.0
1126 *
1127 * @param array<string> $entries Entry strings
1128 * @param array $settings Sitemap settings
1129 * @param bool $with_image_ns Include the image sitemap namespace
1130 * @return string Full sitemap XML
1131 */
1132 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
1133 $xml = $this->xml_prolog($settings, 'sitemap');
1134
1135 if ($with_image_ns && !empty($settings['include_images'])) {
1136 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
1137 } else {
1138 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1139 }
1140
1141 $xml .= implode('', $entries);
1142 $xml .= '</urlset>';
1143
1144 return $xml;
1145 }
1146
1147 /**
1148 * Derive the filename/URL for a given pagination page.
1149 *
1150 * Page 1 keeps the base URL (e.g. /sitemap-posts.xml); pages 2+ insert the
1151 * page number before the extension (e.g. /sitemap-posts-2.xml).
1152 *
1153 * @since 1.14.0
1154 *
1155 * @param string $url Base sitemap URL
1156 * @param int $page 1-based page number
1157 * @return string Paginated URL
1158 */
1159 private function paginate_url(string $url, int $page): string {
1160 if ($page <= 1) {
1161 return $url;
1162 }
1163
1164 return (string) preg_replace('/\.xml$/i', '-' . $page . '.xml', $url);
1165 }
1166
1167
1168
1169 /**
1170 * Extract images from post for sitemap
1171 *
1172 * @since 1.0.0
1173 *
1174 * @param \WP_Post $post Post object
1175 * @param array $settings Sitemap settings
1176 * @return array Array of image data
1177 */
1178 private function extract_post_images(\WP_Post $post, array $settings): array {
1179 $images = [];
1180
1181 // Skip if images are disabled
1182 if (empty($settings['include_images'])) {
1183 return $images;
1184 }
1185
1186 // Get featured image if enabled
1187 if (!empty($settings['include_featured_images'])) {
1188 $featured_image = $this->get_featured_image($post->ID);
1189 if ($featured_image) {
1190 $images[] = $featured_image;
1191 }
1192 }
1193
1194 // Extract images from content
1195 $content_images = $this->extract_content_images($post->post_content);
1196 $images = array_merge($images, $content_images);
1197
1198 // Remove duplicates based on URL
1199 $unique_images = [];
1200 $seen_urls = [];
1201 foreach ($images as $image) {
1202 if (!in_array($image['url'], $seen_urls, true)) {
1203 $unique_images[] = $image;
1204 $seen_urls[] = $image['url'];
1205 }
1206 }
1207
1208 return $unique_images;
1209 }
1210
1211 /**
1212 * Get featured image data
1213 *
1214 * @since 1.0.0
1215 *
1216 * @param int $post_id Post ID
1217 * @return array|null Featured image data or null
1218 */
1219 private function get_featured_image(int $post_id): ?array {
1220 $thumbnail_id = get_post_thumbnail_id($post_id);
1221 if (!$thumbnail_id) {
1222 return null;
1223 }
1224
1225 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
1226 if (!$image_url) {
1227 return null;
1228 }
1229
1230 $image_title = get_the_title($thumbnail_id);
1231 $image_alt = get_post_meta($thumbnail_id, '_wp_attachment_image_alt', true);
1232
1233 return [
1234 'url' => $image_url,
1235 'title' => $image_title ?: '',
1236 'alt' => $image_alt ?: ''
1237 ];
1238 }
1239
1240 /**
1241 * Extract images from post content
1242 *
1243 * @since 1.0.0
1244 *
1245 * @param string $content Post content
1246 * @return array Array of image data
1247 */
1248 private function extract_content_images(string $content): array {
1249 $images = [];
1250
1251 // Find all img tags in content
1252 preg_match_all('/<img[^>]+>/i', $content, $img_tags);
1253
1254 foreach ($img_tags[0] as $img_tag) {
1255 // Extract src attribute
1256 if (preg_match('/src=["\']([^"\']+)["\']/', $img_tag, $src_match)) {
1257 $image_url = $src_match[1];
1258
1259 // Skip if not a valid URL or external image
1260 if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
1261 continue;
1262 }
1263
1264 // Extract title and alt attributes
1265 $title = '';
1266 $alt = '';
1267
1268 if (preg_match('/title=["\']([^"\']*)["\']/', $img_tag, $title_match)) {
1269 $title = $title_match[1];
1270 }
1271
1272 if (preg_match('/alt=["\']([^"\']*)["\']/', $img_tag, $alt_match)) {
1273 $alt = $alt_match[1];
1274 }
1275
1276 $images[] = [
1277 'url' => $image_url,
1278 'title' => $title,
1279 'alt' => $alt
1280 ];
1281 }
1282 }
1283
1284 return $images;
1285 }
1286
1287 /**
1288 * Get enabled taxonomies based on settings
1289 *
1290 * @since 1.0.0
1291 * @param array $settings Sitemap settings
1292 * @return array Array of enabled taxonomies
1293 */
1294 private function get_enabled_taxonomies(array $settings): array {
1295 $taxonomies = [];
1296
1297 // Core taxonomies based on settings
1298 if (!empty($settings['include_categories'])) { // �
1299 Evidence-based field name
1300 $taxonomies[] = 'category';
1301 }
1302
1303 if (!empty($settings['include_tags'])) { // �
1304 Evidence-based field name
1305 $taxonomies[] = 'post_tag';
1306 }
1307
1308 // Auto-detect public custom taxonomies
1309 $custom_taxonomies = get_taxonomies([
1310 'public' => true,
1311 '_builtin' => false
1312 ], 'names');
1313
1314 foreach ($custom_taxonomies as $taxonomy) {
1315 if (!$this->should_include_taxonomy($taxonomy)) {
1316 continue;
1317 }
1318
1319 // Same as the post-type walk above: an explicit per-taxonomy flag
1320 // now decides, and an unset flag keeps the previous "included"
1321 // behaviour (#660).
1322 if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $settings)) {
1323 $taxonomies[] = $taxonomy;
1324 }
1325 }
1326
1327 return array_unique($taxonomies);
1328 }
1329
1330
1331
1332 /**
1333 * Fetch taxonomies using optimized combined query
1334 *
1335 * @since 1.0.0
1336 *
1337 * @param array $taxonomies Array of taxonomy names to fetch
1338 * @param array $settings Sitemap settings
1339 * @return array Grouped terms by taxonomy
1340 */
1341 private function fetch_taxonomies_optimized(array $taxonomies, array $settings): array {
1342 if (empty($taxonomies)) {
1343 return [];
1344 }
1345
1346 // Parse and validate exclude_terms setting
1347 $exclude_term_ids = [];
1348 if (!empty($settings['exclude_terms'])) {
1349 try {
1350 $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
1351 } catch (InvalidArgumentException $e) {
1352 // Continue with empty array on validation failure - error details available in exception
1353 }
1354 }
1355
1356 // Determine hide_empty setting based on taxonomies
1357 $hide_empty = true;
1358 foreach ($taxonomies as $taxonomy) {
1359 // For product categories, don't hide empty categories since they might not have products yet
1360 if ($taxonomy === 'product_cat') {
1361 $hide_empty = false;
1362 break;
1363 }
1364 }
1365
1366 // OPTIMIZED: Single query for multiple taxonomies
1367 $all_terms = get_terms($this->filter_term_query_args([
1368 'taxonomy' => $taxonomies, // �
1369 Multiple taxonomies in single query
1370 'hide_empty' => $hide_empty,
1371 'exclude' => $exclude_term_ids,
1372 'orderby' => 'taxonomy count',
1373 'order' => 'ASC DESC' // Order by taxonomy ASC, then count DESC
1374 ]));
1375
1376 if (is_wp_error($all_terms)) {
1377 return [];
1378 }
1379
1380 // Drop terms the user marked noindex. This path feeds the single general
1381 // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
1382 // so both need the filter or the two disagree about the same term.
1383 $all_terms = array_values(array_filter(
1384 $all_terms,
1385 fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1386 ));
1387
1388 // Group terms by taxonomy
1389 return $this->group_terms_by_taxonomy($all_terms);
1390 }
1391
1392 /**
1393 * Group terms by taxonomy
1394 *
1395 * @since 1.0.0
1396 *
1397 * @param array $terms Array of term objects
1398 * @return array Grouped terms by taxonomy
1399 */
1400 private function group_terms_by_taxonomy(array $terms): array {
1401 $grouped = [];
1402
1403 foreach ($terms as $term) {
1404 $taxonomy = $term->taxonomy; // �
1405 Evidence-based property name
1406
1407 if (!isset($grouped[$taxonomy])) {
1408 $grouped[$taxonomy] = [];
1409 }
1410
1411 $grouped[$taxonomy][] = $term;
1412 }
1413
1414 return $grouped;
1415 }
1416
1417 /**
1418 * Process taxonomy terms to XML entries
1419 *
1420 * @since 1.0.0
1421 *
1422 * @param array $terms Array of term objects
1423 * @param string $taxonomy Taxonomy name
1424 * @param array $settings Sitemap settings
1425 * @return string XML entries
1426 */
1427 private function process_taxonomies_to_xml(array $terms, string $taxonomy, array $settings): string {
1428 $xml = '';
1429
1430 foreach ($terms as $term) {
1431 $url = get_term_link($term);
1432 if (!is_wp_error($url)) {
1433 // Omit lastmod for terms (generation time is not a real
1434 // modification time).
1435 $lastmod = '';
1436 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
1437 $changefreq = 'weekly';
1438
1439 $xml .= $this->generate_url_entry($url, $lastmod, $priority, $changefreq);
1440 }
1441 }
1442
1443 return $xml;
1444 }
1445
1446 /**
1447 * Calculate intelligent priority based on content factors
1448 *
1449 * @since 1.0.0
1450 *
1451 * @param \WP_Post $post Post object
1452 * @param string $post_type Post type
1453 * @return float Priority value between 0.1 and 1.0
1454 */
1455 private function calculate_intelligent_priority(\WP_Post $post, string $post_type): float {
1456 $base_priority = $post_type === 'page' ? 0.9 : 0.8;
1457
1458 // Factors that can adjust priority
1459 $adjustments = 0;
1460
1461 // Recent content gets higher priority
1462 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
1463 if ($days_old < 30) {
1464 $adjustments += 0.1; // Recent content boost
1465 } elseif ($days_old > 365) {
1466 $adjustments -= 0.1; // Older content penalty
1467 }
1468
1469 // Content length factor
1470 $content_length = strlen(wp_strip_all_tags($post->post_content));
1471 if ($content_length > 2000) {
1472 $adjustments += 0.05; // Comprehensive content boost
1473 } elseif ($content_length < 500) {
1474 $adjustments -= 0.1; // Thin content penalty
1475 }
1476
1477 // Special page types get higher priority
1478 if ($post_type === 'page') {
1479 $page_template = get_page_template_slug($post->ID);
1480 if (in_array($page_template, ['page-home.php', 'front-page.php'], true) ||
1481 (int) $post->ID === (int) get_option('page_on_front')) {
1482 $base_priority = 1.0; // Homepage gets maximum priority
1483 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1484 $adjustments += 0.05; // Important pages boost
1485 }
1486 }
1487
1488 // Ensure priority stays within valid range
1489 $final_priority = max(0.1, min(1.0, $base_priority + $adjustments));
1490
1491 return round($final_priority, 1);
1492 }
1493
1494 /**
1495 * Calculate change frequency based on content type and age
1496 *
1497 * @since 1.0.0
1498 *
1499 * @param \WP_Post $post Post object
1500 * @param string $post_type Post type
1501 * @return string Change frequency
1502 */
1503 private function calculate_change_frequency(\WP_Post $post, string $post_type): string {
1504 // Pages typically change less frequently
1505 if ($post_type === 'page') {
1506 $page_template = get_page_template_slug($post->ID);
1507 if ((int) $post->ID === (int) get_option('page_on_front')) {
1508 return 'daily'; // Homepage changes frequently
1509 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1510 return 'monthly'; // Static pages change monthly
1511 }
1512 return 'yearly'; // Other pages change rarely
1513 }
1514
1515 // Posts frequency based on age and type
1516 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
1517
1518 if ($days_old < 7) {
1519 return 'daily'; // Very recent posts
1520 } elseif ($days_old < 30) {
1521 return 'weekly'; // Recent posts
1522 } elseif ($days_old < 365) {
1523 return 'monthly'; // Older posts
1524 }
1525
1526 return 'yearly'; // Very old posts
1527 }
1528
1529 /**
1530 * Determine if content should be included in sitemap
1531 *
1532 * @since 1.0.0
1533 *
1534 * @param \WP_Post $post Post object
1535 * @param array $settings Sitemap settings
1536 * @return bool Whether to include in sitemap
1537 */
1538 private function should_include_in_sitemap(\WP_Post $post, array $settings): bool {
1539 // The WooCommerce cart, checkout and account pages are transactional,
1540 // never indexable, and generate_default_robots_rules() already emits a
1541 // Disallow for each of them. Listing them here submitted URLs our own
1542 // robots.txt blocks, which Search Console reports as "Submitted URL
1543 // blocked by robots.txt". Yoast and Rank Math exclude the same three.
1544 if (in_array($post->ID, $this->woocommerce_excluded_page_ids(), true)) {
1545 return false;
1546 }
1547
1548 // Respect user setting for password protected content
1549 if (!empty($post->post_password) && !empty($settings['exclude_password_protected'])) {
1550 return false;
1551 }
1552
1553 // Respect user setting for private posts
1554 if ($post->post_status === 'private' && !empty($settings['exclude_private_posts'])) {
1555 return false;
1556 }
1557
1558 // Only published (and, per setting, private) content belongs in the
1559 // sitemap. Content-quality heuristics (length, "demo"/"test"/"sample"
1560 // in the title, "lorem ipsum" text) were intentionally removed: an XML
1561 // sitemap should list every indexable published URL. Filtering by
1562 // description length silently dropped legitimate WooCommerce products
1563 // with short descriptions, and the substring title match excluded real
1564 // pages such as "Demo" or "Product Samples". Indexability is governed
1565 // by noindex directives below, not by heuristics.
1566 if (!in_array($post->post_status, ['publish', 'private'], true)) {
1567 return false;
1568 }
1569
1570 // Check if post overrides robots and sets noindex.
1571 if ((bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true)) {
1572 $raw = get_post_meta($post->ID, '_thinkrank_robots_meta', true);
1573 if (is_string($raw) && $raw !== '') {
1574 $robots = json_decode($raw, true);
1575 if (is_array($robots) && !empty($robots['noindex'])) {
1576 return false;
1577 }
1578 }
1579 }
1580
1581 return true;
1582 }
1583
1584 /**
1585 * WooCommerce pages that must never reach the sitemap.
1586 *
1587 * Resolved through wc_get_page_id() so a store that moved or renamed its
1588 * cart/checkout/account pages is still matched. Returns an empty list when
1589 * WooCommerce is not active. Memoised — should_include_in_sitemap() runs
1590 * once per post.
1591 *
1592 * @since 2.0.1
1593 *
1594 * @return int[] Page IDs to exclude.
1595 */
1596 private function woocommerce_excluded_page_ids(): array {
1597 if ($this->woocommerce_excluded_page_ids !== null) {
1598 return $this->woocommerce_excluded_page_ids;
1599 }
1600
1601 $ids = [];
1602
1603 if (function_exists('wc_get_page_id')) {
1604 foreach (['cart', 'checkout', 'myaccount'] as $page) {
1605 $id = (int) wc_get_page_id($page);
1606 // wc_get_page_id() returns -1 when the page is not configured.
1607 if ($id > 0) {
1608 $ids[] = $id;
1609 }
1610 }
1611 }
1612
1613 $this->woocommerce_excluded_page_ids = $ids;
1614
1615 return $ids;
1616 }
1617
1618 /**
1619 * Whether a term carries an explicit noindex override.
1620 *
1621 * Mirrors the post-side check in should_include_post(); terms store the same
1622 * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1623 * by the update-term-seo ability and by the SEO importer.
1624 *
1625 * @since 1.31.0
1626 *
1627 * @param int $term_id Term to test.
1628 * @return bool True when the term is marked noindex.
1629 */
1630 private function term_is_noindexed(int $term_id): bool {
1631 if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1632 return false;
1633 }
1634
1635 $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1636 if (!is_string($raw) || $raw === '') {
1637 return false;
1638 }
1639
1640 $robots = json_decode($raw, true);
1641
1642 return is_array($robots) && !empty($robots['noindex']);
1643 }
1644
1645 /**
1646 * Count total URLs in sitemap
1647 *
1648 * @since 1.0.0
1649 *
1650 * @param array $settings Sitemap settings
1651 * @return int Total URL count
1652 */
1653 public function count_sitemap_urls(array $settings): int {
1654 $count = 1; // Homepage
1655
1656 if (!empty($settings['include_posts'])) {
1657 $count += wp_count_posts('post')->publish;
1658 }
1659
1660 if (!empty($settings['include_pages'])) {
1661 $count += wp_count_posts('page')->publish;
1662 }
1663
1664 if (!empty($settings['include_categories'])) {
1665 $count += wp_count_terms(['taxonomy' => 'category', 'hide_empty' => true]);
1666 }
1667
1668 if (!empty($settings['include_tags'])) {
1669 $count += wp_count_terms(['taxonomy' => 'post_tag', 'hide_empty' => true]);
1670 }
1671
1672 return $count;
1673 }
1674
1675 /**
1676 * Handle content changes for auto-generation
1677 *
1678 * @since 1.0.0
1679 * @param int $post_id Post ID
1680 * @param \WP_Post $post Post object
1681 * @return void
1682 */
1683 public function handle_content_change(int $post_id, \WP_Post $post): void {
1684 // Skip if auto-generation is disabled
1685 if (!$this->should_auto_generate()) {
1686 return;
1687 }
1688
1689 // Skip autosaves and revisions
1690 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
1691 return;
1692 }
1693
1694 // Only process published content
1695 if ($post->post_status !== 'publish') {
1696 return;
1697 }
1698
1699 // Check if this post type should trigger regeneration
1700 if (!$this->should_include_post_type($post->post_type)) {
1701 return;
1702 }
1703
1704 // Schedule debounced regeneration
1705 $this->schedule_debounced_regeneration();
1706 }
1707
1708 /**
1709 * Handle content deletion for auto-generation
1710 *
1711 * @since 1.0.0
1712 * @param int $post_id Post ID
1713 * @return void
1714 */
1715 public function handle_content_deletion(int $post_id): void {
1716 // Skip if auto-generation is disabled
1717 if (!$this->should_auto_generate()) {
1718 return;
1719 }
1720
1721 $post = get_post($post_id);
1722 if (!$post) {
1723 return;
1724 }
1725
1726 // Check if this post type should trigger regeneration
1727 if (!$this->should_include_post_type($post->post_type)) {
1728 return;
1729 }
1730
1731 // Schedule debounced regeneration
1732 $this->schedule_debounced_regeneration();
1733 }
1734
1735 /**
1736 * Handle content change by ID (for untrash, etc.)
1737 *
1738 * @since 1.0.0
1739 * @param int $post_id Post ID
1740 * @return void
1741 */
1742 public function handle_content_change_by_id(int $post_id): void {
1743 $post = get_post($post_id);
1744 if ($post) {
1745 $this->handle_content_change($post_id, $post);
1746 }
1747 }
1748
1749 /**
1750 * Handle taxonomy changes for auto-generation
1751 *
1752 * @since 1.0.0
1753 * @param int $term_id Term ID
1754 * @param int $tt_id Term taxonomy ID
1755 * @param string $taxonomy Taxonomy slug
1756 * @return void
1757 */
1758 public function handle_taxonomy_change(int $term_id, int $tt_id, string $taxonomy): void {
1759 // Skip if auto-generation is disabled
1760 if (!$this->should_auto_generate()) {
1761 return;
1762 }
1763
1764 // Check if this taxonomy should trigger regeneration
1765 if (!$this->should_include_taxonomy($taxonomy)) {
1766 return;
1767 }
1768
1769 // Schedule debounced regeneration
1770 $this->schedule_debounced_regeneration();
1771 }
1772
1773 /**
1774 * Check if auto-generation is enabled
1775 *
1776 * @since 1.0.0
1777 * @return bool True if auto-generation is enabled
1778 */
1779 private function should_auto_generate(): bool {
1780 $settings = $this->get_settings('site');
1781
1782 // Check if sitemap is enabled
1783 if (empty($settings['enabled'])) {
1784 return false;
1785 }
1786
1787 // Check if auto-generation is enabled
1788 return !empty($settings['auto_generate']);
1789 }
1790
1791 /**
1792 * Get enabled post types based on settings
1793 *
1794 * @since 1.0.0
1795 * @param array $settings Sitemap settings
1796 * @return array Array of enabled post types
1797 */
1798 private function get_enabled_post_types(array $settings): array {
1799 $post_types = [];
1800
1801 // Core post types based on settings
1802 if (!empty($settings['include_posts'])) {
1803 $post_types[] = 'post';
1804 }
1805
1806 if (!empty($settings['include_pages'])) {
1807 $post_types[] = 'page';
1808 }
1809
1810 // Auto-detect public custom post types that should be included.
1811 //
1812 // A custom type's `include_<slug>` / `exclude_<slug>` flag is honoured
1813 // here (#660). It was previously stored — additional_setting_keys()
1814 // has always let those keys through — but never read, so a CPT was in
1815 // the sitemap whatever the setting said. Unset still means included, so
1816 // a site that never touched the flag is unaffected.
1817 $custom_post_types = get_post_types([
1818 'public' => true,
1819 '_builtin' => false
1820 ], 'names');
1821
1822 foreach ($custom_post_types as $post_type) {
1823 if (!$this->should_include_post_type($post_type)) {
1824 continue;
1825 }
1826
1827 if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $post_type, $settings)) {
1828 $post_types[] = $post_type;
1829 }
1830 }
1831
1832 return array_unique($post_types);
1833 }
1834
1835 /**
1836 * Check if post type should trigger regeneration
1837 *
1838 * @since 1.0.0
1839 * @param string $post_type Post type
1840 * @return bool True if post type should trigger regeneration
1841 */
1842 private function should_include_post_type(string $post_type): bool {
1843 // Match Rank Math: only publicly viewable post types belong in the
1844 // sitemap (public && publicly_queryable). Additionally skip post types
1845 // that opt out of front-end search (exclude_from_search => true) — e.g.
1846 // Templately's internal `templately_library` store — which are template
1847 // records, not standalone indexable URLs. BetterDocs `docs` and
1848 // WooCommerce `product` register exclude_from_search => false, so they
1849 // remain included.
1850 // The predicate lives in Content_Type_Settings so the matrix can ask
1851 // the same question before offering a switch for this post type.
1852 return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_post_type($post_type);
1853 }
1854
1855 /**
1856 * Check if taxonomy should trigger regeneration
1857 *
1858 * @since 1.0.0
1859 * @param string $taxonomy Taxonomy slug
1860 * @return bool True if taxonomy should trigger regeneration
1861 */
1862 private function should_include_taxonomy(string $taxonomy): bool {
1863 // Public taxonomies only, and only the ones this generator can actually
1864 // emit — the same predicate the content-type matrix asks before it
1865 // offers a sitemap switch for one (presets still control which
1866 // sitemaps are created).
1867 return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_taxonomy($taxonomy);
1868 }
1869
1870 /**
1871 * Schedule debounced sitemap regeneration
1872 *
1873 * @since 1.0.0
1874 * @return void
1875 */
1876 private function schedule_debounced_regeneration(): void {
1877 $this->mark_regeneration_pending('content');
1878 $this->debounce_event('thinkrank_regenerate_sitemap');
1879 }
1880
1881 /**
1882 * Schedule (or keep) the debounced single event behind a regeneration hook.
1883 *
1884 * An event that is already due is left alone. WP-Cron only runs when a
1885 * request arrives, so on a site with DISABLE_WP_CRON, a blocked loopback or
1886 * little traffic an overdue event can sit in the queue for a long time —
1887 * clearing and re-scheduling it on every save pushed the rebuild
1888 * permanently 30 seconds into the future and the sitemap never updated
1889 * (#629). Debouncing only against an event that has not come due yet keeps
1890 * the bulk-edit coalescing without starving the rebuild.
1891 *
1892 * @since 2.2.1
1893 * @param string $hook Regeneration hook to debounce.
1894 * @return void
1895 */
1896 private function debounce_event(string $hook): void {
1897 $next = wp_next_scheduled($hook);
1898
1899 if ($next !== false) {
1900 if ($next <= time()) {
1901 return;
1902 }
1903
1904 wp_clear_scheduled_hook($hook);
1905 }
1906
1907 wp_schedule_single_event(time() + self::REGENERATION_DEBOUNCE, $hook);
1908 }
1909
1910 /**
1911 * Record that a rebuild is outstanding, so an overdue one can be taken over
1912 * by a later request and its staleness surfaced in the UI.
1913 *
1914 * `since` is the *oldest* outstanding change: it is what the takeover grace
1915 * and the admin staleness warning are measured from, so successive edits
1916 * must not push it forward. A settings change outranks a content change —
1917 * it rebuilds regardless of the auto_generate toggle and handles a sitemap
1918 * that has just been disabled — so once one is outstanding it stays the
1919 * recorded source until the rebuild lands.
1920 *
1921 * @since 2.2.1
1922 * @param string $source Either 'content' or 'settings'.
1923 * @return void
1924 */
1925 private function mark_regeneration_pending(string $source): void {
1926 // Whatever made the static files stale made the rendered ones stale
1927 // too. Invalidating here rather than only on the rebuild keeps the two
1928 // delivery modes reacting to exactly the same triggers, which is the
1929 // only way a dynamic site stays as fresh as a static one (#752).
1930 $this->flush_dynamic_cache();
1931
1932 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1933 $pending = is_array($pending) ? $pending : [];
1934
1935 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1936 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1937 $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1938
1939 // Merged so the bookkeeping a rebuild keeps on the marker (`started`,
1940 // `memory_limit`) survives an edit made while it is outstanding.
1941 update_option(
1942 self::REGENERATION_PENDING_OPTION,
1943 array_merge($pending, [
1944 'since' => $since,
1945 'source' => $source,
1946 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
1947 'next_attempt' => !empty($pending['next_attempt'])
1948 ? (int) $pending['next_attempt']
1949 : time() + self::REGENERATION_TAKEOVER_GRACE,
1950 // Bumped on every change so a rebuild can tell whether the edit
1951 // it started for is still the newest one outstanding.
1952 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
1953 ]),
1954 true
1955 );
1956 }
1957
1958 /**
1959 * The revision of the outstanding rebuild, for
1960 * {@see mark_regeneration_complete()} to compare against once it is done.
1961 *
1962 * @since 2.2.1
1963 * @return int Current revision, 0 when nothing is outstanding.
1964 */
1965 private function current_regeneration_revision(): int {
1966 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1967
1968 return (is_array($pending) && !empty($pending['revision'])) ? (int) $pending['revision'] : 0;
1969 }
1970
1971 /**
1972 * Clear the outstanding-rebuild marker and any recorded failure.
1973 *
1974 * Public because a manual generation satisfies whatever the automatic path
1975 * was still waiting to write.
1976 *
1977 * @since 2.2.1
1978 * @return void
1979 */
1980 public function mark_regeneration_complete(?int $revision = null): void {
1981 // The write succeeded, so whatever failure was on record is history.
1982 if (get_option(self::REGENERATION_ERROR_OPTION, null) !== null) {
1983 delete_option(self::REGENERATION_ERROR_OPTION);
1984 }
1985
1986 $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
1987
1988 if ($pending === null) {
1989 return;
1990 }
1991
1992 // A change that landed while this rebuild was running is not covered by
1993 // the files it just wrote, so it has to stay outstanding — otherwise, on
1994 // a site where WP-Cron never fires, clearing the marker would strand it
1995 // exactly the way #629 stranded everything.
1996 if (
1997 $revision !== null
1998 && is_array($pending)
1999 && (int) ($pending['revision'] ?? 0) !== $revision
2000 ) {
2001 // This attempt succeeded, so drop what it claimed: the newer change
2002 // waits the grace a fresh edit gets, not a failure backoff it never
2003 // earned. Not zero: that edit queued its own debounced event, and
2004 // a marker due at once had the next admin request rebuild in its
2005 // shutdown and the event rebuild again seconds later.
2006 unset($pending['started'], $pending['memory_limit']);
2007 $pending['attempts'] = 0;
2008 $pending['next_attempt'] = time() + self::REGENERATION_TAKEOVER_GRACE;
2009
2010 update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2011 return;
2012 }
2013
2014 delete_option(self::REGENERATION_PENDING_OPTION);
2015 }
2016
2017 /**
2018 * Record the attempt that is about to run before it runs.
2019 *
2020 * A PHP fatal — the memory limit or max_execution_time — is not a
2021 * Throwable, so no catch or finally around the generation runs when the
2022 * process dies, and a failure recorded afterwards was never recorded at
2023 * all: `attempts` stayed 0, the backoff never applied, and the next request
2024 * started the same doomed rebuild again (a fatal every few minutes for as
2025 * long as an admin was logged in). Claiming the attempt up front makes the
2026 * backoff hold even when nothing after this line gets to run, and leaves a
2027 * `started` stamp the next attempt can recognise as an interrupted one.
2028 *
2029 * @since 2.10.1
2030 * @return void
2031 */
2032 private function claim_regeneration_attempt(): void {
2033 $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
2034
2035 // Nothing outstanding (e.g. a manual generation already satisfied it):
2036 // there is no marker to retry from, so nothing to claim.
2037 if (!is_array($pending) || empty($pending['since'])) {
2038 return;
2039 }
2040
2041 if (!empty($pending['started'])) {
2042 // The previous attempt claimed itself and never reported back.
2043 update_option(
2044 self::REGENERATION_ERROR_OPTION,
2045 [
2046 'message' => __('The previous automatic sitemap rebuild stopped before it finished, most likely because PHP ran out of memory or time. It is retried with a growing delay. If this keeps happening, raise the PHP memory_limit or max_execution_time, or run WP-Cron from a system cron.', 'thinkrank'),
2047 'source' => isset($pending['source']) ? (string) $pending['source'] : 'content',
2048 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 1,
2049 'time' => (int) $pending['started'],
2050 ],
2051 false
2052 );
2053 }
2054
2055 $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2056
2057 $pending['attempts'] = $attempts;
2058 $pending['next_attempt'] = time() + $this->regeneration_backoff($attempts);
2059 $pending['started'] = time();
2060
2061 update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2062 }
2063
2064 /**
2065 * Delay before the next takeover after the given number of attempts.
2066 *
2067 * @since 2.10.1
2068 * @param int $attempts Attempts made so far (1 or more).
2069 * @return int Seconds.
2070 */
2071 private function regeneration_backoff(int $attempts): int {
2072 return (int) min(
2073 self::REGENERATION_TAKEOVER_GRACE * (2 ** min(max($attempts, 1), 10)),
2074 self::REGENERATION_MAX_BACKOFF
2075 );
2076 }
2077
2078 /**
2079 * Record a failed regeneration instead of discarding it.
2080 *
2081 * Keeps the pending marker in place so the rebuild is retried, but backs the
2082 * next attempt off exponentially (capped) so a persistently failing
2083 * generation cannot run on every admin request.
2084 *
2085 * @since 2.2.1
2086 * @since 2.10.1 Accepts the memory limit a rebuild had to stop short of, and
2087 * does not count an attempt claim_regeneration_attempt()
2088 * already counted.
2089 * @param string $message Failure detail.
2090 * @param string $source Either 'content' or 'settings'.
2091 * @param int|null $memory_limit Memory limit (bytes) the rebuild stopped
2092 * short of, when that was the failure.
2093 * @return void
2094 */
2095 private function record_regeneration_failure(string $message, string $source, ?int $memory_limit = null): void {
2096 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2097 $pending = is_array($pending) ? $pending : [];
2098 $attempts = !empty($pending['attempts']) ? (int) $pending['attempts'] : 0;
2099
2100 // An attempt that claimed itself up front has already been counted.
2101 if (empty($pending['started'])) {
2102 $attempts++;
2103 }
2104 $attempts = max($attempts, 1);
2105
2106 $backoff = $this->regeneration_backoff($attempts);
2107
2108 // Same precedence mark_regeneration_pending() enforces: a settings
2109 // rebuild outranks a content one and must not be downgraded by a failed
2110 // attempt. Overwriting it routed the retry back through the content
2111 // path, where should_auto_generate() can be false and the completion
2112 // marker then discards the settings rebuild entirely. Only the
2113 // outstanding rebuild is upgraded — the recorded error keeps reporting
2114 // whichever attempt actually failed.
2115 $current = isset($pending['source']) ? (string) $pending['source'] : '';
2116 $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
2117
2118 $marker = [
2119 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
2120 'source' => $pending_source,
2121 'attempts' => $attempts,
2122 'next_attempt' => time() + $backoff,
2123 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
2124 ];
2125
2126 // Remembered so has_memory_for_retry() can keep requests with no more
2127 // memory than this from repeating the same attempt.
2128 if ($memory_limit !== null && $memory_limit > 0) {
2129 $marker['memory_limit'] = $memory_limit;
2130 }
2131
2132 update_option(self::REGENERATION_PENDING_OPTION, $marker, true);
2133
2134 update_option(
2135 self::REGENERATION_ERROR_OPTION,
2136 [
2137 'message' => $message,
2138 'source' => $source,
2139 'attempts' => $attempts,
2140 'time' => time(),
2141 ],
2142 false
2143 );
2144
2145 if (defined('WP_DEBUG') && WP_DEBUG) {
2146 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2147 error_log(sprintf('ThinkRank: sitemap %s regeneration failed — %s', $source, $message));
2148 }
2149 }
2150
2151 /**
2152 * Is a rebuild outstanding and past the point where WP-Cron should have run
2153 * it?
2154 *
2155 * Deliberately cheap — one autoloaded option read — because it is consulted
2156 * on every admin request to decide whether the takeover is needed.
2157 *
2158 * @since 2.2.1
2159 * @return bool True when a request should rebuild the sitemap itself.
2160 */
2161 public static function has_overdue_regeneration(): bool {
2162 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2163
2164 if (!is_array($pending) || empty($pending['since'])) {
2165 return false;
2166 }
2167
2168 $due = !empty($pending['next_attempt'])
2169 ? (int) $pending['next_attempt']
2170 : (int) $pending['since'] + self::REGENERATION_TAKEOVER_GRACE;
2171
2172 return time() >= $due;
2173 }
2174
2175 /**
2176 * Rebuild the sitemap in-request when WP-Cron has not delivered.
2177 *
2178 * Hooked on `shutdown` for admin, REST and CLI requests only (see
2179 * Plugin::register_sitemap_cron_listeners()), so the work happens after the
2180 * response has been sent and never adds latency to a visitor page view.
2181 *
2182 * @since 2.2.1
2183 * @return void
2184 */
2185 public function run_overdue_regeneration(): void {
2186 if (!self::has_overdue_regeneration()) {
2187 return;
2188 }
2189
2190 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2191 $pending = is_array($pending) ? $pending : [];
2192 $source = isset($pending['source']) ? (string) $pending['source'] : 'content';
2193
2194 // Cron is running and its own event for this rebuild is still queued
2195 // and due: it is about to do exactly this work. Only then — an event
2196 // that has already been consumed (e.g. it fired while another process
2197 // held the generation lock) is never re-queued, and returning here
2198 // unconditionally left the rebuild to requests that could not finish it.
2199 if (wp_doing_cron()) {
2200 $hook = $source === 'settings' ? 'thinkrank_regenerate_sitemap_settings' : 'thinkrank_regenerate_sitemap';
2201 $next = wp_next_scheduled($hook);
2202
2203 if ($next !== false && $next <= time()) {
2204 return;
2205 }
2206 }
2207
2208 // The last attempt had to stop short of this process's memory limit.
2209 // Retrying at the same (or a lower) limit only repeats that, so leave
2210 // the rebuild to a process with more room — WP-CLI, a system cron, or a
2211 // host with a higher limit — instead of burning it on every request.
2212 if (!$this->has_memory_for_retry($pending)) {
2213 return;
2214 }
2215
2216 if ($source === 'settings') {
2217 $this->regenerate_sitemap_from_settings();
2218 return;
2219 }
2220
2221 $this->auto_regenerate_sitemap();
2222 }
2223
2224 /**
2225 * Acquire the shared generation lock.
2226 *
2227 * @since 2.2.1
2228 * @return bool True when this process may generate.
2229 */
2230 private function acquire_generation_lock(): bool {
2231 if (get_transient(self::GENERATION_LOCK_TRANSIENT)) {
2232 return false;
2233 }
2234
2235 set_transient(self::GENERATION_LOCK_TRANSIENT, time(), 5 * MINUTE_IN_SECONDS);
2236
2237 return true;
2238 }
2239
2240 /**
2241 * Release the shared generation lock.
2242 *
2243 * @since 2.2.1
2244 * @return void
2245 */
2246 private function release_generation_lock(): void {
2247 delete_transient(self::GENERATION_LOCK_TRANSIENT);
2248 }
2249
2250 /**
2251 * This process's PHP memory limit in bytes.
2252 *
2253 * @since 2.10.1
2254 * @return int Bytes, or -1 when unlimited (or unreadable).
2255 */
2256 private function current_memory_limit(): int {
2257 $limit = (string) ini_get('memory_limit');
2258
2259 if ($limit === '' || $limit === '-1') {
2260 return -1;
2261 }
2262
2263 $bytes = (int) wp_convert_hr_to_bytes($limit);
2264
2265 return $bytes > 0 ? $bytes : -1;
2266 }
2267
2268 /**
2269 * May this process retry a rebuild that last stopped at the memory limit?
2270 *
2271 * Raises the limit the way wp-admin does first, so a request that can get
2272 * more room than the failed attempt had is still allowed to try.
2273 *
2274 * @since 2.10.1
2275 * @param array $pending The pending marker.
2276 * @return bool True when there is no recorded memory failure, or this
2277 * process has more memory than the attempt that failed.
2278 */
2279 private function has_memory_for_retry(array $pending): bool {
2280 if (empty($pending['memory_limit'])) {
2281 return true;
2282 }
2283
2284 wp_raise_memory_limit('admin');
2285
2286 $limit = $this->current_memory_limit();
2287
2288 return $limit === -1 || $limit > (int) $pending['memory_limit'];
2289 }
2290
2291 /**
2292 * Release what one walked chunk left behind.
2293 *
2294 * Hydrating a chunk through get_posts()/get_terms() also stores every
2295 * object and its meta in the in-process object cache, which nothing
2296 * empties until the request ends. Unsetting the chunk therefore freed
2297 * nothing, and the walk grew with the size of the site instead of the size
2298 * of a chunk — about 1.3 GB on a 45k-post site.
2299 *
2300 * A persistent object cache that supports it drops only its in-process
2301 * copy (`flush_runtime`); the shared store keeps its data. WordPress's
2302 * default cache has no shared store, and flushing it would empty every
2303 * group for the rest of the request (options, the queried object, other
2304 * plugins' data), so there only the chunk's own entries are deleted. A
2305 * persistent cache without `flush_runtime` is left alone: deleting from it
2306 * would evict the objects for every other request too.
2307 *
2308 * @since 2.10.1
2309 * @param int $chunk_start memory_get_usage(true) before the chunk was hydrated.
2310 * @param array $groups Cache groups keyed by the chunk's object IDs.
2311 * @param int[] $ids The chunk's object IDs, plus any objects it
2312 * hydrated under their own (featured images).
2313 * @return void
2314 * @throws \Error See assert_memory_headroom().
2315 */
2316 private function release_walk_memory(int $chunk_start, array $groups, array $ids): void {
2317 // What one chunk costs before it is released: the margin the next one
2318 // needs. Measured in the same real allocated size assert_memory_headroom()
2319 // compares against the limit, so the two are the same unit.
2320 $this->walk_chunk_cost = max($this->walk_chunk_cost, memory_get_usage(true) - $chunk_start);
2321
2322 if (wp_using_ext_object_cache()) {
2323 if (
2324 function_exists('wp_cache_supports')
2325 && wp_cache_supports('flush_runtime')
2326 && function_exists('wp_cache_flush_runtime')
2327 ) {
2328 wp_cache_flush_runtime();
2329 }
2330 } elseif (!empty($ids)) {
2331 foreach ($groups as $group) {
2332 wp_cache_delete_multiple($ids, $group);
2333 }
2334 }
2335
2336 $this->assert_memory_headroom();
2337 }
2338
2339 /**
2340 * Cache groups get_posts() fills per post for the given post types.
2341 *
2342 * The post, its meta, and one relationships group per taxonomy the post
2343 * type uses (update_object_term_cache()). Term objects themselves are
2344 * bounded by the number of terms, not posts, so they are left cached.
2345 *
2346 * @since 2.10.1
2347 * @param string[] $post_types Post types being walked.
2348 * @return string[] Cache groups keyed by post ID.
2349 */
2350 private function chunk_post_cache_groups(array $post_types): array {
2351 $groups = ['posts', 'post_meta'];
2352
2353 foreach (get_object_taxonomies($post_types) as $taxonomy) {
2354 $groups[] = $taxonomy . '_relationships';
2355 }
2356
2357 return array_values(array_unique($groups));
2358 }
2359
2360 /**
2361 * Stop an automatic rebuild before the memory limit rather than at it.
2362 *
2363 * A PHP memory fatal skips every catch and finally, so the lock, the
2364 * failure record and the backoff are all lost with it, while stopping here
2365 * is an ordinary, fully recorded failure. Checked before each chunk is
2366 * hydrated, against a margin of at least the largest chunk seen so far.
2367 *
2368 * It throws an \Error, not an \Exception, on purpose: the per-segment
2369 * catch (\Exception) blocks in generate_multiple_sitemaps() would otherwise
2370 * swallow it and carry on — writing an index without the aborted segments
2371 * and then pruning their files as orphans. Only the automatic rebuild's
2372 * catch (\Throwable) is meant to see it.
2373 *
2374 * @since 2.10.1
2375 * @return void
2376 * @throws \Error When the automatic rebuild is close to the memory limit.
2377 */
2378 private function assert_memory_headroom(): void {
2379 if (!$this->memory_guard) {
2380 return;
2381 }
2382
2383 $limit = $this->current_memory_limit();
2384 if ($limit === -1) {
2385 return;
2386 }
2387
2388 // A fifth of the limit (at least 32 MB) for writing the files, or one
2389 // and a half of the costliest chunk if that is more — and never more
2390 // than half the limit either way. Without that outer cap a single
2391 // anomalously expensive chunk (500 posts of serialised page-builder or
2392 // ACF meta reaches hundreds of megabytes) puts the margin above the
2393 // limit itself, so every later check aborts at any usage at all, the
2394 // failure records this process's limit, and has_memory_for_retry()
2395 // then refuses every process that has the same limit. A site that
2396 // never actually ran out of memory would stop rebuilding until WP-CLI
2397 // or a system cron happened to run.
2398 $headroom = (int) min(
2399 max(
2400 min(max($limit * 0.2, 32 * MB_IN_BYTES), $limit * 0.5),
2401 $this->walk_chunk_cost * 1.5
2402 ),
2403 $limit * 0.5
2404 );
2405
2406 // The real allocated size, which is what PHP enforces memory_limit
2407 // against; memory_get_usage(false) reports only what is handed out of
2408 // those allocations and so understates the margin by the allocator's
2409 // slack.
2410 $usage = memory_get_usage(true);
2411
2412 if ($usage > $limit - $headroom) {
2413 throw new \Error(
2414 sprintf(
2415 /* translators: 1: memory in use, 2: PHP memory limit. */
2416 __('The sitemap rebuild was stopped at %1$s of the %2$s PHP memory limit, before PHP would have run out of memory. It will be retried by a process with more memory (WP-CLI or a system cron). To let it finish in the admin, raise the PHP memory_limit.', 'thinkrank'),
2417 size_format($usage),
2418 size_format($limit)
2419 ),
2420 self::MEMORY_ABORT_CODE
2421 );
2422 }
2423 }
2424
2425 /**
2426 * Run an automatic rebuild's generation with the fatal-safe bookkeeping.
2427 *
2428 * @since 2.10.1
2429 * @param array $settings Sitemap settings.
2430 * @return bool Whatever generate_and_save() returned.
2431 * @throws \Throwable Whatever generation throws, after the memory guard is
2432 * switched back off.
2433 */
2434 private function generate_for_regeneration(array $settings): bool {
2435 // Same headroom wp-admin gives itself; a no-op when the limit is
2436 // already higher or unlimited.
2437 wp_raise_memory_limit('admin');
2438
2439 $this->claim_regeneration_attempt();
2440 $this->memory_guard = true;
2441 $this->walk_chunk_cost = 0;
2442
2443 try {
2444 return $this->generate_and_save($settings);
2445 } finally {
2446 $this->memory_guard = false;
2447 }
2448 }
2449
2450 /**
2451 * Record a failure thrown by an automatic rebuild.
2452 *
2453 * @since 2.10.1
2454 * @param \Throwable $e What was thrown.
2455 * @param string $source Either 'content' or 'settings'.
2456 * @return void
2457 */
2458 private function record_thrown_regeneration_failure(\Throwable $e, string $source): void {
2459 $memory_limit = null;
2460
2461 if ($e instanceof \Error && $e->getCode() === self::MEMORY_ABORT_CODE) {
2462 $memory_limit = $this->current_memory_limit();
2463 $memory_limit = $memory_limit > 0 ? $memory_limit : null;
2464 }
2465
2466 $this->record_regeneration_failure($e->getMessage(), $source, $memory_limit);
2467 }
2468
2469 /**
2470 * Report how automatic regeneration is faring, for the admin UI.
2471 *
2472 * The feature used to fail invisibly: `last_generated` simply stopped
2473 * advancing and nothing drew attention to it (#629).
2474 *
2475 * @since 2.2.1
2476 * @return array Health payload.
2477 */
2478 public function get_regeneration_health(): array {
2479 $settings = $this->get_settings('site');
2480 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2481 $pending = is_array($pending) ? $pending : [];
2482 $error = get_option(self::REGENERATION_ERROR_OPTION, []);
2483 $error = is_array($error) ? $error : [];
2484
2485 $since = !empty($pending['since']) ? (int) $pending['since'] : 0;
2486
2487 $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap');
2488 if ($next_scheduled === false) {
2489 $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap_settings');
2490 }
2491
2492 return [
2493 'auto_generate' => !empty($settings['auto_generate']),
2494 'last_generated' => $settings['last_generated'] ?? '',
2495 'pending_since' => $since ? gmdate('c', $since) : null,
2496 'pending_seconds' => $since ? max(0, time() - $since) : 0,
2497 'next_scheduled' => $next_scheduled ? gmdate('c', (int) $next_scheduled) : null,
2498 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON,
2499 'stale' => $this->is_sitemap_stale($settings),
2500 'last_error' => !empty($error['message'])
2501 ? [
2502 'message' => (string) $error['message'],
2503 'source' => isset($error['source']) ? (string) $error['source'] : 'content',
2504 'time' => !empty($error['time']) ? gmdate('c', (int) $error['time']) : null,
2505 ]
2506 : null,
2507 ];
2508 }
2509
2510 /**
2511 * Has published content changed since the served sitemap was last written?
2512 *
2513 * Uses core's cached last-modified lookup, which only considers published
2514 * posts — the same content the sitemap covers.
2515 *
2516 * @since 2.2.1
2517 * @param array $settings Sitemap settings.
2518 * @return bool True when the sitemap is behind the content.
2519 */
2520 private function is_sitemap_stale(array $settings): bool {
2521 if (empty($settings['enabled']) || empty($settings['last_generated'])) {
2522 // Never generated is already reported separately by the UI.
2523 return false;
2524 }
2525
2526 $generated = strtotime((string) $settings['last_generated']);
2527 if (!$generated) {
2528 return false;
2529 }
2530
2531 $modified = get_lastpostmodified('gmt');
2532 if (!$modified) {
2533 return false;
2534 }
2535
2536 $modified = strtotime($modified . ' UTC');
2537 if (!$modified) {
2538 return false;
2539 }
2540
2541 // A minute of slack keeps a rebuild that ran alongside the edit from
2542 // reporting itself as stale.
2543 return $modified > ($generated + MINUTE_IN_SECONDS);
2544 }
2545
2546 /**
2547 * Public entry point to debounce-rebuild the sitemap after a settings change
2548 * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
2549 * file reflects the new settings instead of going stale until a content edit.
2550 *
2551 * @return void
2552 */
2553 public function schedule_regeneration(): void {
2554 // Debounce against rapid successive saves, but use the settings-specific
2555 // hook so the rebuild runs regardless of the auto_generate toggle (which
2556 // only governs content-change-triggered regeneration).
2557 $this->mark_regeneration_pending('settings');
2558 $this->debounce_event('thinkrank_regenerate_sitemap_settings');
2559 }
2560
2561 /**
2562 * Rebuild the served sitemap after an explicit settings change.
2563 *
2564 * Unlike {@see auto_regenerate_sitemap()}, this is NOT gated on the
2565 * auto_generate setting: the user deliberately changed inclusion rules and
2566 * expects the served file to reflect them even if content-triggered
2567 * auto-generation is turned off. Still respects the master `enabled` flag.
2568 *
2569 * @since 2.10.0 Reports whether the served sitemap was actually rebuilt, so
2570 * a caller can say so rather than assume it (#764). Existing
2571 * callers that ignore the return are unaffected.
2572 *
2573 * @return bool True when the served sitemap now reflects the settings.
2574 */
2575 public function regenerate_sitemap_from_settings(): bool {
2576 if (!$this->acquire_generation_lock()) {
2577 // A manual generation (or another request's takeover) is already
2578 // writing the files; the pending marker survives so this rebuild is
2579 // retried rather than lost.
2580 return false;
2581 }
2582
2583 try {
2584 $settings = $this->get_settings('site');
2585 if (empty($settings['enabled'])) {
2586 // The sitemap was disabled: remove the previously generated static
2587 // files so the web server stops serving a stale sitemap that
2588 // crawlers would otherwise keep fetching.
2589 //
2590 // A file that could not be removed is still being served, so
2591 // this is not a success. Reporting one here would tell a caller
2592 // the sitemap was gone while the web server kept answering with
2593 // it, which is the failure this return value exists to prevent
2594 // (#764).
2595 $removal = $this->delete_published_sitemaps($settings);
2596 $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2597
2598 if (!empty($stuck)) {
2599 $this->record_regeneration_failure(
2600 $this->stuck_files_message($stuck, true),
2601 'settings'
2602 );
2603
2604 return false;
2605 }
2606
2607 $this->mark_regeneration_complete();
2608
2609 return true;
2610 }
2611
2612 $revision = $this->current_regeneration_revision();
2613
2614 if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2615 // Returns false when a static file is stuck in the web root:
2616 // the server keeps serving that file in preference to WordPress,
2617 // so the switch has not taken effect (#764).
2618 return $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2619 }
2620
2621 if ($this->generate_for_regeneration($settings)) {
2622 $this->mark_regeneration_complete($revision);
2623
2624 return true;
2625 }
2626
2627 $this->record_regeneration_failure(
2628 $this->write_failure_message(),
2629 'settings'
2630 );
2631
2632 return false;
2633 } catch (\Throwable $e) {
2634 $this->record_thrown_regeneration_failure($e, 'settings');
2635
2636 return false;
2637 } finally {
2638 $this->release_generation_lock();
2639 }
2640 }
2641
2642 /**
2643 * When a rebuild has been outstanding since, or 0 when none is.
2644 *
2645 * Lets a caller report an honest "saved, but the served file has not caught
2646 * up yet" instead of a bare success (#764).
2647 *
2648 * @since 2.10.0
2649 * @return int Unix timestamp, or 0 when nothing is pending.
2650 */
2651 public static function regeneration_pending_since(): int {
2652 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2653
2654 if (!is_array($pending) || empty($pending['since'])) {
2655 return 0;
2656 }
2657
2658 return (int) $pending['since'];
2659 }
2660
2661 /**
2662 * Remove every static sitemap file ThinkRank publishes to the web root.
2663 *
2664 * Called when the sitemap feature is disabled, by the cleanup route, and by
2665 * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
2666 * children (incl. paginated -N pages), and /local-sitemap.xml stop being
2667 * served. Only ThinkRank's own filenames are targeted; WordPress core's
2668 * wp-sitemap.xml and any other plugin's sitemap in the web root are left
2669 * untouched.
2670 *
2671 * @since 1.31.0 Returns the filenames removed, and accepts the settings to
2672 * derive them from, so a caller that already read them (and
2673 * needs to report what went) does not have to re-read or
2674 * re-derive the name list.
2675 * @since 2.1.0 Delegates to thinkrank_webroot_delete_sitemaps(). Uninstall
2676 * needs the same removal but has no autoloader to reach this
2677 * class, so the logic moved to includes/cleanup-webroot.php
2678 * and this stays as the in-plugin entry point.
2679 *
2680 * @param array|null $settings Optional. Sitemap settings; defaults to the
2681 * saved site settings.
2682 * @return array{deleted: string[], failed: string[]} Basenames removed, and
2683 * those that existed but could not be removed.
2684 */
2685 public function delete_published_sitemaps(?array $settings = null): array {
2686 return thinkrank_webroot_delete_sitemaps($settings ?? $this->get_settings('site'));
2687 }
2688
2689 /**
2690 * Every child-sitemap filename this site could have published.
2691 *
2692 * @since 1.31.0
2693 * @since 2.1.0 Delegates to thinkrank_webroot_segment_filenames().
2694 *
2695 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
2696 * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
2697 */
2698 private function publishable_segment_filenames(array $settings): array {
2699 return thinkrank_webroot_segment_filenames($settings);
2700 }
2701
2702 /**
2703 * Can this web-root file be shown to be a sitemap ThinkRank wrote?
2704 *
2705 * The generator deletes as often as cleanup does — a segment that dropped
2706 * out of the set, a pagination page beyond the new count, the local sitemap
2707 * after the business identity was cleared — and until 2.1.1 it did all
2708 * three by filename alone. That is the #515 bug on a far more frequent
2709 * trigger: our names are the canonical ones, so an ordinary regeneration
2710 * (post save, term change, settings save) destroyed RankMath's
2711 * `sitemap-tags.xml` and `local-sitemap.xml` with no deactivation involved.
2712 *
2713 * Every name the generator derives comes from the current settings — the
2714 * url pattern, the configured `sitemap_urls`, `local-sitemap.xml` — but the
2715 * ownership test is still asked with `$name_derived = false`, which switches
2716 * off the legacy fallback for the whole generator side.
2717 *
2718 * The fallback exists to recover a pre-2.1.1 file written with
2719 * `enable_styling` off, which carries neither marker. That recovery belongs
2720 * to the once-off cleanup paths. Here it can only do harm: this method runs
2721 * on every post save, and everything this version writes carries
2722 * THINKRANK_SITEMAP_MARKER, so after the site's first regeneration an
2723 * unmarked file at one of our names is by definition somebody else's — and
2724 * deleting it on an ordinary regeneration is #515 through the more common
2725 * door. The cost is a stale unmarked segment left on disk until deactivation
2726 * picks it up, which is the safe direction to fail in.
2727 *
2728 * @since 2.1.1
2729 *
2730 * @param string $path Absolute path to a file in the web root.
2731 * @param array $settings Sitemap settings.
2732 * @return bool True when the file may be deleted.
2733 */
2734 private function webroot_sitemap_is_ours(string $path, array $settings): bool {
2735 return thinkrank_webroot_sitemap_is_ours($path, $settings, false);
2736 }
2737
2738 /**
2739 * Auto-regenerate sitemap (called by scheduled action)
2740 *
2741 * @since 1.0.0
2742 * @return void
2743 */
2744 public function auto_regenerate_sitemap(): void {
2745 if (!$this->acquire_generation_lock()) {
2746 // A manual generation (or another request's takeover) is already
2747 // writing the files; the pending marker survives so this rebuild is
2748 // retried rather than lost.
2749 return;
2750 }
2751
2752 try {
2753 // Double-check that auto-generation is still enabled
2754 if (!$this->should_auto_generate()) {
2755 // Nothing outstanding can be delivered while the feature is off,
2756 // so drop the marker rather than let the takeover retry forever.
2757 $this->mark_regeneration_complete();
2758 return;
2759 }
2760
2761 $revision = $this->current_regeneration_revision();
2762 $settings = $this->get_settings('site');
2763
2764 // See regenerate_sitemap_from_settings(): nothing to write.
2765 if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2766 $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2767 return;
2768 }
2769
2770 if ($this->generate_for_regeneration($settings)) {
2771 $this->mark_regeneration_complete($revision);
2772 } else {
2773 // Previously this returned quietly and last_generated simply
2774 // stopped advancing, leaving the site owner with no way to learn
2775 // the sitemap had stopped updating (#629).
2776 $this->record_regeneration_failure(
2777 $this->write_failure_message(),
2778 'content'
2779 );
2780 }
2781 } catch (\Throwable $e) {
2782 $this->record_thrown_regeneration_failure($e, 'content');
2783 } finally {
2784 $this->release_generation_lock();
2785 }
2786 }
2787
2788 /**
2789 * Build and write the sitemap files for the given settings.
2790 *
2791 * Segmented files plus an index when the settings configure one, a single
2792 * file otherwise, and the standalone local business sitemap either way.
2793 * Records `last_generated` so the UI's "View Generated Sitemaps" links
2794 * unlock, mirroring the manual generate endpoint.
2795 *
2796 * @since 1.17.0
2797 * @param array $settings Sitemap settings.
2798 * @return bool True when the sitemap files were written.
2799 */
2800 public function generate_and_save(array $settings): bool {
2801 // Dynamic delivery publishes no files, so writing them here would put a
2802 // static copy back in the web root for the server to serve in place of
2803 // the dynamic route. Guarding at each call site left gaps — the
2804 // snapshot migrator's post-import regeneration had none — so the rule
2805 // lives with the writing instead.
2806 //
2807 // `is_collecting()` is the exception that makes dynamic delivery work
2808 // at all: render_document() and collect_documents() reach this same
2809 // method with the writer swapped for a collector, and that is precisely
2810 // the dynamic build. Only a real write is skipped.
2811 if (!$this->is_collecting() && 'dynamic' === $this->resolve_delivery_mode($settings)) {
2812 // Whatever prompted this call changed the sitemap's content, so the
2813 // rendered copies must not outlive it.
2814 $this->flush_dynamic_cache();
2815
2816 return true;
2817 }
2818
2819 // Index mode is driven by the use_sitemap_index toggle (not merely by how
2820 // many sitemap_urls happen to be configured). When the toggle is on but
2821 // no child sitemaps are set up yet, synthesize the per-type segmented set
2822 // so we emit a real <sitemapindex> with paginated children instead of a
2823 // flat urlset misnamed sitemap_index.xml.
2824 $settings = $this->maybe_promote_to_index($settings);
2825
2826 if (!empty($settings['use_sitemap_index']) || count((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : [])) > 1) {
2827 $results = $this->generate_multiple_sitemaps($settings);
2828 $written = !empty($results['success']);
2829 } else {
2830 $xml = $this->generate_sitemap($settings);
2831 $primary = $this->get_primary_sitemap_filename($settings);
2832 $written = $this->save_sitemap_to_file($xml, $primary);
2833
2834 // Local business sitemap is a standalone file, regenerated on the
2835 // single-sitemap path too (this is the default mode).
2836 $this->regenerate_local_sitemap($settings);
2837
2838 // Switching out of index mode leaves sitemap_index.xml and every
2839 // child on disk, still served and never refreshed again. The index
2840 // path already prunes what it no longer owns; this path never did,
2841 // so the site kept serving two sitemap trees (#563). Ownership is
2842 // still tested per file, so another plugin's sitemap at one of our
2843 // names is never touched (#515).
2844 $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
2845 }
2846
2847 // last_generated describes what is on disk. A dynamic render publishes
2848 // nothing, so advancing it would report a static publication that never
2849 // happened and would let primary_sitemap_file_exists() callers believe
2850 // there is a file to serve.
2851 if ($written && !$this->is_collecting()) {
2852 $settings['last_generated'] = gmdate('c');
2853 $this->save_settings('site', null, $settings);
2854 }
2855
2856 return $written;
2857 }
2858
2859 /**
2860 * Whether this instance is rendering documents rather than publishing them.
2861 *
2862 * @since 2.9.0
2863 *
2864 * @return bool
2865 */
2866 private function is_collecting(): bool {
2867 return $this->document_sink !== null;
2868 }
2869
2870 /**
2871 * Complete a regeneration that delivers dynamically, retiring stale files.
2872 *
2873 * Dynamic delivery renders nothing to disk, but that is only half the job.
2874 * A web server hands back an existing `/sitemap.xml` without ever loading
2875 * WordPress, so any file left over from a previous static generation goes on
2876 * being served forever and {@see \ThinkRank\Frontend\SEO_Manager
2877 * ::maybe_serve_sitemap()} is never reached. Switching to dynamic while
2878 * leaving those files in place would therefore appear to do nothing at all.
2879 *
2880 * Both transitions matter and they differ:
2881 *
2882 * - An explicit switch to `dynamic` happens on a site whose root is usually
2883 * still writable, so the files can simply be removed.
2884 * - An `auto` site that becomes read-only cannot remove them, because
2885 * deleting an entry needs write permission on the directory that holds
2886 * it. There the stale sitemap really is stuck in front of us, and the
2887 * honest outcome is a recorded failure naming it rather than a rebuild
2888 * reported as complete (#754 review).
2889 *
2890 * Ownership is tested per file by the shared helper, so another plugin's
2891 * sitemap at one of our names is never deleted (#515).
2892 *
2893 * @since 2.9.0
2894 *
2895 * @param array $settings Sitemap settings.
2896 * @param int $revision Revision this rebuild is completing.
2897 * @param string $source 'settings' or 'content', for the failure record.
2898 * @return void
2899 */
2900 private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): bool {
2901 $this->flush_dynamic_cache();
2902
2903 $removal = $this->delete_published_sitemaps($settings);
2904 $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2905
2906 if (!empty($stuck)) {
2907 $this->record_regeneration_failure($this->stuck_files_message($stuck), $source);
2908
2909 return false;
2910 }
2911
2912 $this->mark_regeneration_complete($revision);
2913
2914 return true;
2915 }
2916
2917 /**
2918 * Why a stale file left in the web root means the change has not landed.
2919 *
2920 * Shared by every path that removes published files, so they cannot
2921 * describe the same situation differently (#764).
2922 *
2923 * The two situations that reach it differ in what WordPress is doing, and
2924 * the message has to say which. After a switch to dynamic delivery
2925 * WordPress IS serving the sitemap and the files shadow it. After the
2926 * sitemap is switched off WordPress serves nothing, so the one message
2927 * used to tell a site owner who had just disabled the sitemap that it was
2928 * "being served from WordPress", which is the opposite of what they did.
2929 *
2930 * @since 2.10.0
2931 * @since 2.10.0 Public, so the REST endpoint uses it rather than a copy;
2932 * takes $sitemap_disabled for the disabled path.
2933 *
2934 * @param string[] $stuck Basenames that could not be removed.
2935 * @param bool $sitemap_disabled True when the files outlived disabling
2936 * the sitemap rather than a switch to
2937 * dynamic delivery.
2938 * @return string
2939 */
2940 public function stuck_files_message(array $stuck, bool $sitemap_disabled = false): string {
2941 if ($sitemap_disabled) {
2942 return sprintf(
2943 /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2944 __('The sitemap is disabled, but these files are still in the site root and your web server is still serving them: %1$s. They could not be removed because %2$s is not writable. Delete them, or ask your host to make the WordPress root writable.', 'thinkrank'),
2945 implode(', ', $stuck),
2946 untrailingslashit(ABSPATH)
2947 );
2948 }
2949
2950 return sprintf(
2951 /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2952 __('The sitemap is being served from WordPress, but these files are still in the site root and your web server will keep serving them instead: %1$s. They could not be removed because %2$s is not writable. Delete them, or ask your host to make the WordPress root writable.', 'thinkrank'),
2953 implode(', ', $stuck),
2954 untrailingslashit(ABSPATH)
2955 );
2956 }
2957
2958 /**
2959 * What to tell the site owner when publishing the files failed.
2960 *
2961 * The old wording stated the symptom and stopped there, so the reported
2962 * cause was a guess and this reached support as a plugin fault rather than
2963 * a folder permission (#752, #753). When the root is demonstrably
2964 * unwritable, say that, and say what to do about it.
2965 *
2966 * @since 2.9.0
2967 *
2968 * @return string
2969 */
2970 private function write_failure_message(): string {
2971 if (!wp_is_writable(ABSPATH)) {
2972 return sprintf(
2973 /* translators: %s: absolute path to the WordPress root. */
2974 __('The sitemap could not be written because the folder %s is not writable by PHP. Ask your host to make the WordPress root writable, or set Sitemap Delivery to Dynamic to serve the sitemap without writing files.', 'thinkrank'),
2975 untrailingslashit(ABSPATH)
2976 );
2977 }
2978
2979 return __('The sitemap files could not be written to the site root.', 'thinkrank');
2980 }
2981
2982 /**
2983 * How this site delivers its sitemap.
2984 *
2985 * `auto` is resolved on whether the web root can be written. That is the
2986 * right signal here (unlike llms.txt, where the question is whether the
2987 * server applies the .htaccess charset block): a site whose root is
2988 * read-only cannot publish a sitemap file at all, and before this existed
2989 * the feature simply failed with "The sitemap files could not be written to
2990 * the site root." and served nothing (#752).
2991 *
2992 * @since 2.9.0
2993 *
2994 * @param array|null $settings Sitemap settings (falls back to saved ones).
2995 * @return string One of 'static' or 'dynamic'. Never 'auto'.
2996 */
2997 public function resolve_delivery_mode(?array $settings = null): string {
2998 $settings = $settings ?? $this->get_settings('site');
2999 $mode = (string) ($settings['delivery_mode'] ?? 'auto');
3000
3001 if ('static' === $mode || 'dynamic' === $mode) {
3002 return $mode;
3003 }
3004
3005 return wp_is_writable(ABSPATH) ? 'static' : 'dynamic';
3006 }
3007
3008 /**
3009 * Render one published sitemap document without touching the filesystem.
3010 *
3011 * Runs the ordinary build pipeline with the writer swapped for a collector,
3012 * so the bytes returned here are the bytes the static path would have
3013 * written. `SitemapDeliveryParityTest` asserts that equivalence rather than
3014 * trusting it.
3015 *
3016 * The whole set is built to answer for one file, because the index can only
3017 * be assembled from the children that were actually produced. The result is
3018 * cached per document, so that cost is paid once per change and not once
3019 * per crawler request.
3020 *
3021 * @since 2.9.0
3022 *
3023 * @param string $filename Published file name, e.g. 'sitemap.xml'.
3024 * @param array|null $settings Sitemap settings (falls back to saved ones).
3025 * @return string|null XML, or null when this site does not publish that name.
3026 */
3027 public function render_document(string $filename, ?array $settings = null): ?string {
3028 $filename = basename($filename);
3029 $settings = $settings ?? $this->get_settings('site');
3030
3031 if (empty($settings['enabled'])) {
3032 return null;
3033 }
3034
3035 $cached = get_transient($this->dynamic_cache_key($filename));
3036 if (self::ABSENT_MARKER === $cached) {
3037 return null;
3038 }
3039 if (is_string($cached) && '' !== $cached) {
3040 return $cached;
3041 }
3042
3043 // A miss builds the whole set, because the index can only be assembled
3044 // from the children that were actually produced. Caching only the
3045 // requested document therefore made a crawler walking the index and its
3046 // children rebuild the entire site's sitemap once per file — every post
3047 // and taxonomy query repeated N times on a public endpoint (#754
3048 // review). The set is built once and stored in full.
3049 return $this->stream_documents($settings, $filename);
3050 }
3051
3052 /**
3053 * Build every document, caching each as it is produced, keeping one.
3054 *
3055 * A miss has to build the whole set, because the index can only be
3056 * assembled from the children that were actually produced. It does not have
3057 * to *hold* the whole set: the static path never keeps more than one page
3058 * in memory, writing each to disk as it goes, and buffering every
3059 * document's XML to return one of them undid that on the request path,
3060 * where a large site's entire sitemap corpus would sit in a single PHP
3061 * process (#754 review).
3062 *
3063 * So the sink writes each document straight to its cache entry and lets it
3064 * go, retaining only the one this request is answering. Peak retention is
3065 * one document, whatever the site's size.
3066 *
3067 * Concurrency: the first request through takes a short lock and does the
3068 * work. One that finds the lock held waits a bounded moment for the winner
3069 * to publish, then builds anyway, because serving a correct sitemap late
3070 * beats serving none.
3071 *
3072 * @since 2.9.0
3073 *
3074 * @param array $settings Sitemap settings.
3075 * @param string $wanted Document this request is answering.
3076 * @return string|null XML for $wanted, or null when the site does not publish it.
3077 */
3078 private function stream_documents(array $settings, string $wanted): ?string {
3079 $lock = self::DYNAMIC_CACHE_PREFIX . 'lock';
3080
3081 if (!$this->acquire_render_lock($lock)) {
3082 for ($attempt = 0; $attempt < self::RENDER_LOCK_WAIT_ATTEMPTS; $attempt++) {
3083 usleep(self::RENDER_LOCK_WAIT_MICROSECONDS);
3084
3085 $cached = get_transient($this->dynamic_cache_key($wanted));
3086 if (self::ABSENT_MARKER === $cached) {
3087 return null;
3088 }
3089 if (is_string($cached) && '' !== $cached) {
3090 return $cached;
3091 }
3092 }
3093 }
3094
3095 $kept = null;
3096 // Names only. Keeping the bodies here would be the very retention this
3097 // method exists to avoid.
3098 $produced = [];
3099
3100 $previous = $this->document_sink;
3101 $this->document_sink = function (string $name, string $xml) use (&$kept, &$produced, $wanted): void {
3102 $produced[$name] = true;
3103 set_transient($this->dynamic_cache_key($name), $xml, self::DYNAMIC_CACHE_TTL);
3104
3105 if ($name === $wanted) {
3106 $kept = $xml;
3107 }
3108 };
3109
3110 try {
3111 $this->generate_and_save($settings);
3112
3113 // Names the configuration lists but this build did not produce get
3114 // a negative entry, so asking for one again is a cache hit rather
3115 // than another full rebuild.
3116 $absent = $this->published_document_names($settings);
3117
3118 // Also the exact name this request asked for: a paginated page past
3119 // the end of a stem is a legitimate request shape that the base
3120 // list cannot enumerate, and without an entry it would rebuild on
3121 // every hit.
3122 $absent[] = $wanted;
3123
3124 foreach (array_unique($absent) as $name) {
3125 if (!isset($produced[$name])) {
3126 set_transient($this->dynamic_cache_key($name), self::ABSENT_MARKER, self::DYNAMIC_CACHE_TTL);
3127 }
3128 }
3129 } finally {
3130 $this->document_sink = $previous;
3131 delete_transient($lock);
3132 }
3133
3134 return $kept;
3135 }
3136
3137 /**
3138 * Take the render lock, if it is free.
3139 *
3140 * Not atomic across processes, and deliberately so: the fallback for losing
3141 * a race is duplicated work, never a wrong or missing sitemap, so a
3142 * heavier primitive would buy nothing here.
3143 *
3144 * @since 2.9.0
3145 *
3146 * @param string $lock Lock transient name.
3147 * @return bool True when this request holds the lock.
3148 */
3149 private function acquire_render_lock(string $lock): bool {
3150 if (false !== get_transient($lock)) {
3151 return false;
3152 }
3153
3154 set_transient($lock, time(), self::RENDER_LOCK_TTL);
3155
3156 return true;
3157 }
3158
3159 /**
3160 * Build every document this site publishes and return them all.
3161 *
3162 * Verification and tooling only. This retains the whole set in memory, so
3163 * it must never be used to answer a request: {@see self::stream_documents()}
3164 * is the serving path and keeps one document at a time regardless of site
3165 * size (#754 review). `SitemapDeliveryParityTest` enforces that separation
3166 * by failing if the request path routes back through here.
3167 *
3168 * @since 2.9.0
3169 *
3170 * @param array $settings Sitemap settings.
3171 * @return array<string,string> Filename => XML.
3172 */
3173 public function collect_documents(array $settings): array {
3174 $documents = [];
3175
3176 $previous = $this->document_sink;
3177 $this->document_sink = static function (string $name, string $xml) use (&$documents): void {
3178 $documents[$name] = $xml;
3179 };
3180
3181 try {
3182 $this->generate_and_save($settings);
3183 } finally {
3184 $this->document_sink = $previous;
3185 }
3186
3187 return $documents;
3188 }
3189
3190 /**
3191 * The file names this site publishes, without building their contents.
3192 *
3193 * Used by the request router to decide whether a URL is ours before doing
3194 * any work. Cheap: it reads the configured child list rather than querying
3195 * for entries.
3196 *
3197 * @since 2.9.0
3198 *
3199 * @param array|null $settings Sitemap settings (falls back to saved ones).
3200 * @return string[] File names, including paginated pages that may exist.
3201 */
3202 public function published_document_names(?array $settings = null): array {
3203 $settings = $settings ?? $this->get_settings('site');
3204 $resolved = $this->maybe_promote_to_index($settings);
3205
3206 $names = [$this->get_primary_sitemap_filename($settings), 'local-sitemap.xml'];
3207
3208 foreach ((array) ($resolved['sitemap_urls'] ?? []) as $child) {
3209 if (!is_array($child) || empty($child['enabled'])) {
3210 continue;
3211 }
3212
3213 $path = (string) wp_parse_url((string) ($child['url'] ?? ''), PHP_URL_PATH);
3214 if ('' !== $path) {
3215 $names[] = basename($path);
3216 }
3217 }
3218
3219 return array_values(array_unique(array_filter($names)));
3220 }
3221
3222 /**
3223 * Does this site publish a document under that name?
3224 *
3225 * Not a plain membership test against {@see self::published_document_names()}:
3226 * that lists the configured children, and a child over the per-file URL cap
3227 * is split into `<stem>-2.xml`, `<stem>-3.xml` and so on, with every page
3228 * listed in the index. Gating the request router on the base list alone
3229 * therefore 404'd exactly the pages the index points at, which is worse than
3230 * not serving them at all.
3231 *
3232 * Page counts are not knowable without building, so the stem is what is
3233 * matched; a page that does not exist is answered by the build finding
3234 * nothing for it, and is then cached as absent.
3235 *
3236 * @since 2.9.0
3237 *
3238 * @param string $name Requested file name.
3239 * @param array|null $settings Sitemap settings (falls back to saved ones).
3240 * @return bool
3241 */
3242 public function publishes_document_name(string $name, ?array $settings = null): bool {
3243 $names = $this->published_document_names($settings);
3244
3245 if (in_array($name, $names, true)) {
3246 return true;
3247 }
3248
3249 if (!preg_match('/^(.*)-\d+\.xml$/i', $name, $m)) {
3250 return false;
3251 }
3252
3253 return in_array($m[1] . '.xml', $names, true);
3254 }
3255
3256 /**
3257 * Transient key for a rendered document.
3258 *
3259 * @since 2.9.0
3260 *
3261 * @param string $filename Published file name.
3262 * @return string
3263 */
3264 private function dynamic_cache_key(string $filename): string {
3265 return self::DYNAMIC_CACHE_PREFIX . md5($filename);
3266 }
3267
3268 /**
3269 * Drop every cached dynamic document.
3270 *
3271 * Called from the same places that mark the static files stale, so the two
3272 * delivery modes invalidate on identical triggers.
3273 *
3274 * @since 2.9.0
3275 *
3276 * @return void
3277 */
3278 public function flush_dynamic_cache(): void {
3279 foreach ($this->published_document_names() as $name) {
3280 delete_transient($this->dynamic_cache_key($name));
3281 }
3282 }
3283
3284 /**
3285 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
3286 *
3287 * - When use_sitemap_index is on but no child sitemaps are configured, build
3288 * the per-type segmented set so a real <sitemapindex> is produced (#127).
3289 * - When the toggle is off but a single flat file would exceed the per-file
3290 * URL cap, auto-promote to a paginated index instead of one oversized file
3291 * that can cross Google's 50k-URL/50MB limits (#129).
3292 *
3293 * @param array $settings Sitemap settings.
3294 * @return array Possibly-updated settings.
3295 */
3296 public function maybe_promote_to_index(array $settings): array {
3297 $saved = $this->get_settings('site');
3298
3299 // Read from the *payload*, before the merge below folds the saved values
3300 // in: "the caller named this" and "this has a value" are different
3301 // questions, and the mode resolution turns on the former.
3302 $mode_supplied = array_key_exists('use_sitemap_index', $settings);
3303 $urls_supplied = is_array($settings['sitemap_urls'] ?? null);
3304 $has_children = count($urls_supplied ? $settings['sitemap_urls'] : []) > 1;
3305
3306 // Which inclusion flags did the caller actually name? The child list is
3307 // the only thing that reads them, and inheriting a saved one skipped
3308 // that — so on an index-mode site the include_* flags were enforced
3309 // nowhere but in the browser, where SitemapGeneration.js recomputes
3310 // sitemap_urls itself. Every non-UI client, the shipped
3311 // `update-sitemap-settings` ability included, saved the flag and changed
3312 // nothing (#398). Read from the payload for the same reason as above:
3313 // after the merge every saved flag would look like one the caller named.
3314 $named_inclusions = array_intersect(
3315 array_keys(self::INCLUSION_CHILD_TYPES),
3316 array_keys($settings)
3317 );
3318 $inclusions_supplied = (bool) $named_inclusions;
3319
3320 // Inclusion flags may be absent from a partial payload (e.g. the manual
3321 // generate endpoint) — fall back to saved settings so synthesized child
3322 // sitemaps reflect the real include_posts/pages/categories choices.
3323 $inclusions = array_merge($saved, $settings);
3324
3325 // Hand the generators a *complete* settings array. Only the mode was
3326 // resolved before, so every other unnamed key reached them missing: a
3327 // bare `{}` from a REST/MCP client republished the sitemap with
3328 // enable_styling and include_images read as off, overwriting the live
3329 // files with output that had lost its XSL stylesheet, its image
3330 // namespace and its image entries. Presentation and inclusion settings
3331 // are not something a generate call opts into — they are the site's
3332 // configuration, and only a value actually present in the payload
3333 // overrides them.
3334 $settings = $inclusions;
3335
3336 // The two keys that drive mode keep their own resolution rules below,
3337 // so they must go back to "not specified" when the caller omitted them.
3338 if (!$mode_supplied) {
3339 unset($settings['use_sitemap_index']);
3340 }
3341 if (!$urls_supplied) {
3342 unset($settings['sitemap_urls']);
3343 }
3344
3345 // An absent use_sitemap_index means "not specified", which is not the
3346 // same as "single file". Reading it as the latter meant a partial payload
3347 // — `{}` from a REST/MCP client, or anything short of the full settings
3348 // object the admin bundle sends — republished one flat sitemap.xml on an
3349 // index-mode site and left sitemap_index.xml and its children stale or
3350 // missing. Inherit the saved mode instead; only a value actually present
3351 // in the payload decides the mode.
3352 if (!array_key_exists('use_sitemap_index', $settings)) {
3353 $settings['use_sitemap_index'] = $saved['use_sitemap_index'] ?? '';
3354
3355 // Inheriting the mode means inheriting its children too, unless the
3356 // caller named its own set.
3357 $saved_children = (is_array($saved['sitemap_urls'] ?? null) ? $saved['sitemap_urls'] : []);
3358 if (!empty($settings['use_sitemap_index'])
3359 && !$urls_supplied
3360 && count($saved_children) > 1) {
3361 // A named inclusion flag is applied *to* the inherited list, not
3362 // used to regenerate it. build_segmented_sitemap_urls() also adds
3363 // a child for every public custom post type, so rebuilding here
3364 // would make `{include_pages: false}` — one thing off — silently
3365 // switch on children the saved list never had (an Elementor
3366 // internal CPT, a WooCommerce product feed). Only the flags the
3367 // caller actually named change anything.
3368 $settings['sitemap_urls'] = $inclusions_supplied
3369 ? $this->apply_inclusion_flags_to_children($saved_children, $named_inclusions, $inclusions)
3370 : $saved_children;
3371
3372 // The children are resolved either way — including when the
3373 // caller switched the last one off, which leaves a bare index and
3374 // is what they asked for.
3375 $has_children = true;
3376 }
3377 }
3378
3379 if (!empty($settings['use_sitemap_index'])) {
3380 if (!$has_children) {
3381 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
3382 }
3383 return $settings;
3384 }
3385
3386 if (!$has_children) {
3387 $limit = $this->get_links_per_sitemap($settings);
3388 if ($limit > 0 && $this->estimate_total_sitemap_urls($inclusions) > $limit) {
3389 $settings['use_sitemap_index'] = true;
3390 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
3391 }
3392 }
3393
3394 return $settings;
3395 }
3396
3397 /**
3398 * Rough count of URLs a single-file sitemap would contain, used only to
3399 * decide whether to auto-promote to a paginated index. Cheap COUNT queries;
3400 * intentionally approximate (homepage + published posts of enabled types +
3401 * terms of enabled taxonomies).
3402 *
3403 * @param array $settings Sitemap settings.
3404 * @return int
3405 */
3406 private function estimate_total_sitemap_urls(array $settings): int {
3407 $total = 1; // homepage
3408
3409 foreach ($this->get_enabled_post_types($settings) as $post_type) {
3410 $counts = wp_count_posts($post_type);
3411 $total += isset($counts->publish) ? (int) $counts->publish : 0;
3412 }
3413
3414 foreach ($this->get_enabled_taxonomies($settings) as $taxonomy) {
3415 $count = wp_count_terms(['taxonomy' => $taxonomy, 'hide_empty' => true]);
3416 if (!is_wp_error($count)) {
3417 $total += (int) $count;
3418 }
3419 }
3420
3421 return $total;
3422 }
3423
3424 /**
3425 * Resolve the sitemap file the site publishes for the given settings.
3426 *
3427 * Index mode serves the index (`sitemap_index.xml`); the default single-file
3428 * mode serves `sitemap.xml`. Callers use this to link to — or check for —
3429 * the file the site actually serves, since ThinkRank's sitemap is a static
3430 * file in the web root rather than a route.
3431 *
3432 * @since 1.17.0
3433 * @param array $settings Sitemap settings.
3434 * @return string Sitemap filename.
3435 */
3436 public function get_primary_sitemap_filename(array $settings): string {
3437 return thinkrank_webroot_primary_sitemap_filename($settings);
3438 }
3439
3440 /**
3441 * Public URL of the sitemap the site serves.
3442 *
3443 * @since 1.17.0
3444 * @param array|null $settings Sitemap settings (falls back to saved site settings).
3445 * @return string Absolute sitemap URL.
3446 */
3447 public function get_primary_sitemap_url(?array $settings = null): string {
3448 $settings = $settings ?? $this->get_settings('site');
3449
3450 return home_url('/' . $this->get_primary_sitemap_filename($settings));
3451 }
3452
3453 /**
3454 * Whether the sitemap file this site publishes exists on disk.
3455 *
3456 * @since 1.17.0
3457 * @param array $settings Sitemap settings.
3458 * @return bool True when the file is present.
3459 */
3460 public function primary_sitemap_file_exists(array $settings): bool {
3461 return file_exists(ABSPATH . $this->get_primary_sitemap_filename($settings));
3462 }
3463
3464 /**
3465 * Save sitemap XML to file
3466 *
3467 * @since 1.0.0
3468 * @param string $sitemap_xml Sitemap XML content
3469 * @param string $filename Optional. Filename to save (defaults to 'sitemap.xml')
3470 * @return bool True on success, false on failure
3471 */
3472 private function save_sitemap_to_file(string $sitemap_xml, string $filename = 'sitemap.xml'): bool {
3473 // Validate and sanitize filename for security
3474 try {
3475 $filename = $this->validate_sitemap_filename($filename);
3476 } catch (InvalidArgumentException $e) {
3477 // File validation failed - error details available in exception
3478 return false;
3479 }
3480
3481 // Dynamic delivery: hand the document to the collector instead of the
3482 // filesystem. Reported as published, because for this run it is — the
3483 // caller's success/failure bookkeeping and the index assembly both key
3484 // off this return value.
3485 if ($this->document_sink !== null) {
3486 ($this->document_sink)($filename, $sitemap_xml);
3487
3488 return true;
3489 }
3490
3491 $sitemap_path = ABSPATH . $filename;
3492
3493 // Use WordPress filesystem API for better security
3494 global $wp_filesystem;
3495 if (!$wp_filesystem) {
3496 require_once ABSPATH . 'wp-admin/includes/file.php';
3497 WP_Filesystem();
3498 }
3499
3500 if ($wp_filesystem) {
3501 $written = $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3502
3503 if ($written) {
3504 // Every sitemap this version writes carries the ownership
3505 // marker, so once one has been written an unmarked file at one
3506 // of our names cannot be ours. Recording that retires the
3507 // legacy fallback for this install — see
3508 // thinkrank_webroot_sitemap_is_ours().
3509 if (get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1') {
3510 update_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION, '1', false);
3511 }
3512 }
3513
3514 return $written;
3515 }
3516
3517 // WP_Filesystem initialization failed
3518 return false;
3519 }
3520
3521 /**
3522 * Build a segmented, index-based sitemap_urls list from inclusion settings.
3523 *
3524 * Produces the index entry plus one child sitemap per enabled content type
3525 * (posts/pages/categories) and one per public custom post type — the shape
3526 * the "complete" preset creates and that generate_multiple_sitemaps() expects.
3527 * Used when enabling the index during import so the index has real children
3528 * instead of being empty.
3529 *
3530 * @since 1.14.0
3531 *
3532 * @param array $inclusions Inclusion flags (include_posts/pages/categories)
3533 * and optionally custom_url_pattern.
3534 * @return array Sitemap URL configs
3535 */
3536 public function build_segmented_sitemap_urls(array $inclusions): array {
3537 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
3538
3539 $urls = [$this->sitemap_child_entry('/sitemap_index.xml', 'index')];
3540
3541 foreach (self::INCLUSION_CHILD_TYPES as $flag => $type) {
3542 if (!empty($inclusions[$flag])) {
3543 $urls[] = $this->build_child_sitemap_entry($type, $pattern);
3544 }
3545 }
3546
3547 // Public custom post types each get a child sitemap (parity with the
3548 // "complete" preset and with Rank Math, which lists every public CPT).
3549 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
3550 if (!$this->should_include_post_type($cpt)) {
3551 continue;
3552 }
3553
3554 // ...and the per-content-type sitemap switch (#660).
3555 // get_enabled_post_types() already honours it, but this list is what
3556 // index mode builds its children from — so without the same test a
3557 // CPT the user had switched off still got its own child sitemap,
3558 // created and streamed in full. The flags live in $inclusions, which
3559 // is the settings array these children are derived from.
3560 if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $cpt, $inclusions)) {
3561 continue;
3562 }
3563
3564 $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
3565 }
3566
3567 // Public custom taxonomies get the same treatment (#690). Flat mode has
3568 // always walked them through get_enabled_taxonomies(); index mode built
3569 // its children from the list above and never consulted a taxonomy at
3570 // all, so every custom-taxonomy archive silently vanished from the
3571 // sitemap the moment a site switched modes — and the per-taxonomy switch
3572 // the matrix writes had nothing to act on. Same two tests the post-type
3573 // walk applies, in the same order.
3574 $taken = array_column($urls, 'type');
3575
3576 foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
3577 if (!$this->should_include_taxonomy($taxonomy)) {
3578 continue;
3579 }
3580
3581 if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $inclusions)) {
3582 continue;
3583 }
3584
3585 // Post types and taxonomies are separate registries, so a site can
3586 // hold both a `foo` post type and a `foo` taxonomy. They would
3587 // resolve to one filename, and stream_type_entries() answers post
3588 // types first, so the second child would list the first one's file
3589 // twice in the index rather than adding anything.
3590 if (in_array($taxonomy, $taken, true)) {
3591 continue;
3592 }
3593
3594 $urls[] = $this->build_child_sitemap_entry($taxonomy, $pattern);
3595 }
3596
3597 return $urls;
3598 }
3599
3600 /**
3601 * Apply only the inclusion flags the caller named to an existing child list.
3602 *
3603 * The narrow counterpart to build_segmented_sitemap_urls(): that one
3604 * regenerates the whole set from scratch, which is right when there is no set
3605 * yet and wrong when there is. Rebuilding an existing list would add a child
3606 * for every public custom post type it had never contained, so a payload that
3607 * switches one thing off would switch others on. Here a flag adds or removes
3608 * exactly its own child and leaves every other entry — custom post types,
3609 * hand-added URLs, per-child enabled/status state — untouched (#398).
3610 *
3611 * @since 1.31.0
3612 *
3613 * @param array $children Existing child sitemap entries.
3614 * @param string[] $named_inclusions Inclusion flag keys present in the payload.
3615 * @param array $inclusions Merged settings, for the resolved flag
3616 * values and custom_url_pattern.
3617 * @return array Updated child sitemap entries.
3618 */
3619 private function apply_inclusion_flags_to_children(
3620 array $children,
3621 array $named_inclusions,
3622 array $inclusions
3623 ): array {
3624 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
3625
3626 foreach ($named_inclusions as $flag) {
3627 $type = self::INCLUSION_CHILD_TYPES[$flag];
3628
3629 $present = false;
3630 foreach ($children as $entry) {
3631 if (($entry['type'] ?? '') === $type) {
3632 $present = true;
3633 break;
3634 }
3635 }
3636
3637 if (empty($inclusions[$flag])) {
3638 if ($present) {
3639 $children = array_values(array_filter(
3640 $children,
3641 static function ($entry) use ($type): bool {
3642 return (is_array($entry) ? ($entry['type'] ?? '') : '') !== $type;
3643 }
3644 ));
3645 }
3646 continue;
3647 }
3648
3649 if (!$present) {
3650 $children[] = $this->build_child_sitemap_entry($type, $pattern);
3651 }
3652 }
3653
3654 return $children;
3655 }
3656
3657 /**
3658 * Build one child sitemap entry, resolving its filename from the url pattern.
3659 *
3660 * @since 1.31.0
3661 *
3662 * @param string $type Child sitemap type (posts, pages, a post type name).
3663 * @param string $pattern Filename pattern containing {type}.
3664 * @return array Sitemap URL config.
3665 */
3666 private function build_child_sitemap_entry(string $type, string $pattern): array {
3667 $file = str_replace('{type}', $type, $pattern);
3668 if (strpos($file, '/') !== 0) {
3669 $file = '/' . $file;
3670 }
3671
3672 return $this->sitemap_child_entry($file, $type);
3673 }
3674
3675 /**
3676 * The shape generate_multiple_sitemaps() expects of a sitemap_urls entry.
3677 *
3678 * @since 1.31.0
3679 *
3680 * @param string $url Sitemap path.
3681 * @param string $type Entry type.
3682 * @return array Sitemap URL config.
3683 */
3684 private function sitemap_child_entry(string $url, string $type): array {
3685 return [
3686 'url' => $url,
3687 'type' => $type,
3688 'enabled' => true,
3689 'last_checked' => null,
3690 'status' => 'unknown',
3691 ];
3692 }
3693
3694 /**
3695 * Generate multiple sitemaps based on settings
3696 *
3697 * @since 1.0.0
3698 * @param array $settings Sitemap settings
3699 * @return array Results of sitemap generation
3700 */
3701 public function generate_multiple_sitemaps(array $settings): array {
3702 $results = [
3703 'success' => true,
3704 'sitemaps_generated' => [],
3705 'errors' => [],
3706 'total_urls' => 0
3707 ];
3708
3709 $sitemap_urls = (is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []);
3710
3711 if (empty($sitemap_urls)) {
3712 $results['success'] = false;
3713 $results['errors'][] = 'No sitemap URLs configured';
3714 return $results;
3715 }
3716
3717 $limit = $this->get_links_per_sitemap($settings);
3718
3719 // Defer the index until every child sitemap has been generated, so it can
3720 // list the actual files produced (including pagination pages).
3721 $index_config = null;
3722 $index_children = [];
3723
3724 foreach ($sitemap_urls as $sitemap_config) {
3725 if (empty($sitemap_config['enabled'])) {
3726 continue;
3727 }
3728
3729 $type = $sitemap_config['type'];
3730
3731 if ($type === 'index') {
3732 $index_config = $sitemap_config;
3733 continue;
3734 }
3735
3736 // Skip CPT children that no longer qualify (e.g. an internal store
3737 // like Templately's `templately_library`) even when a previously
3738 // saved config still lists them. Built-in aggregate types ('posts',
3739 // 'pages', 'general', etc.) are not post type names, so this only
3740 // affects real custom post types.
3741 if (post_type_exists($type) && !$this->should_include_post_type($type)) {
3742 continue;
3743 }
3744
3745 // Same for the matrix switch: a child list saved before the user
3746 // excluded this content type still names it, and regenerating from
3747 // that list would rewrite the file they asked not to have. Built-in
3748 // aggregates ('posts', 'pages', ...) are not post type names, so
3749 // post_type_exists() keeps this to real custom post types.
3750 if (post_type_exists($type)
3751 && !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $type, $settings)) {
3752 continue;
3753 }
3754
3755 // The taxonomy counterpart of the two guards above (#690). A child
3756 // list saved while a taxonomy was still included keeps naming it, so
3757 // without this, excluding one in the matrix would still rewrite and
3758 // re-list the file the user asked not to have. The built-in
3759 // aggregates are named 'categories'/'tags' rather than
3760 // 'category'/'post_tag', so taxonomy_exists() leaves them to the
3761 // inclusion-flag check below.
3762 $child_taxonomy = self::CHILD_TYPE_ALIASES[$type] ?? $type;
3763 if (taxonomy_exists($child_taxonomy)
3764 && (!$this->should_include_taxonomy($child_taxonomy)
3765 || !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $child_taxonomy, $settings))) {
3766 continue;
3767 }
3768
3769 // The built-in aggregates carry their switch in the inclusion flag
3770 // rather than under a post type name, and they are the four most
3771 // people actually use. build_segmented_sitemap_urls() drops a child
3772 // whose flag is empty; regenerating from a list saved while it was
3773 // still on has to make the same decision, or turning Posts, Pages,
3774 // Categories or Tags off in the matrix rewrites and re-lists the
3775 // very file it was asked to remove.
3776 // An absent flag means "not configured", which every other reader
3777 // treats as included; only a flag that is present and off excludes.
3778 $aggregate_flag = array_search($type, self::INCLUSION_CHILD_TYPES, true);
3779 if ($aggregate_flag !== false
3780 && array_key_exists($aggregate_flag, $settings)
3781 && empty($settings[$aggregate_flag])) {
3782 continue;
3783 }
3784
3785 try {
3786 // The single "general"/"WordPress" sitemap is one un-paginated file
3787 // (there is no index to reference extra pages); it no longer drops
3788 // overflow URLs.
3789 if ($type === 'general' || $type === 'wordpress') { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- lowercase on purpose: this is the stored type slug.
3790 $xml = $this->generate_sitemap($settings);
3791 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
3792 continue;
3793 }
3794
3795 $source = $this->stream_type_entries($type, $settings);
3796
3797 // Unknown type — fall back to a single general sitemap file.
3798 if ($source === null) {
3799 $xml = $this->generate_sitemap($settings);
3800 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
3801 continue;
3802 }
3803
3804 // Stream the entries into files of at most $limit URLs, matching
3805 // Rank Math: page 1 keeps the base filename, pages 2+ get a -N
3806 // suffix, and every page is listed in the index. Buffering only one
3807 // page at a time keeps peak memory bounded to $limit entries rather
3808 // than every URL of the type.
3809 $image_ns = $source['image_ns'];
3810 $buffer = [];
3811 $page = 0;
3812
3813 foreach ($source['entries'] as $entry) {
3814 $buffer[] = $entry;
3815 if (count($buffer) >= $limit) {
3816 $page++;
3817 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
3818 $buffer = [];
3819 }
3820 }
3821
3822 // Flush the trailing partial page, or a single empty page when the
3823 // type had no entries at all (parity with the previous behavior of
3824 // always writing at least one page per configured child).
3825 if (!empty($buffer) || $page === 0) {
3826 $page++;
3827 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
3828 }
3829
3830 // Remove pages left over from a previous, larger generation.
3831 $this->cleanup_stale_pages($sitemap_config['url'], $page, $settings);
3832
3833 } catch (\Exception $e) {
3834 $results['errors'][] = "Error generating {$type} sitemap: " . $e->getMessage();
3835 $results['success'] = false;
3836 }
3837 }
3838
3839 // Local business sitemap (parity with Rank Math's local-sitemap.xml).
3840 // The physical file is written whenever a business identity is
3841 // configured — independent of segmented vs single mode — and is also
3842 // listed in the sitemap index when one exists.
3843 try {
3844 if ($this->regenerate_local_sitemap($settings) && $index_config !== null) {
3845 $index_children[] = ['url' => '/local-sitemap.xml'];
3846 }
3847 } catch (\Exception $e) {
3848 $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
3849 $results['success'] = false;
3850 }
3851
3852 // Build the index last, from the child files actually generated, plus the
3853 // sitemaps other plugins own: those serve their own URLs and write no
3854 // file here, so they are appended to the index only (#104).
3855 if ($index_config !== null) {
3856 foreach (self::additional_sitemaps() as $extra) {
3857 $index_children[] = ['url' => $extra];
3858 }
3859
3860 try {
3861 $index_xml = $this->generate_sitemap_index($index_children, $settings);
3862 $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
3863
3864 if ($this->save_sitemap_to_file($index_xml, $filename)) {
3865 $results['sitemaps_generated'][] = [
3866 'url' => $index_config['url'],
3867 'type' => 'index',
3868 'filename' => $filename,
3869 'url_count' => count($index_children),
3870 ];
3871 } else {
3872 $results['errors'][] = "Failed to save sitemap: {$filename}";
3873 $results['success'] = false;
3874 }
3875 } catch (\Exception $e) {
3876 $results['errors'][] = 'Error generating index sitemap: ' . $e->getMessage();
3877 $results['success'] = false;
3878 }
3879 }
3880
3881 // Remove segments that are no longer part of the set. Publishing was
3882 // purely additive: a type that dropped out (Categories unticked, a CPT
3883 // that stopped qualifying) simply stopped being overwritten, so its file
3884 // kept serving and — until the index happened to be rebuilt — kept being
3885 // listed in it. The index above is built from the children actually
3886 // generated, so pruning here leaves disk and index agreeing.
3887 $this->prune_orphaned_segments($settings, $results['sitemaps_generated']);
3888
3889 return $results;
3890 }
3891
3892 /**
3893 * Delete published segment files that this run did not write.
3894 *
3895 * Only filenames this site could have published under its own url pattern
3896 * are considered, so another plugin's or core's sitemap in the web root is
3897 * never a candidate — the same reason cleanup does not glob 'sitemap-*.xml'.
3898 *
3899 * @since 1.31.0
3900 *
3901 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
3902 * @param array $generated Entries from $results['sitemaps_generated'].
3903 * @return string[] Basenames removed.
3904 */
3905 private function prune_orphaned_segments(array $settings, array $generated): array {
3906 // Rendering for a request, not publishing: there is nothing on disk
3907 // this run owns, and a dynamic render must never delete the files a
3908 // site's previous static mode left behind.
3909 if ($this->is_collecting()) {
3910 return [];
3911 }
3912
3913 $kept = [];
3914 foreach ($generated as $entry) {
3915 if (!empty($entry['filename'])) {
3916 $kept[strtolower((string) $entry['filename'])] = true;
3917 }
3918 }
3919
3920 // The current mode's primary and the local business sitemap are written
3921 // by their own paths and are never orphans here.
3922 $primary = strtolower(basename($this->get_primary_sitemap_filename($settings)));
3923 $kept[$primary] = true;
3924 $kept['local-sitemap.xml'] = true;
3925
3926 // The OTHER mode's primary is an orphan the moment the mode changes:
3927 // index mode leaves sitemap.xml behind, flat mode leaves
3928 // sitemap_index.xml and its children. Both used to be kept
3929 // unconditionally, so the site served two sitemap trees and only ever
3930 // refreshed one (#563). The children are already covered by the segment
3931 // sweep below, which now sees them because the index is no longer kept.
3932 $stale_primaries = array_diff(['sitemap.xml', 'sitemap_index.xml'], [$primary]);
3933
3934 $removed = [];
3935
3936 global $wp_filesystem;
3937 if (!$wp_filesystem) {
3938 require_once ABSPATH . 'wp-admin/includes/file.php';
3939 WP_Filesystem();
3940 }
3941 if (!$wp_filesystem) {
3942 return $removed;
3943 }
3944
3945 $candidates = array_merge($this->publishable_segment_filenames($settings), $stale_primaries);
3946
3947 foreach ($candidates as $candidate) {
3948 if (isset($kept[strtolower($candidate)])) {
3949 continue;
3950 }
3951
3952 if (!preg_match('/^(.*)\.xml$/i', $candidate, $m)) {
3953 continue;
3954 }
3955
3956 // The base file plus its numeric pagination pages.
3957 $paths = [ABSPATH . $candidate];
3958 foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
3959 if (preg_match('/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i', basename($paged))) {
3960 $paths[] = $paged;
3961 }
3962 }
3963
3964 foreach ($paths as $path) {
3965 if (!file_exists($path)) {
3966 continue;
3967 }
3968 // A name we could have published is not proof we published
3969 // this file: RankMath and core write at the same paths (#515).
3970 if (!$this->webroot_sitemap_is_ours($path, $settings)) {
3971 continue;
3972 }
3973 if ($wp_filesystem->delete($path)) {
3974 $removed[] = basename($path);
3975 }
3976 }
3977 }
3978
3979 return $removed;
3980 }
3981
3982
3983 /**
3984 * Collect the full (un-paginated) entry list for a content sitemap type.
3985 *
3986 * @since 1.14.0
3987 *
3988 * @param string $type Sitemap config type (posts, pages, categories, …)
3989 * @param array $settings Sitemap settings
3990 * @return array{entries: array<string>, image_ns: bool}|null Null for an
3991 * unknown type (caller falls back to a general sitemap).
3992 */
3993 /**
3994 * Write (or remove) the physical local-sitemap.xml file.
3995 *
3996 * Called from every sitemap regeneration path — segmented generation, the
3997 * single-sitemap auto-regeneration, and the manual generate endpoint — so
3998 * the local sitemap works regardless of whether the site uses a sitemap
3999 * index. When no business identity is configured, any stale file is removed.
4000 *
4001 * @since 1.15.x
4002 * @param array|null $settings Sitemap settings (falls back to saved site settings).
4003 * @return bool True when the file was written, false when nothing was written.
4004 */
4005 public function regenerate_local_sitemap(?array $settings = null): bool {
4006 $settings = $settings ?? $this->get_settings('site');
4007 $entries = $this->collect_local_entries();
4008
4009 if (empty($entries)) {
4010 // Business identity was cleared — drop the file we left from
4011 // before, but only ours. `local-sitemap.xml` is the name Rank Math
4012 // publishes under too (this method mirrors it deliberately), so on
4013 // a migrated site the file at that path may never have been ours
4014 // to delete (#515).
4015 $path = ABSPATH . 'local-sitemap.xml';
4016 if (!$this->is_collecting() && file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
4017 wp_delete_file($path);
4018 }
4019 return false;
4020 }
4021
4022 $xml = $this->wrap_urlset($entries, $settings, false);
4023 return $this->save_sitemap_to_file($xml, 'local-sitemap.xml');
4024 }
4025
4026 /**
4027 * Build the local business sitemap entries.
4028 *
4029 * Returns a single URL entry pointing at the on-site page that carries the
4030 * business's LocalBusiness/Organization schema (the configured business URL
4031 * when it's on this site, otherwise the homepage). Gated — like Rank Math's
4032 * `local-sitemap.xml` — on a business (non-person) type being configured
4033 * with an actual location (address or geo coordinates). Returns an empty
4034 * array when no business location is set, so no empty local sitemap is
4035 * written or added to the index.
4036 *
4037 * @since 1.15.x
4038 * @return array Zero or one URL entry
4039 */
4040 /**
4041 * Does this site publish a local business sitemap right now?
4042 *
4043 * The same gate {@see self::regenerate_local_sitemap()} applies, asked
4044 * without writing anything. Callers that need to know whether the document
4045 * exists must not test the filesystem: under dynamic delivery it is served
4046 * from PHP and there is no file, which is how `local-sitemap.xml` came to be
4047 * dropped from robots.txt on exactly those sites (#752).
4048 *
4049 * @since 2.9.0
4050 *
4051 * @return bool True when the local sitemap has content to publish.
4052 */
4053 public function publishes_local_sitemap(): bool {
4054 return !empty($this->collect_local_entries());
4055 }
4056
4057 private function collect_local_entries(): array {
4058 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
4059 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
4060 }
4061
4062 $identity = (new \ThinkRank\SEO\Site_Identity_Manager())->get_settings('site');
4063
4064 // Gate like Rank Math's local sitemap: a business (non-person) identity
4065 // is configured. We only emit a single URL (the location page), so a
4066 // full postal address is NOT required — requiring one wrongly excluded
4067 // migrated sites, since the Rank Math importer carries over business
4068 // type/name/phone but not the address. Personal sites, and sites with no
4069 // business identity at all, get no local sitemap.
4070 $business_type = strtolower((string) ($identity['business_type'] ?? ''));
4071 if ($business_type === 'person') {
4072 return [];
4073 }
4074 if ($business_type === '' && empty($identity['business_name'])) {
4075 return [];
4076 }
4077
4078 // Prefer the configured business URL when it points at this site;
4079 // otherwise fall back to the homepage (which outputs the schema).
4080 $location_url = home_url('/');
4081 if (!empty($identity['business_url'])) {
4082 $candidate = esc_url_raw((string) $identity['business_url']);
4083 if ($candidate && wp_parse_url($candidate, PHP_URL_HOST) === wp_parse_url(home_url(), PHP_URL_HOST)) {
4084 $location_url = $candidate;
4085 }
4086 }
4087
4088 // Omit lastmod — no real modification source for the local business URL.
4089 return [$this->generate_url_entry($location_url, '', 0.8, 'weekly')];
4090 }
4091
4092 /**
4093 * Resolve a streaming entry source for a sitemap type.
4094 *
4095 * Returns an iterable that yields the type's <url> entries one at a time
4096 * (so the paginator never materializes the whole type), the image-namespace
4097 * flag, or null when the type isn't one we build (caller falls back to a
4098 * single general sitemap). Entry order matches the previous array-based
4099 * collect_type_entries() exactly.
4100 *
4101 * @param string $type Sitemap type.
4102 * @param array $settings Sitemap settings.
4103 * @return array{entries: iterable<string>, image_ns: bool}|null
4104 */
4105 private function stream_type_entries(string $type, array $settings): ?array {
4106 switch ($type) {
4107 case 'posts':
4108 return ['entries' => $this->collect_post_entries_iter(['post'], $settings), 'image_ns' => true];
4109
4110 case 'pages':
4111 // The homepage lives in the pages sitemap (parity with prior
4112 // output) and is emitted first; collect_post_entries_iter()
4113 // already excludes the static front page, so there's no duplicate.
4114 $entries = (function () use ($settings) {
4115 yield $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
4116 yield from $this->collect_post_entries_iter(['page'], $settings);
4117 })();
4118 return ['entries' => $entries, 'image_ns' => true];
4119
4120 case 'categories':
4121 return ['entries' => $this->collect_taxonomy_entries_iter('category', $settings), 'image_ns' => false];
4122
4123 case 'tags':
4124 return ['entries' => $this->collect_taxonomy_entries_iter('post_tag', $settings), 'image_ns' => false];
4125
4126 case 'products':
4127 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
4128 return ['entries' => $entries, 'image_ns' => true];
4129
4130 default:
4131 // Resolve a preset's display name to the object it streams, so
4132 // 'product_categories' is an ordinary taxonomy child rather than
4133 // a case of its own (#690).
4134 $alias = self::CHILD_TYPE_ALIASES[$type] ?? null;
4135 $object = $alias ?? $type;
4136
4137 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
4138 if (in_array($object, $custom_post_types, true)) {
4139 return ['entries' => $this->collect_post_entries_iter([$object], $settings), 'image_ns' => true];
4140 }
4141
4142 // Custom taxonomies reach index mode here, streamed through the
4143 // same iterator flat mode uses so the two modes emit identical
4144 // URLs for the same settings.
4145 if (taxonomy_exists($object) && $this->should_include_taxonomy($object)) {
4146 return ['entries' => $this->collect_taxonomy_entries_iter($object, $settings), 'image_ns' => false];
4147 }
4148
4149 // An aliased child whose object is gone (WooCommerce deactivated)
4150 // keeps writing the empty file it always wrote. Returning null
4151 // here would hand it the whole-site fallback instead, dumping
4152 // every URL on the site into a file named for products.
4153 //
4154 // A registered taxonomy this generator will not emit
4155 // (`post_format`, `nav_menu`, a non-public one) needs the same
4156 // answer for the same reason. generate_multiple_sitemaps()
4157 // skips those before they reach here, so nothing takes this
4158 // path today — but it is the one branch where falling through
4159 // to null is silently catastrophic rather than merely wrong,
4160 // and the guard keeping it unreachable lives in another method.
4161 if ($alias !== null || taxonomy_exists($object)) {
4162 return ['entries' => [], 'image_ns' => false];
4163 }
4164
4165 return null;
4166 }
4167 }
4168
4169 /**
4170 * Write one sitemap page file and record it in the results + index child list.
4171 *
4172 * @since 1.14.0
4173 *
4174 * @param string $url Sitemap page URL
4175 * @param string $xml Sitemap XML
4176 * @param string $type Sitemap type (for reporting)
4177 * @param int $url_count Number of URLs in this page
4178 * @param array $results Results accumulator (by reference)
4179 * @param array $index_children Index child list (by reference)
4180 * @return void
4181 */
4182 private function write_sitemap_page(string $url, string $xml, string $type, int $url_count, array &$results, array &$index_children): void {
4183 $filename = basename(wp_parse_url($url, PHP_URL_PATH));
4184
4185 if ($this->save_sitemap_to_file($xml, $filename)) {
4186 $results['sitemaps_generated'][] = [
4187 'url' => $url,
4188 'type' => $type,
4189 'filename' => $filename,
4190 'url_count' => $url_count,
4191 ];
4192 $results['total_urls'] += $url_count;
4193 $index_children[] = ['url' => $url];
4194 } else {
4195 $results['errors'][] = "Failed to save sitemap: {$filename}";
4196 $results['success'] = false;
4197 }
4198 }
4199
4200 /**
4201 * Delete pagination files left over when a type shrinks to fewer pages.
4202 *
4203 * For a base URL like /sitemap-posts.xml, removes sitemap-posts-N.xml files
4204 * whose page number N exceeds the current page count. Page 1 (the base file,
4205 * which has no -N suffix) is never touched.
4206 *
4207 * @since 1.14.0
4208 *
4209 * @since 2.1.1 Each candidate must pass the content ownership test — a
4210 * `-N.xml` page of another plugin's sitemap paginates our
4211 * stem exactly as ours does (#515).
4212 *
4213 * @param string $base_url Base (page 1) sitemap URL
4214 * @param int $current_pages Number of pages generated this run
4215 * @param array $settings Sitemap settings, for the ownership test.
4216 * @return void
4217 */
4218 private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
4219 // See prune_orphaned_segments(): a dynamic render deletes nothing.
4220 if ($this->is_collecting()) {
4221 return;
4222 }
4223
4224 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
4225 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
4226 return;
4227 }
4228 $stem = $m[1];
4229
4230 global $wp_filesystem;
4231 if (!$wp_filesystem) {
4232 require_once ABSPATH . 'wp-admin/includes/file.php';
4233 WP_Filesystem();
4234 }
4235 if (!$wp_filesystem) {
4236 return;
4237 }
4238
4239 $candidates = glob(ABSPATH . $stem . '-*.xml') ?: [];
4240 foreach ($candidates as $path) {
4241 // Only delete numeric-suffixed pages beyond the current count.
4242 if (preg_match('/-(\d+)\.xml$/', basename($path), $mm)
4243 && (int) $mm[1] > $current_pages
4244 && $this->webroot_sitemap_is_ours($path, $settings)) {
4245 $wp_filesystem->delete($path);
4246 }
4247 }
4248 }
4249
4250 /**
4251 * Sitemap URLs contributed by other plugins.
4252 *
4253 * ThinkRank owns the sitemap index and the robots.txt `Sitemap:` lines, so a
4254 * companion plugin that serves its own sitemap — Pro's news and video
4255 * sitemaps, for instance — had no way to be discovered: it appeared in
4256 * neither, leaving manual Search Console submission as the only route in
4257 * (#104). Registering here puts a sitemap in the index when one exists, and
4258 * in robots.txt when it does not.
4259 *
4260 * Callers get root-relative paths. Entries are normalised to a leading
4261 * slash, de-duplicated, and anything that is not a non-empty string is
4262 * dropped, so one badly-behaved callback cannot produce a malformed index.
4263 *
4264 * @since 2.3.1
4265 *
4266 * @return string[] Root-relative sitemap paths, e.g. ['/news-sitemap.xml'].
4267 */
4268 public static function additional_sitemaps(): array {
4269 /**
4270 * Filters the sitemaps contributed by other plugins.
4271 *
4272 * @since 2.3.1
4273 *
4274 * @param string[] $sitemaps Root-relative sitemap paths.
4275 */
4276 $sitemaps = apply_filters('thinkrank_additional_sitemaps', []);
4277
4278 if (!is_array($sitemaps)) {
4279 return [];
4280 }
4281
4282 // Both consumers resolve an entry with home_url(), which prefixes the
4283 // install's own directory. Everything below is measured against that so
4284 // an absolute URL is reduced to what home_url() will put back.
4285 $home = wp_parse_url(home_url('/'));
4286 $home_host = strtolower((string) ($home['host'] ?? ''));
4287 $home_path = '/' . trim((string) ($home['path'] ?? ''), '/');
4288
4289 $clean = [];
4290 foreach ($sitemaps as $sitemap) {
4291 if (!is_string($sitemap)) {
4292 continue;
4293 }
4294
4295 $sitemap = trim($sitemap);
4296 if ('' === $sitemap) {
4297 continue;
4298 }
4299
4300 // A full URL on this site is accepted and reduced to the part
4301 // home_url() does not already supply, so a caller that reached for
4302 // home_url() still lands in the right place — including on a
4303 // subdirectory install, where keeping the whole path would repeat
4304 // the directory. A URL on another host is dropped rather than
4305 // rewritten: the sitemaps protocol will not accept a cross-host
4306 // child anyway, and reusing its path would advertise a URL on this
4307 // site that does not exist.
4308 if (preg_match('#^(https?:)?//#i', $sitemap)) {
4309 $parts = wp_parse_url('//' === substr($sitemap, 0, 2) ? 'https:' . $sitemap : $sitemap);
4310 if (!is_array($parts)) {
4311 continue;
4312 }
4313
4314 if (strtolower((string) ($parts['host'] ?? '')) !== $home_host) {
4315 continue;
4316 }
4317
4318 $path = (string) ($parts['path'] ?? '');
4319 if ('' === $path) {
4320 continue;
4321 }
4322
4323 if ('/' !== $home_path && ($path === $home_path || 0 === strpos($path, $home_path . '/'))) {
4324 $path = substr($path, strlen($home_path));
4325 }
4326
4327 // A sitemap served from a query string keeps it; dropping the
4328 // query would point at a different document.
4329 $query = (string) ($parts['query'] ?? '');
4330 $sitemap = $path . ('' !== $query ? '?' . $query : '');
4331 }
4332
4333 $clean[] = '/' . ltrim($sitemap, '/');
4334 }
4335
4336 return array_values(array_unique($clean));
4337 }
4338
4339 /**
4340 * Generate sitemap index XML from the list of child sitemap files produced
4341 * during generation (each already resolved to its final, possibly paginated,
4342 * URL).
4343 *
4344 * @since 1.0.0 (signature updated 1.14.0)
4345 * @param array $children Array of ['url' => string] child sitemap entries
4346 * @param array $settings Sitemap settings
4347 * @return string Sitemap index XML
4348 */
4349 private function generate_sitemap_index(array $children, array $settings): string {
4350 $xml = $this->xml_prolog($settings, 'index');
4351 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
4352
4353 $site_url = home_url();
4354
4355 foreach ($children as $child) {
4356 $sitemap_url = $child['url'] ?? '';
4357 if ($sitemap_url === '') {
4358 continue;
4359 }
4360 if (!str_starts_with($sitemap_url, 'http')) {
4361 $sitemap_url = $site_url . $sitemap_url;
4362 }
4363
4364 $xml .= " <sitemap>\n";
4365 $xml .= " <loc>" . esc_url(Url_Scheme::apply($sitemap_url)) . "</loc>\n";
4366 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
4367 $xml .= " </sitemap>\n";
4368 }
4369
4370 $xml .= '</sitemapindex>';
4371
4372 return $xml;
4373 }
4374
4375 /**
4376 * Count URLs in sitemap XML content
4377 *
4378 * @since 1.0.0
4379 * @param string $sitemap_xml Sitemap XML content
4380 * @return int Number of URLs
4381 */
4382 private function count_urls_in_xml(string $sitemap_xml): int {
4383 return substr_count($sitemap_xml, '<url>');
4384 }
4385
4386 /**
4387 * Validate and sanitize exclude posts input
4388 *
4389 * @since 1.0.0
4390 * @param string $exclude_posts Comma-separated post IDs
4391 * @return array Validated post IDs
4392 * @throws InvalidArgumentException If validation fails
4393 */
4394 private function validate_exclude_posts(string $exclude_posts): array {
4395 if (empty($exclude_posts)) {
4396 return [];
4397 }
4398
4399 // Limit input length to prevent DoS
4400 if (strlen($exclude_posts) > 1000) {
4401 throw new InvalidArgumentException('Exclude posts list too long (max 1000 characters)');
4402 }
4403
4404 // Validate comma-separated integers only
4405 if (!preg_match('/^[\d,\s]+$/', $exclude_posts)) {
4406 throw new InvalidArgumentException('Exclude posts must contain only numbers and commas');
4407 }
4408
4409 $ids = array_map('intval', array_filter(explode(',', $exclude_posts)));
4410
4411 // Limit number of exclusions to prevent performance issues
4412 if (count($ids) > 100) {
4413 throw new InvalidArgumentException('Too many posts to exclude (max 100)');
4414 }
4415
4416 return array_filter($ids, function($id) {
4417 return $id > 0; // Only positive integers
4418 });
4419 }
4420
4421 /**
4422 * Validate and sanitize exclude terms input
4423 *
4424 * @since 1.0.0
4425 * @param string $exclude_terms Comma-separated term IDs
4426 * @return array Validated term IDs
4427 * @throws InvalidArgumentException If validation fails
4428 */
4429 private function validate_exclude_terms(string $exclude_terms): array {
4430 if (empty($exclude_terms)) {
4431 return [];
4432 }
4433
4434 // Limit input length to prevent DoS
4435 if (strlen($exclude_terms) > 1000) {
4436 throw new InvalidArgumentException('Exclude terms list too long (max 1000 characters)');
4437 }
4438
4439 // Validate comma-separated integers only
4440 if (!preg_match('/^[\d,\s]+$/', $exclude_terms)) {
4441 throw new InvalidArgumentException('Exclude terms must contain only numbers and commas');
4442 }
4443
4444 $ids = array_map('intval', array_filter(explode(',', $exclude_terms)));
4445
4446 // Limit number of exclusions to prevent performance issues
4447 if (count($ids) > 100) {
4448 throw new InvalidArgumentException('Too many terms to exclude (max 100)');
4449 }
4450
4451 return array_filter($ids, function($id) {
4452 return $id > 0; // Only positive integers
4453 });
4454 }
4455
4456 /**
4457 * Validate and sanitize custom URL pattern
4458 *
4459 * @since 1.0.0
4460 * @param string $pattern Custom URL pattern
4461 * @return string Validated and sanitized pattern
4462 * @throws InvalidArgumentException If validation fails
4463 */
4464 private function validate_custom_url_pattern(string $pattern): string {
4465 if (empty($pattern)) {
4466 return 'sitemap-{type}.xml';
4467 }
4468
4469 // Remove any HTML/script tags to prevent XSS
4470 $pattern = wp_strip_all_tags($pattern);
4471
4472 // Limit pattern length
4473 if (strlen($pattern) > 100) {
4474 throw new InvalidArgumentException('URL pattern too long (max 100 characters)');
4475 }
4476
4477 // Validate pattern format - only allow safe characters
4478 if (!preg_match('/^[a-zA-Z0-9\-_{}\.]+$/', $pattern)) {
4479 throw new InvalidArgumentException('URL pattern contains invalid characters. Only letters, numbers, hyphens, underscores, dots, and {type} are allowed');
4480 }
4481
4482 // Ensure it contains {type} placeholder
4483 if (strpos($pattern, '{type}') === false) {
4484 throw new InvalidArgumentException('URL pattern must contain {type} placeholder');
4485 }
4486
4487 // Ensure it ends with .xml
4488 if (!str_ends_with($pattern, '.xml')) {
4489 $pattern .= '.xml';
4490 }
4491
4492 return sanitize_file_name($pattern);
4493 }
4494
4495 /**
4496 * Validate sitemap URL
4497 *
4498 * @since 1.0.0
4499 * @param string $url Sitemap URL
4500 * @return string Validated and sanitized URL
4501 * @throws InvalidArgumentException If validation fails
4502 */
4503 private function validate_sitemap_url(string $url): string {
4504 if (empty($url)) {
4505 throw new InvalidArgumentException('Sitemap URL cannot be empty');
4506 }
4507
4508 // Remove leading/trailing whitespace
4509 $url = trim($url);
4510
4511 // Limit URL length
4512 if (strlen($url) > 200) {
4513 throw new InvalidArgumentException('Sitemap URL too long (max 200 characters)');
4514 }
4515
4516 // Ensure it starts with /
4517 if (!str_starts_with($url, '/')) {
4518 $url = '/' . $url;
4519 }
4520
4521 // Validate URL path format
4522 if (!preg_match('/^\/[a-zA-Z0-9\-_\/\.]+\.xml$/', $url)) {
4523 throw new InvalidArgumentException('Invalid sitemap URL format. Must be a valid path ending with .xml');
4524 }
4525
4526 // Prevent directory traversal
4527 if (strpos($url, '..') !== false) {
4528 throw new InvalidArgumentException('Directory traversal not allowed in sitemap URL');
4529 }
4530
4531 // Prevent multiple slashes
4532 $url = preg_replace('/\/+/', '/', $url);
4533
4534 return sanitize_url($url);
4535 }
4536
4537 /**
4538 * Validate links per sitemap setting
4539 *
4540 * @since 1.0.0
4541 * @param mixed $links_per_sitemap Links per sitemap value
4542 * @return int Validated links per sitemap
4543 * @throws InvalidArgumentException If validation fails
4544 */
4545 private function validate_links_per_sitemap($links_per_sitemap): int {
4546 $links = intval($links_per_sitemap);
4547
4548 if ($links < 1) {
4549 throw new InvalidArgumentException('Links per sitemap must be at least 1');
4550 }
4551
4552 if ($links > 50000) {
4553 throw new InvalidArgumentException('Links per sitemap cannot exceed 50,000');
4554 }
4555
4556 return $links;
4557 }
4558
4559 /**
4560 * Validate sitemap filename for security
4561 *
4562 * @since 1.0.0
4563 * @param string $filename Filename to validate
4564 * @return string Validated and sanitized filename
4565 * @throws InvalidArgumentException If validation fails
4566 */
4567 private function validate_sitemap_filename(string $filename): string {
4568 if (empty($filename)) {
4569 return 'sitemap.xml';
4570 }
4571
4572 // Remove any path components to prevent directory traversal
4573 $filename = basename($filename);
4574
4575 // Limit filename length
4576 if (strlen($filename) > 100) {
4577 throw new InvalidArgumentException('Filename too long (max 100 characters)');
4578 }
4579
4580 // Validate filename format - only allow safe characters
4581 if (!preg_match('/^[a-zA-Z0-9\-_\.]+$/', $filename)) {
4582 throw new InvalidArgumentException('Filename contains invalid characters. Only letters, numbers, hyphens, underscores, and dots are allowed');
4583 }
4584
4585 // Prevent directory traversal attempts
4586 if (strpos($filename, '..') !== false) {
4587 throw new InvalidArgumentException('Directory traversal not allowed in filename');
4588 }
4589
4590 // Ensure it ends with .xml
4591 if (!str_ends_with($filename, '.xml')) {
4592 $filename .= '.xml';
4593 }
4594
4595 // Additional sanitization
4596 $filename = sanitize_file_name($filename);
4597
4598 // Final security check - ensure it's still a valid XML filename
4599 if (!preg_match('/^[a-zA-Z0-9\-_]+\.xml$/', $filename)) {
4600 throw new InvalidArgumentException('Invalid XML filename after sanitization');
4601 }
4602
4603 return $filename;
4604 }
4605 }
4606