PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 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.10.0, at includes/seo/class-sitemap-generator.php

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