PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
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 1.27.0 All 56 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.1, at includes/seo/class-sitemap-generator.php

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