PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
thinkrank / includes / seo / class-sitemap-generator.php

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

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