PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.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 All 57 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.14.2, at includes/seo/class-sitemap-generator.php

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