PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.0
2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 All 54 releases
← All changes | includes/seo/class-sitemap-generator.php +2511 -195 1.26.0 → 2.13.0 View file →
@@ -21,8 +21,13 @@
21 21 if ( ! defined( 'ABSPATH' ) ) {
22 22 exit;
23 23 }
24 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 +
25 30 /**
26 31 * Sitemap Generator Class
27 32 *
28 33 * Generates and manages XML sitemaps for search engine optimization.
@@ -33,13 +38,246 @@
33 38 */
34 39 class Sitemap_Generator extends Abstract_SEO_Manager {
35 40
36 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 + /**
37 83 * Supported sitemap types
38 84 *
39 85 * @since 1.0.0
40 86 * @var array
41 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 +
42 280 private array $sitemap_types = [
43 281 'posts' => [
44 282 'name' => 'Posts',
45 283 'post_types' => ['post'],
@@ -66,17 +304,29 @@
66 304 ]
67 305 ];
68 306
69 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 + /**
70 317 * Constructor
71 318 *
72 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.
73 326 */
74 - public function __construct() {
327 + public function __construct(bool $register_hooks = true) {
75 328 parent::__construct('sitemap');
76 -
77 - // Initialize auto-generation hooks
78 - $this->init_auto_generation_hooks();
79 329 }
80 330
81 331 /**
82 332 * Filter the args of a sitemap post query.
@@ -120,31 +370,57 @@
120 370 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
121 371 }
122 372
123 373 /**
124 - * Initialize WordPress hooks for auto-generation
374 + * Register the content-change listeners that queue an automatic rebuild.
125 375 *
126 - * @since 1.0.0
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
127 391 * @return void
128 392 */
129 - private function init_auto_generation_hooks(): void {
130 - // Content change hooks - use priority 20 to run after other plugins
131 - add_action('save_post', [$this, 'handle_content_change'], 20, 2);
132 - add_action('delete_post', [$this, 'handle_content_deletion'], 20);
133 - add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
134 - add_action('untrash_post', [$this, 'handle_content_change_by_id'], 20);
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);
135 407
136 - // Taxonomy change hooks
137 - add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
138 - add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
139 - add_action('delete_term', [$this, 'handle_taxonomy_change'], 20, 3);
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 + }
140 414
141 - // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
142 - // and thinkrank_regenerate_sitemap_settings) are registered at plugin
143 - // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
144 - // rather than here. A cron run never builds this class via the REST
145 - // endpoint (no rest_api_init), so registering them in the constructor
146 - // would leave the scheduled events with no listener at cron time.
415 + /**
416 + * The generator the content-change listeners share.
417 + *
418 + * @since 2.10.1
419 + * @return self
420 + */
421 + private static function listener(): self {
422 + return self::$listener ??= new self(false);
147 423 }
148 424
149 425 /**
150 426 * Generate XML sitemap
@@ -156,15 +432,10 @@
156 432 */
157 433 public function generate_sitemap(array $options = []): string {
158 434 $settings = $this->get_settings('site');
159 435
160 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
436 + $xml = $this->xml_prolog($settings, 'sitemap');
161 437
162 - // Add XSL stylesheet only if styling is enabled
163 - if (!empty($settings['enable_styling'])) {
164 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
165 - }
166 -
167 438 // Add image namespace if images are enabled
168 439 if (!empty($settings['include_images'])) {
169 440 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
170 441 } else {
@@ -276,8 +547,34 @@
276 547 ];
277 548 }
278 549
279 550 /**
551 + * Sitemap keys outside the defaults.
552 + *
553 + * @since 2.0.1
554 + *
555 + * @return string[]
556 + */
557 + protected function additional_setting_keys(): array {
558 + return ['selected_preset'];
559 + }
560 +
561 + /**
562 + * Inclusion flags are per post type and per taxonomy.
563 + *
564 + * A site registering a `product` post type stores `include_product`; an
565 + * enumerated list would go stale on the next registration, so the family
566 + * is matched instead.
567 + *
568 + * @since 2.0.1
569 + *
570 + * @return string[]
571 + */
572 + protected function dynamic_setting_key_patterns(): array {
573 + return ['/^include_[a-z0-9_]+$/', '/^exclude_[a-z0-9_]+$/'];
574 + }
575 +
576 + /**
280 577 * Get default settings for a context type (implements interface)
281 578 *
282 579 * @since 1.0.0
283 580 *
@@ -300,8 +597,15 @@
300 597 ]
301 598 ],
302 599 'use_sitemap_index' => false,
303 600
601 + // How the sitemap reaches crawlers. 'auto' keeps the historical
602 + // behaviour wherever the web root is writable, and only falls back
603 + // to serving the sitemap from PHP where writing a file is
604 + // impossible — previously a hard failure with nothing served
605 + // (#752).
606 + 'delivery_mode' => 'auto',
607 +
304 608 // General Settings
305 609 'links_per_sitemap' => 1000,
306 610 'include_images' => true,
307 611 'include_featured_images' => false,
@@ -323,8 +627,17 @@
323 627 // Advanced Options
324 628 'enable_styling' => true,
325 629 'custom_url_pattern' => 'sitemap-{type}.xml',
326 630
631 + // Stylesheet branding (#639). Both colours default to empty, not
632 + // to the stock hexes: empty means the stylesheet's own value
633 + // stands, so a site that never opens this screen renders exactly
634 + // as it did before the setting existed.
635 + 'styling_logo' => false,
636 + 'styling_logo_url' => '',
637 + 'styling_color_main' => '',
638 + 'styling_color_accent' => '',
639 +
327 640 // Generation tracking
328 641 'last_generated' => ''
329 642 ];
330 643 }
@@ -329,8 +642,49 @@
329 642 ];
330 643 }
331 644
332 645 /**
646 + * Normalize the stylesheet branding values on the way into the store.
647 + *
648 + * The generic sanitizer only runs `sanitize_text_field()` over a string,
649 + * which happily keeps "red" or "rebeccapurple" as a colour. Nothing
650 + * downstream can use those — {@see Sitemap_Stylesheet::render()} skips any
651 + * value it cannot read as a hex colour — so storing them would report a
652 + * successful save of a setting that changes nothing, and `get-sitemap-
653 + * settings` would hand an agent back a colour the sitemap does not use.
654 + * Reducing here instead keeps the store and the rendering in agreement.
655 + *
656 + * @since 2.7.0
657 + *
658 + * @param array $settings Settings to sanitize.
659 + * @param string $context_type Context the save is for.
660 + * @return array
661 + */
662 + protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
663 + $sanitized = parent::sanitize_settings($settings, $context_type);
664 +
665 + foreach (['styling_color_main', 'styling_color_accent'] as $key) {
666 + if (array_key_exists($key, $sanitized)) {
667 + $sanitized[$key] = Sitemap_Stylesheet::hex($sanitized[$key]);
668 + }
669 + }
670 +
671 + if (array_key_exists('styling_logo_url', $sanitized)) {
672 + $sanitized['styling_logo_url'] = esc_url_raw((string) $sanitized['styling_logo_url']);
673 + }
674 +
675 + // A mode this build cannot act on has to be stored as the fallback
676 + // rather than kept verbatim, or get-sitemap-settings reports a delivery
677 + // mode the site does not actually apply.
678 + if (array_key_exists('delivery_mode', $sanitized)) {
679 + $mode = sanitize_key((string) $sanitized['delivery_mode']);
680 + $sanitized['delivery_mode'] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
681 + }
682 +
683 + return $sanitized;
684 + }
685 +
686 + /**
333 687 * Get settings schema definition (implements interface)
334 688 *
335 689 * @since 1.0.0
336 690 *
@@ -344,8 +698,15 @@
344 698 'title' => 'Enable Sitemap',
345 699 'description' => 'Generate XML sitemap for search engines',
346 700 'default' => true
347 701 ],
702 + 'delivery_mode' => [
703 + 'type' => 'string',
704 + 'title' => 'Sitemap Delivery',
705 + 'description' => 'How the sitemap is served: auto picks static when the WordPress root is writable and dynamic when it is not, static writes files to the web root, dynamic serves the sitemap from WordPress with no files written',
706 + 'enum' => self::DELIVERY_MODES,
707 + 'default' => 'auto'
708 + ],
348 709 'include_posts' => [
349 710 'type' => 'boolean',
350 711 'title' => 'Include Posts',
351 712 'description' => 'Include blog posts in sitemap',
@@ -386,8 +747,32 @@
386 747 'type' => 'string',
387 748 'title' => 'Last Generated',
388 749 'description' => 'Timestamp of last sitemap generation',
389 750 'default' => ''
751 + ],
752 + 'styling_logo' => [
753 + 'type' => 'boolean',
754 + 'title' => 'Show Logo On Sitemap',
755 + 'description' => 'Show a logo above the sitemap heading',
756 + 'default' => false
757 + ],
758 + 'styling_logo_url' => [
759 + 'type' => 'string',
760 + 'title' => 'Sitemap Logo',
761 + 'description' => 'Logo image URL. Empty falls back to the site icon',
762 + 'default' => ''
763 + ],
764 + 'styling_color_main' => [
765 + 'type' => 'string',
766 + 'title' => 'Sitemap Main Color',
767 + 'description' => 'Hex color for the sitemap header, links and table head. Empty keeps the stock palette',
768 + 'default' => ''
769 + ],
770 + 'styling_color_accent' => [
771 + 'type' => 'string',
772 + 'title' => 'Sitemap Accent Color',
773 + 'description' => 'Hex color for the header gradient and link hovers. Empty keeps the stock palette',
774 + 'default' => ''
390 775 ]
391 776 ];
392 777 }
393 778
@@ -404,9 +789,14 @@
404 789 * @return string XML URL entry
405 790 */
406 791 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
407 792 $xml = " <url>\n";
408 - $xml .= " <loc>" . esc_url($url) . "</loc>\n";
793 + // Every <loc> in every sitemap passes through here, which is why the
794 + // scheme preference is applied at this one point rather than at each
795 + // of the dozen collectors that build URLs (#638). An http sitemap on
796 + // an https site hands search engines the wrong address for the whole
797 + // site at once.
798 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($url)) . "</loc>\n";
409 799 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
410 800 // than no timestamp, and an absent lastmod is valid per the spec.
411 801 if (!empty($lastmod)) {
412 802 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
@@ -416,9 +806,9 @@
416 806
417 807 // Add image entries if provided
418 808 foreach ($images as $image) {
419 809 $xml .= " <image:image>\n";
420 - $xml .= " <image:loc>" . esc_url($image['url']) . "</image:loc>\n";
810 + $xml .= " <image:loc>" . esc_url(Url_Scheme::apply((string) $image['url'])) . "</image:loc>\n";
421 811
422 812 if (!empty($image['title'])) {
423 813 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
424 814 }
@@ -531,9 +921,21 @@
531 921 if (empty($all_ids)) {
532 922 return;
533 923 }
534 924
535 - foreach (array_chunk($all_ids, 500) as $chunk) {
925 + // Walk the ID list with a moving window rather than array_chunk().
926 + // array_chunk() builds a second array holding every element again, so
927 + // peak memory was twice the ID list — on a 100k-post site that is ~16MB
928 + // where ~8MB is needed, and this walk is the one part of an otherwise
929 + // well-bounded routine with no ceiling (#402).
930 + $total = count($all_ids);
931 +
932 + for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
933 + $this->assert_memory_headroom();
934 + $chunk_start = memory_get_usage(true);
935 +
936 + $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
937 +
536 938 $posts = get_posts($this->filter_query_args([
537 939 'post_type' => $post_types,
538 940 'post_status' => 'publish',
539 941 'numberposts' => count($chunk),
@@ -540,22 +942,51 @@
540 942 'post__in' => $chunk,
541 943 'orderby' => 'post__in', // preserve the resolved order
542 944 ]));
543 945
946 + // get_featured_image() hydrates each featured image under the
947 + // attachment's own ID, which the chunk's post IDs do not reach.
948 + $attachment_ids = [];
949 +
544 950 foreach ($posts as $post) {
545 951 if ($this->should_include_in_sitemap($post, $settings)) {
546 - $url = get_permalink($post);
952 + /**
953 + * Filter a sitemap entry's permalink.
954 + *
955 + * The multilingual manager uses this to generate each
956 + * translation's URL in its OWN language: the sitemap query
957 + * deliberately runs with suppress_filters, and the cron
958 + * rebuild runs with no language context at all, so a bare
959 + * get_permalink() resolved every translation to the
960 + * default-language URL — N entries sharing one <loc> (#409).
961 + *
962 + * @since 2.0.1
963 + * @param string $url Permalink as WordPress resolved it.
964 + * @param \WP_Post $post Post the entry describes.
965 + */
966 + $url = apply_filters('thinkrank_sitemap_post_permalink', get_permalink($post), $post);
547 967 $lastmod = gmdate('c', strtotime($post->post_modified_gmt));
548 968 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
549 969 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
550 970 $images = $this->extract_post_images($post, $settings);
551 971
972 + $thumbnail_id = (int) get_post_thumbnail_id($post);
973 + if ($thumbnail_id > 0) {
974 + $attachment_ids[] = $thumbnail_id;
975 + }
976 +
552 977 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
553 978 }
554 979 }
555 980
556 - // Free the hydrated chunk before loading the next one.
981 + // Free the hydrated chunk before loading the next one — including
982 + // the copies get_posts() left in the runtime object cache.
557 983 unset($posts);
984 + $this->release_walk_memory(
985 + $chunk_start,
986 + $this->chunk_post_cache_groups($post_types),
987 + array_merge($chunk, $attachment_ids)
988 + );
558 989 }
559 990 }
560 991
561 992 /**
@@ -611,9 +1042,17 @@
611 1042 if (is_wp_error($all_ids) || empty($all_ids)) {
612 1043 return;
613 1044 }
614 1045
615 - foreach (array_chunk($all_ids, 1000) as $chunk) {
1046 + // Same moving window as the post walk above, for the same reason.
1047 + $total = count($all_ids);
1048 +
1049 + for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
1050 + $this->assert_memory_headroom();
1051 + $chunk_start = memory_get_usage(true);
1052 +
1053 + $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
1054 +
616 1055 $terms = get_terms($this->filter_term_query_args([
617 1056 'taxonomy' => $taxonomy,
618 1057 'include' => $chunk,
619 1058 'orderby' => 'include', // preserve the resolved order
@@ -624,8 +1063,15 @@
624 1063 continue;
625 1064 }
626 1065
627 1066 foreach ($terms as $term) {
1067 + // A term the user marked noindex must not be advertised in the
1068 + // sitemap: the robots tag now honours term meta, so listing it
1069 + // here would have the sitemap contradict the page's own tag.
1070 + if ($this->term_is_noindexed((int) $term->term_id)) {
1071 + continue;
1072 + }
1073 +
628 1074 $url = get_term_link($term);
629 1075 if (!is_wp_error($url)) {
630 1076 // Omit lastmod for terms — the generation time is not a real
631 1077 // modification time and would mislabel every term as just-changed.
@@ -633,12 +1079,48 @@
633 1079 }
634 1080 }
635 1081
636 1082 unset($terms);
1083 + $this->release_walk_memory($chunk_start, ['terms', 'term_meta'], $chunk);
637 1084 }
638 1085 }
639 1086
640 1087 /**
1088 + * The XML declaration, ownership marker and optional stylesheet every
1089 + * sitemap document opens with.
1090 + *
1091 + * The marker is written unconditionally, and that is the point: removal on
1092 + * deactivate and uninstall deletes a web-root sitemap only when the file
1093 + * says it is ours, and our filenames are the canonical ones another SEO
1094 + * plugin writes too (#515). Tying the proof to `enable_styling` — the one
1095 + * marker older versions left — would mean a site with styling off either
1096 + * kept a shadowing file behind (#510) or had a competitor's deleted.
1097 + *
1098 + * @since 2.1.1
1099 + *
1100 + * The stylesheet URL is served by {@see Sitemap_Stylesheet}, not read off
1101 + * disk by the web server, because a static file cannot carry the site's own
1102 + * logo and colours (#639). It is a fixed URL: the palette is applied per
1103 + * request, so changing a brand colour needs no regeneration and shows up on
1104 + * sitemaps published long before.
1105 + *
1106 + * @param array $settings Sitemap settings (read for `enable_styling`).
1107 + * @param string $variant Stylesheet variant, `sitemap` or `index`.
1108 + * @return string Prolog lines, newline-terminated.
1109 + */
1110 + private function xml_prolog(array $settings, string $variant): string {
1111 + $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1112 + $xml .= THINKRANK_SITEMAP_MARKER . "\n";
1113 +
1114 + // The stylesheet is presentation only, so it stays opt-in.
1115 + if (!empty($settings['enable_styling'])) {
1116 + $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url(Sitemap_Stylesheet::url($variant)) . '"?>' . "\n";
1117 + }
1118 +
1119 + return $xml;
1120 + }
1121 +
1122 + /**
641 1123 * Wrap a set of <url> entry strings in a complete <urlset> document.
642 1124 *
643 1125 * @since 1.14.0
644 1126 *
@@ -647,14 +1129,10 @@
647 1129 * @param bool $with_image_ns Include the image sitemap namespace
648 1130 * @return string Full sitemap XML
649 1131 */
650 1132 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
651 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1133 + $xml = $this->xml_prolog($settings, 'sitemap');
652 1134
653 - if (!empty($settings['enable_styling'])) {
654 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
655 - }
656 -
657 1135 if ($with_image_ns && !empty($settings['include_images'])) {
658 1136 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
659 1137 } else {
660 1138 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
@@ -720,9 +1198,9 @@
720 1198 // Remove duplicates based on URL
721 1199 $unique_images = [];
722 1200 $seen_urls = [];
723 1201 foreach ($images as $image) {
724 - if (!in_array($image['url'], $seen_urls)) {
1202 + if (!in_array($image['url'], $seen_urls, true)) {
725 1203 $unique_images[] = $image;
726 1204 $seen_urls[] = $image['url'];
727 1205 }
728 1206 }
@@ -831,9 +1309,16 @@
831 1309 '_builtin' => false
832 1310 ], 'names');
833 1311
834 1312 foreach ($custom_taxonomies as $taxonomy) {
835 - if ($this->should_include_taxonomy($taxonomy)) {
1313 + if (!$this->should_include_taxonomy($taxonomy)) {
1314 + continue;
1315 + }
1316 +
1317 + // Same as the post-type walk above: an explicit per-taxonomy flag
1318 + // now decides, and an unset flag keeps the previous "included"
1319 + // behaviour (#660).
1320 + if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $settings)) {
836 1321 $taxonomies[] = $taxonomy;
837 1322 }
838 1323 }
839 1324
@@ -888,8 +1373,16 @@
888 1373 if (is_wp_error($all_terms)) {
889 1374 return [];
890 1375 }
891 1376
1377 + // Drop terms the user marked noindex. This path feeds the single general
1378 + // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
1379 + // so both need the filter or the two disagree about the same term.
1380 + $all_terms = array_values(array_filter(
1381 + $all_terms,
1382 + fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1383 + ));
1384 +
892 1385 // Group terms by taxonomy
893 1386 return $this->group_terms_by_taxonomy($all_terms);
894 1387 }
895 1388
@@ -979,12 +1472,12 @@
979 1472
980 1473 // Special page types get higher priority
981 1474 if ($post_type === 'page') {
982 1475 $page_template = get_page_template_slug($post->ID);
983 - if (in_array($page_template, ['page-home.php', 'front-page.php']) ||
984 - $post->ID == get_option('page_on_front')) {
1476 + if (in_array($page_template, ['page-home.php', 'front-page.php'], true) ||
1477 + (int) $post->ID === (int) get_option('page_on_front')) {
985 1478 $base_priority = 1.0; // Homepage gets maximum priority
986 - } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'])) {
1479 + } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
987 1480 $adjustments += 0.05; // Important pages boost
988 1481 }
989 1482 }
990 1483
@@ -1006,11 +1499,11 @@
1006 1499 private function calculate_change_frequency(\WP_Post $post, string $post_type): string {
1007 1500 // Pages typically change less frequently
1008 1501 if ($post_type === 'page') {
1009 1502 $page_template = get_page_template_slug($post->ID);
1010 - if ($post->ID == get_option('page_on_front')) {
1503 + if ((int) $post->ID === (int) get_option('page_on_front')) {
1011 1504 return 'daily'; // Homepage changes frequently
1012 - } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'])) {
1505 + } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1013 1506 return 'monthly'; // Static pages change monthly
1014 1507 }
1015 1508 return 'yearly'; // Other pages change rarely
1016 1509 }
@@ -1038,8 +1531,17 @@
1038 1531 * @param array $settings Sitemap settings
1039 1532 * @return bool Whether to include in sitemap
1040 1533 */
1041 1534 private function should_include_in_sitemap(\WP_Post $post, array $settings): bool {
1535 + // The WooCommerce cart, checkout and account pages are transactional,
1536 + // never indexable, and generate_default_robots_rules() already emits a
1537 + // Disallow for each of them. Listing them here submitted URLs our own
1538 + // robots.txt blocks, which Search Console reports as "Submitted URL
1539 + // blocked by robots.txt". Yoast and Rank Math exclude the same three.
1540 + if (in_array($post->ID, $this->woocommerce_excluded_page_ids(), true)) {
1541 + return false;
1542 + }
1543 +
1042 1544 // Respect user setting for password protected content
1043 1545 if (!empty($post->post_password) && !empty($settings['exclude_password_protected'])) {
1044 1546 return false;
1045 1547 }
@@ -1075,8 +1577,69 @@
1075 1577 return true;
1076 1578 }
1077 1579
1078 1580 /**
1581 + * WooCommerce pages that must never reach the sitemap.
1582 + *
1583 + * Resolved through wc_get_page_id() so a store that moved or renamed its
1584 + * cart/checkout/account pages is still matched. Returns an empty list when
1585 + * WooCommerce is not active. Memoised — should_include_in_sitemap() runs
1586 + * once per post.
1587 + *
1588 + * @since 2.0.1
1589 + *
1590 + * @return int[] Page IDs to exclude.
1591 + */
1592 + private function woocommerce_excluded_page_ids(): array {
1593 + if ($this->woocommerce_excluded_page_ids !== null) {
1594 + return $this->woocommerce_excluded_page_ids;
1595 + }
1596 +
1597 + $ids = [];
1598 +
1599 + if (function_exists('wc_get_page_id')) {
1600 + foreach (['cart', 'checkout', 'myaccount'] as $page) {
1601 + $id = (int) wc_get_page_id($page);
1602 + // wc_get_page_id() returns -1 when the page is not configured.
1603 + if ($id > 0) {
1604 + $ids[] = $id;
1605 + }
1606 + }
1607 + }
1608 +
1609 + $this->woocommerce_excluded_page_ids = $ids;
1610 +
1611 + return $ids;
1612 + }
1613 +
1614 + /**
1615 + * Whether a term carries an explicit noindex override.
1616 + *
1617 + * Mirrors the post-side check in should_include_post(); terms store the same
1618 + * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1619 + * by the update-term-seo ability and by the SEO importer.
1620 + *
1621 + * @since 1.31.0
1622 + *
1623 + * @param int $term_id Term to test.
1624 + * @return bool True when the term is marked noindex.
1625 + */
1626 + private function term_is_noindexed(int $term_id): bool {
1627 + if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1628 + return false;
1629 + }
1630 +
1631 + $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1632 + if (!is_string($raw) || $raw === '') {
1633 + return false;
1634 + }
1635 +
1636 + $robots = json_decode($raw, true);
1637 +
1638 + return is_array($robots) && !empty($robots['noindex']);
1639 + }
1640 +
1641 + /**
1079 1642 * Count total URLs in sitemap
1080 1643 *
1081 1644 * @since 1.0.0
1082 1645 *
@@ -1239,9 +1802,15 @@
1239 1802 if (!empty($settings['include_pages'])) {
1240 1803 $post_types[] = 'page';
1241 1804 }
1242 1805
1243 - // Auto-detect public custom post types that should be included
1806 + // Auto-detect public custom post types that should be included.
1807 + //
1808 + // A custom type's `include_<slug>` / `exclude_<slug>` flag is honoured
1809 + // here (#660). It was previously stored — additional_setting_keys()
1810 + // has always let those keys through — but never read, so a CPT was in
1811 + // the sitemap whatever the setting said. Unset still means included, so
1812 + // a site that never touched the flag is unaffected.
1244 1813 $custom_post_types = get_post_types([
1245 1814 'public' => true,
1246 1815 '_builtin' => false
1247 1816 ], 'names');
@@ -1246,9 +1815,13 @@
1246 1815 '_builtin' => false
1247 1816 ], 'names');
1248 1817
1249 1818 foreach ($custom_post_types as $post_type) {
1250 - if ($this->should_include_post_type($post_type)) {
1819 + if (!$this->should_include_post_type($post_type)) {
1820 + continue;
1821 + }
1822 +
1823 + if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $post_type, $settings)) {
1251 1824 $post_types[] = $post_type;
1252 1825 }
1253 1826 }
1254 1827
@@ -1269,18 +1842,11 @@
1269 1842 // Templately's internal `templately_library` store — which are template
1270 1843 // records, not standalone indexable URLs. BetterDocs `docs` and
1271 1844 // WooCommerce `product` register exclude_from_search => false, so they
1272 1845 // remain included.
1273 - if (!is_post_type_viewable($post_type)) {
1274 - return false;
1275 - }
1276 -
1277 - $post_type_obj = get_post_type_object($post_type);
1278 - if (!$post_type_obj || !empty($post_type_obj->exclude_from_search)) {
1279 - return false;
1280 - }
1281 -
1282 - return true;
1846 + // The predicate lives in Content_Type_Settings so the matrix can ask
1847 + // the same question before offering a switch for this post type.
1848 + return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_post_type($post_type);
1283 1849 }
1284 1850
1285 1851 /**
1286 1852 * Check if taxonomy should trigger regeneration
@@ -1289,11 +1855,13 @@
1289 1855 * @param string $taxonomy Taxonomy slug
1290 1856 * @return bool True if taxonomy should trigger regeneration
1291 1857 */
1292 1858 private function should_include_taxonomy(string $taxonomy): bool {
1293 - // Include all public taxonomies (presets control which sitemaps are created)
1294 - $taxonomy_obj = get_taxonomy($taxonomy);
1295 - return $taxonomy_obj && $taxonomy_obj->public;
1859 + // Public taxonomies only, and only the ones this generator can actually
1860 + // emit — the same predicate the content-type matrix asks before it
1861 + // offers a sitemap switch for one (presets still control which
1862 + // sitemaps are created).
1863 + return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_taxonomy($taxonomy);
1296 1864 }
1297 1865
1298 1866 /**
1299 1867 * Schedule debounced sitemap regeneration
@@ -1301,16 +1869,679 @@
1301 1869 * @since 1.0.0
1302 1870 * @return void
1303 1871 */
1304 1872 private function schedule_debounced_regeneration(): void {
1305 - // Clear any existing scheduled regeneration
1306 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap');
1873 + $this->mark_regeneration_pending('content');
1874 + $this->debounce_event('thinkrank_regenerate_sitemap');
1875 + }
1307 1876
1308 - // Schedule regeneration in 30 seconds to debounce rapid changes
1309 - wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap');
1877 + /**
1878 + * Schedule (or keep) the debounced single event behind a regeneration hook.
1879 + *
1880 + * An event that is already due is left alone. WP-Cron only runs when a
1881 + * request arrives, so on a site with DISABLE_WP_CRON, a blocked loopback or
1882 + * little traffic an overdue event can sit in the queue for a long time —
1883 + * clearing and re-scheduling it on every save pushed the rebuild
1884 + * permanently 30 seconds into the future and the sitemap never updated
1885 + * (#629). Debouncing only against an event that has not come due yet keeps
1886 + * the bulk-edit coalescing without starving the rebuild.
1887 + *
1888 + * @since 2.2.1
1889 + * @param string $hook Regeneration hook to debounce.
1890 + * @return void
1891 + */
1892 + private function debounce_event(string $hook): void {
1893 + $next = wp_next_scheduled($hook);
1894 +
1895 + if ($next !== false) {
1896 + if ($next <= time()) {
1897 + return;
1898 + }
1899 +
1900 + wp_clear_scheduled_hook($hook);
1901 + }
1902 +
1903 + wp_schedule_single_event(time() + self::REGENERATION_DEBOUNCE, $hook);
1310 1904 }
1311 1905
1312 1906 /**
1907 + * Record that a rebuild is outstanding, so an overdue one can be taken over
1908 + * by a later request and its staleness surfaced in the UI.
1909 + *
1910 + * `since` is the *oldest* outstanding change: it is what the takeover grace
1911 + * and the admin staleness warning are measured from, so successive edits
1912 + * must not push it forward. A settings change outranks a content change —
1913 + * it rebuilds regardless of the auto_generate toggle and handles a sitemap
1914 + * that has just been disabled — so once one is outstanding it stays the
1915 + * recorded source until the rebuild lands.
1916 + *
1917 + * @since 2.2.1
1918 + * @param string $source Either 'content' or 'settings'.
1919 + * @return void
1920 + */
1921 + private function mark_regeneration_pending(string $source): void {
1922 + // Whatever made the static files stale made the rendered ones stale
1923 + // too. Invalidating here rather than only on the rebuild keeps the two
1924 + // delivery modes reacting to exactly the same triggers, which is the
1925 + // only way a dynamic site stays as fresh as a static one (#752).
1926 + $this->flush_dynamic_cache();
1927 +
1928 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1929 + $pending = is_array($pending) ? $pending : [];
1930 +
1931 + $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1932 + $current = isset($pending['source']) ? (string) $pending['source'] : '';
1933 + $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1934 +
1935 + // Merged so the bookkeeping a rebuild keeps on the marker (`started`,
1936 + // `memory_limit`) survives an edit made while it is outstanding.
1937 + update_option(
1938 + self::REGENERATION_PENDING_OPTION,
1939 + array_merge($pending, [
1940 + 'since' => $since,
1941 + 'source' => $source,
1942 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
1943 + 'next_attempt' => !empty($pending['next_attempt'])
1944 + ? (int) $pending['next_attempt']
1945 + : time() + self::REGENERATION_TAKEOVER_GRACE,
1946 + // Bumped on every change so a rebuild can tell whether the edit
1947 + // it started for is still the newest one outstanding.
1948 + 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
1949 + ]),
1950 + true
1951 + );
1952 + }
1953 +
1954 + /**
1955 + * The revision of the outstanding rebuild, for
1956 + * {@see mark_regeneration_complete()} to compare against once it is done.
1957 + *
1958 + * @since 2.2.1
1959 + * @return int Current revision, 0 when nothing is outstanding.
1960 + */
1961 + private function current_regeneration_revision(): int {
1962 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1963 +
1964 + return (is_array($pending) && !empty($pending['revision'])) ? (int) $pending['revision'] : 0;
1965 + }
1966 +
1967 + /**
1968 + * Clear the outstanding-rebuild marker and any recorded failure.
1969 + *
1970 + * Public because a manual generation satisfies whatever the automatic path
1971 + * was still waiting to write.
1972 + *
1973 + * @since 2.2.1
1974 + * @return void
1975 + */
1976 + public function mark_regeneration_complete(?int $revision = null): void {
1977 + // The write succeeded, so whatever failure was on record is history.
1978 + if (get_option(self::REGENERATION_ERROR_OPTION, null) !== null) {
1979 + delete_option(self::REGENERATION_ERROR_OPTION);
1980 + }
1981 +
1982 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
1983 +
1984 + if ($pending === null) {
1985 + return;
1986 + }
1987 +
1988 + // A change that landed while this rebuild was running is not covered by
1989 + // the files it just wrote, so it has to stay outstanding — otherwise, on
1990 + // a site where WP-Cron never fires, clearing the marker would strand it
1991 + // exactly the way #629 stranded everything.
1992 + if (
1993 + $revision !== null
1994 + && is_array($pending)
1995 + && (int) ($pending['revision'] ?? 0) !== $revision
1996 + ) {
1997 + // This attempt succeeded, so drop what it claimed: the newer change
1998 + // waits the grace a fresh edit gets, not a failure backoff it never
1999 + // earned. Not zero: that edit queued its own debounced event, and
2000 + // a marker due at once had the next admin request rebuild in its
2001 + // shutdown and the event rebuild again seconds later.
2002 + unset($pending['started'], $pending['memory_limit']);
2003 + $pending['attempts'] = 0;
2004 + $pending['next_attempt'] = time() + self::REGENERATION_TAKEOVER_GRACE;
2005 +
2006 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2007 + return;
2008 + }
2009 +
2010 + delete_option(self::REGENERATION_PENDING_OPTION);
2011 + }
2012 +
2013 + /**
2014 + * Record the attempt that is about to run before it runs.
2015 + *
2016 + * A PHP fatal — the memory limit or max_execution_time — is not a
2017 + * Throwable, so no catch or finally around the generation runs when the
2018 + * process dies, and a failure recorded afterwards was never recorded at
2019 + * all: `attempts` stayed 0, the backoff never applied, and the next request
2020 + * started the same doomed rebuild again (a fatal every few minutes for as
2021 + * long as an admin was logged in). Claiming the attempt up front makes the
2022 + * backoff hold even when nothing after this line gets to run, and leaves a
2023 + * `started` stamp the next attempt can recognise as an interrupted one.
2024 + *
2025 + * @since 2.10.1
2026 + * @return void
2027 + */
2028 + private function claim_regeneration_attempt(): void {
2029 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
2030 +
2031 + // Nothing outstanding (e.g. a manual generation already satisfied it):
2032 + // there is no marker to retry from, so nothing to claim.
2033 + if (!is_array($pending) || empty($pending['since'])) {
2034 + return;
2035 + }
2036 +
2037 + if (!empty($pending['started'])) {
2038 + // The previous attempt claimed itself and never reported back.
2039 + update_option(
2040 + self::REGENERATION_ERROR_OPTION,
2041 + [
2042 + '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'),
2043 + 'source' => isset($pending['source']) ? (string) $pending['source'] : 'content',
2044 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 1,
2045 + 'time' => (int) $pending['started'],
2046 + ],
2047 + false
2048 + );
2049 + }
2050 +
2051 + $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2052 +
2053 + $pending['attempts'] = $attempts;
2054 + $pending['next_attempt'] = time() + $this->regeneration_backoff($attempts);
2055 + $pending['started'] = time();
2056 +
2057 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2058 + }
2059 +
2060 + /**
2061 + * Delay before the next takeover after the given number of attempts.
2062 + *
2063 + * @since 2.10.1
2064 + * @param int $attempts Attempts made so far (1 or more).
2065 + * @return int Seconds.
2066 + */
2067 + private function regeneration_backoff(int $attempts): int {
2068 + return (int) min(
2069 + self::REGENERATION_TAKEOVER_GRACE * (2 ** min(max($attempts, 1), 10)),
2070 + self::REGENERATION_MAX_BACKOFF
2071 + );
2072 + }
2073 +
2074 + /**
2075 + * Record a failed regeneration instead of discarding it.
2076 + *
2077 + * Keeps the pending marker in place so the rebuild is retried, but backs the
2078 + * next attempt off exponentially (capped) so a persistently failing
2079 + * generation cannot run on every admin request.
2080 + *
2081 + * @since 2.2.1
2082 + * @since 2.10.1 Accepts the memory limit a rebuild had to stop short of, and
2083 + * does not count an attempt claim_regeneration_attempt()
2084 + * already counted.
2085 + * @param string $message Failure detail.
2086 + * @param string $source Either 'content' or 'settings'.
2087 + * @param int|null $memory_limit Memory limit (bytes) the rebuild stopped
2088 + * short of, when that was the failure.
2089 + * @return void
2090 + */
2091 + private function record_regeneration_failure(string $message, string $source, ?int $memory_limit = null): void {
2092 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2093 + $pending = is_array($pending) ? $pending : [];
2094 + $attempts = !empty($pending['attempts']) ? (int) $pending['attempts'] : 0;
2095 +
2096 + // An attempt that claimed itself up front has already been counted.
2097 + if (empty($pending['started'])) {
2098 + $attempts++;
2099 + }
2100 + $attempts = max($attempts, 1);
2101 +
2102 + $backoff = $this->regeneration_backoff($attempts);
2103 +
2104 + // Same precedence mark_regeneration_pending() enforces: a settings
2105 + // rebuild outranks a content one and must not be downgraded by a failed
2106 + // attempt. Overwriting it routed the retry back through the content
2107 + // path, where should_auto_generate() can be false and the completion
2108 + // marker then discards the settings rebuild entirely. Only the
2109 + // outstanding rebuild is upgraded — the recorded error keeps reporting
2110 + // whichever attempt actually failed.
2111 + $current = isset($pending['source']) ? (string) $pending['source'] : '';
2112 + $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
2113 +
2114 + $marker = [
2115 + 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
2116 + 'source' => $pending_source,
2117 + 'attempts' => $attempts,
2118 + 'next_attempt' => time() + $backoff,
2119 + 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
2120 + ];
2121 +
2122 + // Remembered so has_memory_for_retry() can keep requests with no more
2123 + // memory than this from repeating the same attempt.
2124 + if ($memory_limit !== null && $memory_limit > 0) {
2125 + $marker['memory_limit'] = $memory_limit;
2126 + }
2127 +
2128 + update_option(self::REGENERATION_PENDING_OPTION, $marker, true);
2129 +
2130 + update_option(
2131 + self::REGENERATION_ERROR_OPTION,
2132 + [
2133 + 'message' => $message,
2134 + 'source' => $source,
2135 + 'attempts' => $attempts,
2136 + 'time' => time(),
2137 + ],
2138 + false
2139 + );
2140 +
2141 + if (defined('WP_DEBUG') && WP_DEBUG) {
2142 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2143 + error_log(sprintf('ThinkRank: sitemap %s regeneration failed — %s', $source, $message));
2144 + }
2145 + }
2146 +
2147 + /**
2148 + * Is a rebuild outstanding and past the point where WP-Cron should have run
2149 + * it?
2150 + *
2151 + * Deliberately cheap — one autoloaded option read — because it is consulted
2152 + * on every admin request to decide whether the takeover is needed.
2153 + *
2154 + * @since 2.2.1
2155 + * @return bool True when a request should rebuild the sitemap itself.
2156 + */
2157 + public static function has_overdue_regeneration(): bool {
2158 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2159 +
2160 + if (!is_array($pending) || empty($pending['since'])) {
2161 + return false;
2162 + }
2163 +
2164 + $due = !empty($pending['next_attempt'])
2165 + ? (int) $pending['next_attempt']
2166 + : (int) $pending['since'] + self::REGENERATION_TAKEOVER_GRACE;
2167 +
2168 + return time() >= $due;
2169 + }
2170 +
2171 + /**
2172 + * Rebuild the sitemap in-request when WP-Cron has not delivered.
2173 + *
2174 + * Hooked on `shutdown` for admin, REST and CLI requests only (see
2175 + * Plugin::register_sitemap_cron_listeners()), so the work happens after the
2176 + * response has been sent and never adds latency to a visitor page view.
2177 + *
2178 + * @since 2.2.1
2179 + * @return void
2180 + */
2181 + public function run_overdue_regeneration(): void {
2182 + if (!self::has_overdue_regeneration()) {
2183 + return;
2184 + }
2185 +
2186 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2187 + $pending = is_array($pending) ? $pending : [];
2188 + $source = isset($pending['source']) ? (string) $pending['source'] : 'content';
2189 +
2190 + // Cron is running and its own event for this rebuild is still queued
2191 + // and due: it is about to do exactly this work. Only then — an event
2192 + // that has already been consumed (e.g. it fired while another process
2193 + // held the generation lock) is never re-queued, and returning here
2194 + // unconditionally left the rebuild to requests that could not finish it.
2195 + if (wp_doing_cron()) {
2196 + $hook = $source === 'settings' ? 'thinkrank_regenerate_sitemap_settings' : 'thinkrank_regenerate_sitemap';
2197 + $next = wp_next_scheduled($hook);
2198 +
2199 + if ($next !== false && $next <= time()) {
2200 + return;
2201 + }
2202 + }
2203 +
2204 + // The last attempt had to stop short of this process's memory limit.
2205 + // Retrying at the same (or a lower) limit only repeats that, so leave
2206 + // the rebuild to a process with more room — WP-CLI, a system cron, or a
2207 + // host with a higher limit — instead of burning it on every request.
2208 + if (!$this->has_memory_for_retry($pending)) {
2209 + return;
2210 + }
2211 +
2212 + if ($source === 'settings') {
2213 + $this->regenerate_sitemap_from_settings();
2214 + return;
2215 + }
2216 +
2217 + $this->auto_regenerate_sitemap();
2218 + }
2219 +
2220 + /**
2221 + * Acquire the shared generation lock.
2222 + *
2223 + * @since 2.2.1
2224 + * @return bool True when this process may generate.
2225 + */
2226 + private function acquire_generation_lock(): bool {
2227 + if (get_transient(self::GENERATION_LOCK_TRANSIENT)) {
2228 + return false;
2229 + }
2230 +
2231 + set_transient(self::GENERATION_LOCK_TRANSIENT, time(), 5 * MINUTE_IN_SECONDS);
2232 +
2233 + return true;
2234 + }
2235 +
2236 + /**
2237 + * Release the shared generation lock.
2238 + *
2239 + * @since 2.2.1
2240 + * @return void
2241 + */
2242 + private function release_generation_lock(): void {
2243 + delete_transient(self::GENERATION_LOCK_TRANSIENT);
2244 + }
2245 +
2246 + /**
2247 + * This process's PHP memory limit in bytes.
2248 + *
2249 + * @since 2.10.1
2250 + * @return int Bytes, or -1 when unlimited (or unreadable).
2251 + */
2252 + private function current_memory_limit(): int {
2253 + $limit = (string) ini_get('memory_limit');
2254 +
2255 + if ($limit === '' || $limit === '-1') {
2256 + return -1;
2257 + }
2258 +
2259 + $bytes = (int) wp_convert_hr_to_bytes($limit);
2260 +
2261 + return $bytes > 0 ? $bytes : -1;
2262 + }
2263 +
2264 + /**
2265 + * May this process retry a rebuild that last stopped at the memory limit?
2266 + *
2267 + * Raises the limit the way wp-admin does first, so a request that can get
2268 + * more room than the failed attempt had is still allowed to try.
2269 + *
2270 + * @since 2.10.1
2271 + * @param array $pending The pending marker.
2272 + * @return bool True when there is no recorded memory failure, or this
2273 + * process has more memory than the attempt that failed.
2274 + */
2275 + private function has_memory_for_retry(array $pending): bool {
2276 + if (empty($pending['memory_limit'])) {
2277 + return true;
2278 + }
2279 +
2280 + wp_raise_memory_limit('admin');
2281 +
2282 + $limit = $this->current_memory_limit();
2283 +
2284 + return $limit === -1 || $limit > (int) $pending['memory_limit'];
2285 + }
2286 +
2287 + /**
2288 + * Release what one walked chunk left behind.
2289 + *
2290 + * Hydrating a chunk through get_posts()/get_terms() also stores every
2291 + * object and its meta in the in-process object cache, which nothing
2292 + * empties until the request ends. Unsetting the chunk therefore freed
2293 + * nothing, and the walk grew with the size of the site instead of the size
2294 + * of a chunk — about 1.3 GB on a 45k-post site.
2295 + *
2296 + * A persistent object cache that supports it drops only its in-process
2297 + * copy (`flush_runtime`); the shared store keeps its data. WordPress's
2298 + * default cache has no shared store, and flushing it would empty every
2299 + * group for the rest of the request (options, the queried object, other
2300 + * plugins' data), so there only the chunk's own entries are deleted. A
2301 + * persistent cache without `flush_runtime` is left alone: deleting from it
2302 + * would evict the objects for every other request too.
2303 + *
2304 + * @since 2.10.1
2305 + * @param int $chunk_start memory_get_usage(true) before the chunk was hydrated.
2306 + * @param array $groups Cache groups keyed by the chunk's object IDs.
2307 + * @param int[] $ids The chunk's object IDs, plus any objects it
2308 + * hydrated under their own (featured images).
2309 + * @return void
2310 + * @throws \Error See assert_memory_headroom().
2311 + */
2312 + private function release_walk_memory(int $chunk_start, array $groups, array $ids): void {
2313 + // What one chunk costs before it is released: the margin the next one
2314 + // needs. Measured in the same real allocated size assert_memory_headroom()
2315 + // compares against the limit, so the two are the same unit.
2316 + $this->walk_chunk_cost = max($this->walk_chunk_cost, memory_get_usage(true) - $chunk_start);
2317 +
2318 + if (wp_using_ext_object_cache()) {
2319 + if (
2320 + function_exists('wp_cache_supports')
2321 + && wp_cache_supports('flush_runtime')
2322 + && function_exists('wp_cache_flush_runtime')
2323 + ) {
2324 + wp_cache_flush_runtime();
2325 + }
2326 + } elseif (!empty($ids)) {
2327 + foreach ($groups as $group) {
2328 + wp_cache_delete_multiple($ids, $group);
2329 + }
2330 + }
2331 +
2332 + $this->assert_memory_headroom();
2333 + }
2334 +
2335 + /**
2336 + * Cache groups get_posts() fills per post for the given post types.
2337 + *
2338 + * The post, its meta, and one relationships group per taxonomy the post
2339 + * type uses (update_object_term_cache()). Term objects themselves are
2340 + * bounded by the number of terms, not posts, so they are left cached.
2341 + *
2342 + * @since 2.10.1
2343 + * @param string[] $post_types Post types being walked.
2344 + * @return string[] Cache groups keyed by post ID.
2345 + */
2346 + private function chunk_post_cache_groups(array $post_types): array {
2347 + $groups = ['posts', 'post_meta'];
2348 +
2349 + foreach (get_object_taxonomies($post_types) as $taxonomy) {
2350 + $groups[] = $taxonomy . '_relationships';
2351 + }
2352 +
2353 + return array_values(array_unique($groups));
2354 + }
2355 +
2356 + /**
2357 + * Stop an automatic rebuild before the memory limit rather than at it.
2358 + *
2359 + * A PHP memory fatal skips every catch and finally, so the lock, the
2360 + * failure record and the backoff are all lost with it, while stopping here
2361 + * is an ordinary, fully recorded failure. Checked before each chunk is
2362 + * hydrated, against a margin of at least the largest chunk seen so far.
2363 + *
2364 + * It throws an \Error, not an \Exception, on purpose: the per-segment
2365 + * catch (\Exception) blocks in generate_multiple_sitemaps() would otherwise
2366 + * swallow it and carry on — writing an index without the aborted segments
2367 + * and then pruning their files as orphans. Only the automatic rebuild's
2368 + * catch (\Throwable) is meant to see it.
2369 + *
2370 + * @since 2.10.1
2371 + * @return void
2372 + * @throws \Error When the automatic rebuild is close to the memory limit.
2373 + */
2374 + private function assert_memory_headroom(): void {
2375 + if (!$this->memory_guard) {
2376 + return;
2377 + }
2378 +
2379 + $limit = $this->current_memory_limit();
2380 + if ($limit === -1) {
2381 + return;
2382 + }
2383 +
2384 + // A fifth of the limit (at least 32 MB) for writing the files, or one
2385 + // and a half of the costliest chunk if that is more — and never more
2386 + // than half the limit either way. Without that outer cap a single
2387 + // anomalously expensive chunk (500 posts of serialised page-builder or
2388 + // ACF meta reaches hundreds of megabytes) puts the margin above the
2389 + // limit itself, so every later check aborts at any usage at all, the
2390 + // failure records this process's limit, and has_memory_for_retry()
2391 + // then refuses every process that has the same limit. A site that
2392 + // never actually ran out of memory would stop rebuilding until WP-CLI
2393 + // or a system cron happened to run.
2394 + $headroom = (int) min(
2395 + max(
2396 + min(max($limit * 0.2, 32 * MB_IN_BYTES), $limit * 0.5),
2397 + $this->walk_chunk_cost * 1.5
2398 + ),
2399 + $limit * 0.5
2400 + );
2401 +
2402 + // The real allocated size, which is what PHP enforces memory_limit
2403 + // against; memory_get_usage(false) reports only what is handed out of
2404 + // those allocations and so understates the margin by the allocator's
2405 + // slack.
2406 + $usage = memory_get_usage(true);
2407 +
2408 + if ($usage > $limit - $headroom) {
2409 + throw new \Error(
2410 + sprintf(
2411 + /* translators: 1: memory in use, 2: PHP memory limit. */
2412 + 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'),
2413 + esc_html((string) size_format($usage)),
2414 + esc_html((string) size_format($limit))
2415 + ),
2416 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- an integer class constant, not output.
2417 + self::MEMORY_ABORT_CODE
2418 + );
2419 + }
2420 + }
2421 +
2422 + /**
2423 + * Run an automatic rebuild's generation with the fatal-safe bookkeeping.
2424 + *
2425 + * @since 2.10.1
2426 + * @param array $settings Sitemap settings.
2427 + * @return bool Whatever generate_and_save() returned.
2428 + * @throws \Throwable Whatever generation throws, after the memory guard is
2429 + * switched back off.
2430 + */
2431 + private function generate_for_regeneration(array $settings): bool {
2432 + // Same headroom wp-admin gives itself; a no-op when the limit is
2433 + // already higher or unlimited.
2434 + wp_raise_memory_limit('admin');
2435 +
2436 + $this->claim_regeneration_attempt();
2437 + $this->memory_guard = true;
2438 + $this->walk_chunk_cost = 0;
2439 +
2440 + try {
2441 + return $this->generate_and_save($settings);
2442 + } finally {
2443 + $this->memory_guard = false;
2444 + }
2445 + }
2446 +
2447 + /**
2448 + * Record a failure thrown by an automatic rebuild.
2449 + *
2450 + * @since 2.10.1
2451 + * @param \Throwable $e What was thrown.
2452 + * @param string $source Either 'content' or 'settings'.
2453 + * @return void
2454 + */
2455 + private function record_thrown_regeneration_failure(\Throwable $e, string $source): void {
2456 + $memory_limit = null;
2457 +
2458 + if ($e instanceof \Error && $e->getCode() === self::MEMORY_ABORT_CODE) {
2459 + $memory_limit = $this->current_memory_limit();
2460 + $memory_limit = $memory_limit > 0 ? $memory_limit : null;
2461 + }
2462 +
2463 + $this->record_regeneration_failure($e->getMessage(), $source, $memory_limit);
2464 + }
2465 +
2466 + /**
2467 + * Report how automatic regeneration is faring, for the admin UI.
2468 + *
2469 + * The feature used to fail invisibly: `last_generated` simply stopped
2470 + * advancing and nothing drew attention to it (#629).
2471 + *
2472 + * @since 2.2.1
2473 + * @return array Health payload.
2474 + */
2475 + public function get_regeneration_health(): array {
2476 + $settings = $this->get_settings('site');
2477 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2478 + $pending = is_array($pending) ? $pending : [];
2479 + $error = get_option(self::REGENERATION_ERROR_OPTION, []);
2480 + $error = is_array($error) ? $error : [];
2481 +
2482 + $since = !empty($pending['since']) ? (int) $pending['since'] : 0;
2483 +
2484 + $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap');
2485 + if ($next_scheduled === false) {
2486 + $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap_settings');
2487 + }
2488 +
2489 + return [
2490 + 'auto_generate' => !empty($settings['auto_generate']),
2491 + 'last_generated' => $settings['last_generated'] ?? '',
2492 + 'pending_since' => $since ? gmdate('c', $since) : null,
2493 + 'pending_seconds' => $since ? max(0, time() - $since) : 0,
2494 + 'next_scheduled' => $next_scheduled ? gmdate('c', (int) $next_scheduled) : null,
2495 + 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON,
2496 + 'stale' => $this->is_sitemap_stale($settings),
2497 + 'last_error' => !empty($error['message'])
2498 + ? [
2499 + 'message' => (string) $error['message'],
2500 + 'source' => isset($error['source']) ? (string) $error['source'] : 'content',
2501 + 'time' => !empty($error['time']) ? gmdate('c', (int) $error['time']) : null,
2502 + ]
2503 + : null,
2504 + ];
2505 + }
2506 +
2507 + /**
2508 + * Has published content changed since the served sitemap was last written?
2509 + *
2510 + * Uses core's cached last-modified lookup, which only considers published
2511 + * posts — the same content the sitemap covers.
2512 + *
2513 + * @since 2.2.1
2514 + * @param array $settings Sitemap settings.
2515 + * @return bool True when the sitemap is behind the content.
2516 + */
2517 + private function is_sitemap_stale(array $settings): bool {
2518 + if (empty($settings['enabled']) || empty($settings['last_generated'])) {
2519 + // Never generated is already reported separately by the UI.
2520 + return false;
2521 + }
2522 +
2523 + $generated = strtotime((string) $settings['last_generated']);
2524 + if (!$generated) {
2525 + return false;
2526 + }
2527 +
2528 + $modified = get_lastpostmodified('gmt');
2529 + if (!$modified) {
2530 + return false;
2531 + }
2532 +
2533 + $modified = strtotime($modified . ' UTC');
2534 + if (!$modified) {
2535 + return false;
2536 + }
2537 +
2538 + // A minute of slack keeps a rebuild that ran alongside the edit from
2539 + // reporting itself as stale.
2540 + return $modified > ($generated + MINUTE_IN_SECONDS);
2541 + }
2542 +
2543 + /**
1313 2544 * Public entry point to debounce-rebuild the sitemap after a settings change
1314 2545 * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
1315 2546 * file reflects the new settings instead of going stale until a content edit.
1316 2547 *
@@ -1319,10 +2550,10 @@
1319 2550 public function schedule_regeneration(): void {
1320 2551 // Debounce against rapid successive saves, but use the settings-specific
1321 2552 // hook so the rebuild runs regardless of the auto_generate toggle (which
1322 2553 // only governs content-change-triggered regeneration).
1323 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap_settings');
1324 - wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap_settings');
2554 + $this->mark_regeneration_pending('settings');
2555 + $this->debounce_event('thinkrank_regenerate_sitemap_settings');
1325 2556 }
1326 2557
1327 2558 /**
1328 2559 * Rebuild the served sitemap after an explicit settings change.
@@ -1331,11 +2562,22 @@
1331 2562 * auto_generate setting: the user deliberately changed inclusion rules and
1332 2563 * expects the served file to reflect them even if content-triggered
1333 2564 * auto-generation is turned off. Still respects the master `enabled` flag.
1334 2565 *
1335 - * @return void
2566 + * @since 2.10.0 Reports whether the served sitemap was actually rebuilt, so
2567 + * a caller can say so rather than assume it (#764). Existing
2568 + * callers that ignore the return are unaffected.
2569 + *
2570 + * @return bool True when the served sitemap now reflects the settings.
1336 2571 */
1337 - public function regenerate_sitemap_from_settings(): void {
2572 + public function regenerate_sitemap_from_settings(): bool {
2573 + if (!$this->acquire_generation_lock()) {
2574 + // A manual generation (or another request's takeover) is already
2575 + // writing the files; the pending marker survives so this rebuild is
2576 + // retried rather than lost.
2577 + return false;
2578 + }
2579 +
1338 2580 try {
1339 2581 $settings = $this->get_settings('site');
1340 2582 if (empty($settings['enabled'])) {
1341 2583 // The sitemap was disabled: remove the previously generated static
@@ -1340,64 +2582,155 @@
1340 2582 if (empty($settings['enabled'])) {
1341 2583 // The sitemap was disabled: remove the previously generated static
1342 2584 // files so the web server stops serving a stale sitemap that
1343 2585 // crawlers would otherwise keep fetching.
1344 - $this->delete_published_sitemaps();
1345 - return;
2586 + //
2587 + // A file that could not be removed is still being served, so
2588 + // this is not a success. Reporting one here would tell a caller
2589 + // the sitemap was gone while the web server kept answering with
2590 + // it, which is the failure this return value exists to prevent
2591 + // (#764).
2592 + $removal = $this->delete_published_sitemaps($settings);
2593 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2594 +
2595 + if (!empty($stuck)) {
2596 + $this->record_regeneration_failure(
2597 + $this->stuck_files_message($stuck, true),
2598 + 'settings'
2599 + );
2600 +
2601 + return false;
2602 + }
2603 +
2604 + $this->mark_regeneration_complete();
2605 +
2606 + return true;
1346 2607 }
1347 - $this->generate_and_save($settings);
2608 +
2609 + $revision = $this->current_regeneration_revision();
2610 +
2611 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2612 + // Returns false when a static file is stuck in the web root:
2613 + // the server keeps serving that file in preference to WordPress,
2614 + // so the switch has not taken effect (#764).
2615 + return $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2616 + }
2617 +
2618 + if ($this->generate_for_regeneration($settings)) {
2619 + $this->mark_regeneration_complete($revision);
2620 +
2621 + return true;
2622 + }
2623 +
2624 + $this->record_regeneration_failure(
2625 + $this->write_failure_message(),
2626 + 'settings'
2627 + );
2628 +
2629 + return false;
1348 2630 } catch (\Throwable $e) {
1349 - // Settings-triggered regeneration failed - details in exception.
2631 + $this->record_thrown_regeneration_failure($e, 'settings');
2632 +
2633 + return false;
2634 + } finally {
2635 + $this->release_generation_lock();
1350 2636 }
1351 2637 }
1352 2638
1353 2639 /**
2640 + * When a rebuild has been outstanding since, or 0 when none is.
2641 + *
2642 + * Lets a caller report an honest "saved, but the served file has not caught
2643 + * up yet" instead of a bare success (#764).
2644 + *
2645 + * @since 2.10.0
2646 + * @return int Unix timestamp, or 0 when nothing is pending.
2647 + */
2648 + public static function regeneration_pending_since(): int {
2649 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2650 +
2651 + if (!is_array($pending) || empty($pending['since'])) {
2652 + return 0;
2653 + }
2654 +
2655 + return (int) $pending['since'];
2656 + }
2657 +
2658 + /**
1354 2659 * Remove every static sitemap file ThinkRank publishes to the web root.
1355 2660 *
1356 - * Called when the sitemap feature is disabled so /sitemap.xml,
1357 - * /sitemap_index.xml, the segmented children (incl. paginated -N pages), and
1358 - * /local-sitemap.xml stop being served. Only ThinkRank's own filenames are
1359 - * targeted; WordPress core's wp-sitemap.xml is left untouched.
2661 + * Called when the sitemap feature is disabled, by the cleanup route, and by
2662 + * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
2663 + * children (incl. paginated -N pages), and /local-sitemap.xml stop being
2664 + * served. Only ThinkRank's own filenames are targeted; WordPress core's
2665 + * wp-sitemap.xml and any other plugin's sitemap in the web root are left
2666 + * untouched.
1360 2667 *
1361 - * @return void
2668 + * @since 1.31.0 Returns the filenames removed, and accepts the settings to
2669 + * derive them from, so a caller that already read them (and
2670 + * needs to report what went) does not have to re-read or
2671 + * re-derive the name list.
2672 + * @since 2.1.0 Delegates to thinkrank_webroot_delete_sitemaps(). Uninstall
2673 + * needs the same removal but has no autoloader to reach this
2674 + * class, so the logic moved to includes/cleanup-webroot.php
2675 + * and this stays as the in-plugin entry point.
2676 + *
2677 + * @param array|null $settings Optional. Sitemap settings; defaults to the
2678 + * saved site settings.
2679 + * @return array{deleted: string[], failed: string[]} Basenames removed, and
2680 + * those that existed but could not be removed.
1362 2681 */
1363 - public function delete_published_sitemaps(): void {
1364 - $settings = $this->get_settings('site');
2682 + public function delete_published_sitemaps(?array $settings = null): array {
2683 + return thinkrank_webroot_delete_sitemaps($settings ?? $this->get_settings('site'));
2684 + }
1365 2685
1366 - // Base filenames to remove. Derive the child/index names from the stored
1367 - // sitemap_urls so a custom custom_url_pattern (e.g. seo-{type}.xml) is
1368 - // honored, not just the default sitemap-*.xml naming. Include the current
1369 - // primary + the default names as a safety net.
1370 - $names = [
1371 - 'sitemap.xml',
1372 - 'sitemap_index.xml',
1373 - 'local-sitemap.xml',
1374 - $this->get_primary_sitemap_filename($settings),
1375 - ];
2686 + /**
2687 + * Every child-sitemap filename this site could have published.
2688 + *
2689 + * @since 1.31.0
2690 + * @since 2.1.0 Delegates to thinkrank_webroot_segment_filenames().
2691 + *
2692 + * @param array $settings Sitemap settings (read for `custom_url_pattern`).
2693 + * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
2694 + */
2695 + private function publishable_segment_filenames(array $settings): array {
2696 + return thinkrank_webroot_segment_filenames($settings);
2697 + }
1376 2698
1377 - foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
1378 - if (empty($config['url'])) {
1379 - continue;
1380 - }
1381 - $name = basename((string) wp_parse_url($config['url'], PHP_URL_PATH));
1382 - if ($name !== '') {
1383 - $names[] = $name;
1384 - }
1385 - }
1386 -
1387 - foreach (array_unique(array_filter($names)) as $name) {
1388 - // Remove the file itself and any paginated -N variants of its stem
1389 - // (e.g. seo-posts.xml plus seo-posts-2.xml, seo-posts-3.xml…).
1390 - $path = ABSPATH . $name;
1391 - if (file_exists($path)) {
1392 - wp_delete_file($path);
1393 - }
1394 - if (preg_match('/^(.*)\.xml$/i', $name, $m)) {
1395 - foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
1396 - wp_delete_file($paged);
1397 - }
1398 - }
1399 - }
2699 + /**
2700 + * Can this web-root file be shown to be a sitemap ThinkRank wrote?
2701 + *
2702 + * The generator deletes as often as cleanup does — a segment that dropped
2703 + * out of the set, a pagination page beyond the new count, the local sitemap
2704 + * after the business identity was cleared — and until 2.1.1 it did all
2705 + * three by filename alone. That is the #515 bug on a far more frequent
2706 + * trigger: our names are the canonical ones, so an ordinary regeneration
2707 + * (post save, term change, settings save) destroyed RankMath's
2708 + * `sitemap-tags.xml` and `local-sitemap.xml` with no deactivation involved.
2709 + *
2710 + * Every name the generator derives comes from the current settings — the
2711 + * url pattern, the configured `sitemap_urls`, `local-sitemap.xml` — but the
2712 + * ownership test is still asked with `$name_derived = false`, which switches
2713 + * off the legacy fallback for the whole generator side.
2714 + *
2715 + * The fallback exists to recover a pre-2.1.1 file written with
2716 + * `enable_styling` off, which carries neither marker. That recovery belongs
2717 + * to the once-off cleanup paths. Here it can only do harm: this method runs
2718 + * on every post save, and everything this version writes carries
2719 + * THINKRANK_SITEMAP_MARKER, so after the site's first regeneration an
2720 + * unmarked file at one of our names is by definition somebody else's — and
2721 + * deleting it on an ordinary regeneration is #515 through the more common
2722 + * door. The cost is a stale unmarked segment left on disk until deactivation
2723 + * picks it up, which is the safe direction to fail in.
2724 + *
2725 + * @since 2.1.1
2726 + *
2727 + * @param string $path Absolute path to a file in the web root.
2728 + * @param array $settings Sitemap settings.
2729 + * @return bool True when the file may be deleted.
2730 + */
2731 + private function webroot_sitemap_is_ours(string $path, array $settings): bool {
2732 + return thinkrank_webroot_sitemap_is_ours($path, $settings, false);
1400 2733 }
1401 2734
1402 2735 /**
1403 2736 * Auto-regenerate sitemap (called by scheduled action)
@@ -1405,20 +2738,48 @@
1405 2738 * @since 1.0.0
1406 2739 * @return void
1407 2740 */
1408 2741 public function auto_regenerate_sitemap(): void {
2742 + if (!$this->acquire_generation_lock()) {
2743 + // A manual generation (or another request's takeover) is already
2744 + // writing the files; the pending marker survives so this rebuild is
2745 + // retried rather than lost.
2746 + return;
2747 + }
2748 +
1409 2749 try {
1410 2750 // Double-check that auto-generation is still enabled
1411 2751 if (!$this->should_auto_generate()) {
2752 + // Nothing outstanding can be delivered while the feature is off,
2753 + // so drop the marker rather than let the takeover retry forever.
2754 + $this->mark_regeneration_complete();
1412 2755 return;
1413 2756 }
1414 2757
1415 - $this->generate_and_save($this->get_settings('site'));
2758 + $revision = $this->current_regeneration_revision();
2759 + $settings = $this->get_settings('site');
1416 2760
1417 - // Sitemap auto-regenerated successfully
2761 + // See regenerate_sitemap_from_settings(): nothing to write.
2762 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2763 + $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2764 + return;
2765 + }
1418 2766
2767 + if ($this->generate_for_regeneration($settings)) {
2768 + $this->mark_regeneration_complete($revision);
2769 + } else {
2770 + // Previously this returned quietly and last_generated simply
2771 + // stopped advancing, leaving the site owner with no way to learn
2772 + // the sitemap had stopped updating (#629).
2773 + $this->record_regeneration_failure(
2774 + $this->write_failure_message(),
2775 + 'content'
2776 + );
2777 + }
1419 2778 } catch (\Throwable $e) {
1420 - // Sitemap auto-regeneration failed - error details available in exception
2779 + $this->record_thrown_regeneration_failure($e, 'content');
2780 + } finally {
2781 + $this->release_generation_lock();
1421 2782 }
1422 2783 }
1423 2784
1424 2785 /**
@@ -1433,8 +2794,26 @@
1433 2794 * @param array $settings Sitemap settings.
1434 2795 * @return bool True when the sitemap files were written.
1435 2796 */
1436 2797 public function generate_and_save(array $settings): bool {
2798 + // Dynamic delivery publishes no files, so writing them here would put a
2799 + // static copy back in the web root for the server to serve in place of
2800 + // the dynamic route. Guarding at each call site left gaps — the
2801 + // snapshot migrator's post-import regeneration had none — so the rule
2802 + // lives with the writing instead.
2803 + //
2804 + // `is_collecting()` is the exception that makes dynamic delivery work
2805 + // at all: render_document() and collect_documents() reach this same
2806 + // method with the writer swapped for a collector, and that is precisely
2807 + // the dynamic build. Only a real write is skipped.
2808 + if (!$this->is_collecting() && 'dynamic' === $this->resolve_delivery_mode($settings)) {
2809 + // Whatever prompted this call changed the sitemap's content, so the
2810 + // rendered copies must not outlive it.
2811 + $this->flush_dynamic_cache();
2812 +
2813 + return true;
2814 + }
2815 +
1437 2816 // Index mode is driven by the use_sitemap_index toggle (not merely by how
1438 2817 // many sitemap_urls happen to be configured). When the toggle is on but
1439 2818 // no child sitemaps are set up yet, synthesize the per-type segmented set
1440 2819 // so we emit a real <sitemapindex> with paginated children instead of a
@@ -1445,16 +2824,29 @@
1445 2824 $results = $this->generate_multiple_sitemaps($settings);
1446 2825 $written = !empty($results['success']);
1447 2826 } else {
1448 2827 $xml = $this->generate_sitemap($settings);
1449 - $written = $this->save_sitemap_to_file($xml, $this->get_primary_sitemap_filename($settings));
2828 + $primary = $this->get_primary_sitemap_filename($settings);
2829 + $written = $this->save_sitemap_to_file($xml, $primary);
1450 2830
1451 2831 // Local business sitemap is a standalone file, regenerated on the
1452 2832 // single-sitemap path too (this is the default mode).
1453 2833 $this->regenerate_local_sitemap($settings);
2834 +
2835 + // Switching out of index mode leaves sitemap_index.xml and every
2836 + // child on disk, still served and never refreshed again. The index
2837 + // path already prunes what it no longer owns; this path never did,
2838 + // so the site kept serving two sitemap trees (#563). Ownership is
2839 + // still tested per file, so another plugin's sitemap at one of our
2840 + // names is never touched (#515).
2841 + $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
1454 2842 }
1455 2843
1456 - if ($written) {
2844 + // last_generated describes what is on disk. A dynamic render publishes
2845 + // nothing, so advancing it would report a static publication that never
2846 + // happened and would let primary_sitemap_file_exists() callers believe
2847 + // there is a file to serve.
2848 + if ($written && !$this->is_collecting()) {
1457 2849 $settings['last_generated'] = gmdate('c');
1458 2850 $this->save_settings('site', null, $settings);
1459 2851 }
1460 2852
@@ -1461,8 +2853,433 @@
1461 2853 return $written;
1462 2854 }
1463 2855
1464 2856 /**
2857 + * Whether this instance is rendering documents rather than publishing them.
2858 + *
2859 + * @since 2.9.0
2860 + *
2861 + * @return bool
2862 + */
2863 + private function is_collecting(): bool {
2864 + return $this->document_sink !== null;
2865 + }
2866 +
2867 + /**
2868 + * Complete a regeneration that delivers dynamically, retiring stale files.
2869 + *
2870 + * Dynamic delivery renders nothing to disk, but that is only half the job.
2871 + * A web server hands back an existing `/sitemap.xml` without ever loading
2872 + * WordPress, so any file left over from a previous static generation goes on
2873 + * being served forever and {@see \ThinkRank\Frontend\SEO_Manager
2874 + * ::maybe_serve_sitemap()} is never reached. Switching to dynamic while
2875 + * leaving those files in place would therefore appear to do nothing at all.
2876 + *
2877 + * Both transitions matter and they differ:
2878 + *
2879 + * - An explicit switch to `dynamic` happens on a site whose root is usually
2880 + * still writable, so the files can simply be removed.
2881 + * - An `auto` site that becomes read-only cannot remove them, because
2882 + * deleting an entry needs write permission on the directory that holds
2883 + * it. There the stale sitemap really is stuck in front of us, and the
2884 + * honest outcome is a recorded failure naming it rather than a rebuild
2885 + * reported as complete (#754 review).
2886 + *
2887 + * Ownership is tested per file by the shared helper, so another plugin's
2888 + * sitemap at one of our names is never deleted (#515).
2889 + *
2890 + * @since 2.9.0
2891 + *
2892 + * @param array $settings Sitemap settings.
2893 + * @param int $revision Revision this rebuild is completing.
2894 + * @param string $source 'settings' or 'content', for the failure record.
2895 + * @return void
2896 + */
2897 + private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): bool {
2898 + $this->flush_dynamic_cache();
2899 +
2900 + $removal = $this->delete_published_sitemaps($settings);
2901 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2902 +
2903 + if (!empty($stuck)) {
2904 + $this->record_regeneration_failure($this->stuck_files_message($stuck), $source);
2905 +
2906 + return false;
2907 + }
2908 +
2909 + $this->mark_regeneration_complete($revision);
2910 +
2911 + return true;
2912 + }
2913 +
2914 + /**
2915 + * Why a stale file left in the web root means the change has not landed.
2916 + *
2917 + * Shared by every path that removes published files, so they cannot
2918 + * describe the same situation differently (#764).
2919 + *
2920 + * The two situations that reach it differ in what WordPress is doing, and
2921 + * the message has to say which. After a switch to dynamic delivery
2922 + * WordPress IS serving the sitemap and the files shadow it. After the
2923 + * sitemap is switched off WordPress serves nothing, so the one message
2924 + * used to tell a site owner who had just disabled the sitemap that it was
2925 + * "being served from WordPress", which is the opposite of what they did.
2926 + *
2927 + * @since 2.10.0
2928 + * @since 2.10.0 Public, so the REST endpoint uses it rather than a copy;
2929 + * takes $sitemap_disabled for the disabled path.
2930 + *
2931 + * @param string[] $stuck Basenames that could not be removed.
2932 + * @param bool $sitemap_disabled True when the files outlived disabling
2933 + * the sitemap rather than a switch to
2934 + * dynamic delivery.
2935 + * @return string
2936 + */
2937 + public function stuck_files_message(array $stuck, bool $sitemap_disabled = false): string {
2938 + if ($sitemap_disabled) {
2939 + return sprintf(
2940 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2941 + __('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'),
2942 + implode(', ', $stuck),
2943 + untrailingslashit(ABSPATH)
2944 + );
2945 + }
2946 +
2947 + return sprintf(
2948 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2949 + __('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'),
2950 + implode(', ', $stuck),
2951 + untrailingslashit(ABSPATH)
2952 + );
2953 + }
2954 +
2955 + /**
2956 + * What to tell the site owner when publishing the files failed.
2957 + *
2958 + * The old wording stated the symptom and stopped there, so the reported
2959 + * cause was a guess and this reached support as a plugin fault rather than
2960 + * a folder permission (#752, #753). When the root is demonstrably
2961 + * unwritable, say that, and say what to do about it.
2962 + *
2963 + * @since 2.9.0
2964 + *
2965 + * @return string
2966 + */
2967 + private function write_failure_message(): string {
2968 + if (!wp_is_writable(ABSPATH)) {
2969 + return sprintf(
2970 + /* translators: %s: absolute path to the WordPress root. */
2971 + __('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'),
2972 + untrailingslashit(ABSPATH)
2973 + );
2974 + }
2975 +
2976 + return __('The sitemap files could not be written to the site root.', 'thinkrank');
2977 + }
2978 +
2979 + /**
2980 + * How this site delivers its sitemap.
2981 + *
2982 + * `auto` is resolved on whether the web root can be written. That is the
2983 + * right signal here (unlike llms.txt, where the question is whether the
2984 + * server applies the .htaccess charset block): a site whose root is
2985 + * read-only cannot publish a sitemap file at all, and before this existed
2986 + * the feature simply failed with "The sitemap files could not be written to
2987 + * the site root." and served nothing (#752).
2988 + *
2989 + * @since 2.9.0
2990 + *
2991 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2992 + * @return string One of 'static' or 'dynamic'. Never 'auto'.
2993 + */
2994 + public function resolve_delivery_mode(?array $settings = null): string {
2995 + $settings = $settings ?? $this->get_settings('site');
2996 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
2997 +
2998 + if ('static' === $mode || 'dynamic' === $mode) {
2999 + return $mode;
3000 + }
3001 +
3002 + return wp_is_writable(ABSPATH) ? 'static' : 'dynamic';
3003 + }
3004 +
3005 + /**
3006 + * Render one published sitemap document without touching the filesystem.
3007 + *
3008 + * Runs the ordinary build pipeline with the writer swapped for a collector,
3009 + * so the bytes returned here are the bytes the static path would have
3010 + * written. `SitemapDeliveryParityTest` asserts that equivalence rather than
3011 + * trusting it.
3012 + *
3013 + * The whole set is built to answer for one file, because the index can only
3014 + * be assembled from the children that were actually produced. The result is
3015 + * cached per document, so that cost is paid once per change and not once
3016 + * per crawler request.
3017 + *
3018 + * @since 2.9.0
3019 + *
3020 + * @param string $filename Published file name, e.g. 'sitemap.xml'.
3021 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3022 + * @return string|null XML, or null when this site does not publish that name.
3023 + */
3024 + public function render_document(string $filename, ?array $settings = null): ?string {
3025 + $filename = basename($filename);
3026 + $settings = $settings ?? $this->get_settings('site');
3027 +
3028 + if (empty($settings['enabled'])) {
3029 + return null;
3030 + }
3031 +
3032 + $cached = get_transient($this->dynamic_cache_key($filename));
3033 + if (self::ABSENT_MARKER === $cached) {
3034 + return null;
3035 + }
3036 + if (is_string($cached) && '' !== $cached) {
3037 + return $cached;
3038 + }
3039 +
3040 + // A miss builds the whole set, because the index can only be assembled
3041 + // from the children that were actually produced. Caching only the
3042 + // requested document therefore made a crawler walking the index and its
3043 + // children rebuild the entire site's sitemap once per file — every post
3044 + // and taxonomy query repeated N times on a public endpoint (#754
3045 + // review). The set is built once and stored in full.
3046 + return $this->stream_documents($settings, $filename);
3047 + }
3048 +
3049 + /**
3050 + * Build every document, caching each as it is produced, keeping one.
3051 + *
3052 + * A miss has to build the whole set, because the index can only be
3053 + * assembled from the children that were actually produced. It does not have
3054 + * to *hold* the whole set: the static path never keeps more than one page
3055 + * in memory, writing each to disk as it goes, and buffering every
3056 + * document's XML to return one of them undid that on the request path,
3057 + * where a large site's entire sitemap corpus would sit in a single PHP
3058 + * process (#754 review).
3059 + *
3060 + * So the sink writes each document straight to its cache entry and lets it
3061 + * go, retaining only the one this request is answering. Peak retention is
3062 + * one document, whatever the site's size.
3063 + *
3064 + * Concurrency: the first request through takes a short lock and does the
3065 + * work. One that finds the lock held waits a bounded moment for the winner
3066 + * to publish, then builds anyway, because serving a correct sitemap late
3067 + * beats serving none.
3068 + *
3069 + * @since 2.9.0
3070 + *
3071 + * @param array $settings Sitemap settings.
3072 + * @param string $wanted Document this request is answering.
3073 + * @return string|null XML for $wanted, or null when the site does not publish it.
3074 + */
3075 + private function stream_documents(array $settings, string $wanted): ?string {
3076 + $lock = self::DYNAMIC_CACHE_PREFIX . 'lock';
3077 +
3078 + if (!$this->acquire_render_lock($lock)) {
3079 + for ($attempt = 0; $attempt < self::RENDER_LOCK_WAIT_ATTEMPTS; $attempt++) {
3080 + usleep(self::RENDER_LOCK_WAIT_MICROSECONDS);
3081 +
3082 + $cached = get_transient($this->dynamic_cache_key($wanted));
3083 + if (self::ABSENT_MARKER === $cached) {
3084 + return null;
3085 + }
3086 + if (is_string($cached) && '' !== $cached) {
3087 + return $cached;
3088 + }
3089 + }
3090 + }
3091 +
3092 + $kept = null;
3093 + // Names only. Keeping the bodies here would be the very retention this
3094 + // method exists to avoid.
3095 + $produced = [];
3096 +
3097 + $previous = $this->document_sink;
3098 + $this->document_sink = function (string $name, string $xml) use (&$kept, &$produced, $wanted): void {
3099 + $produced[$name] = true;
3100 + set_transient($this->dynamic_cache_key($name), $xml, self::DYNAMIC_CACHE_TTL);
3101 +
3102 + if ($name === $wanted) {
3103 + $kept = $xml;
3104 + }
3105 + };
3106 +
3107 + try {
3108 + $this->generate_and_save($settings);
3109 +
3110 + // Names the configuration lists but this build did not produce get
3111 + // a negative entry, so asking for one again is a cache hit rather
3112 + // than another full rebuild.
3113 + $absent = $this->published_document_names($settings);
3114 +
3115 + // Also the exact name this request asked for: a paginated page past
3116 + // the end of a stem is a legitimate request shape that the base
3117 + // list cannot enumerate, and without an entry it would rebuild on
3118 + // every hit.
3119 + $absent[] = $wanted;
3120 +
3121 + foreach (array_unique($absent) as $name) {
3122 + if (!isset($produced[$name])) {
3123 + set_transient($this->dynamic_cache_key($name), self::ABSENT_MARKER, self::DYNAMIC_CACHE_TTL);
3124 + }
3125 + }
3126 + } finally {
3127 + $this->document_sink = $previous;
3128 + delete_transient($lock);
3129 + }
3130 +
3131 + return $kept;
3132 + }
3133 +
3134 + /**
3135 + * Take the render lock, if it is free.
3136 + *
3137 + * Not atomic across processes, and deliberately so: the fallback for losing
3138 + * a race is duplicated work, never a wrong or missing sitemap, so a
3139 + * heavier primitive would buy nothing here.
3140 + *
3141 + * @since 2.9.0
3142 + *
3143 + * @param string $lock Lock transient name.
3144 + * @return bool True when this request holds the lock.
3145 + */
3146 + private function acquire_render_lock(string $lock): bool {
3147 + if (false !== get_transient($lock)) {
3148 + return false;
3149 + }
3150 +
3151 + set_transient($lock, time(), self::RENDER_LOCK_TTL);
3152 +
3153 + return true;
3154 + }
3155 +
3156 + /**
3157 + * Build every document this site publishes and return them all.
3158 + *
3159 + * Verification and tooling only. This retains the whole set in memory, so
3160 + * it must never be used to answer a request: {@see self::stream_documents()}
3161 + * is the serving path and keeps one document at a time regardless of site
3162 + * size (#754 review). `SitemapDeliveryParityTest` enforces that separation
3163 + * by failing if the request path routes back through here.
3164 + *
3165 + * @since 2.9.0
3166 + *
3167 + * @param array $settings Sitemap settings.
3168 + * @return array<string,string> Filename => XML.
3169 + */
3170 + public function collect_documents(array $settings): array {
3171 + $documents = [];
3172 +
3173 + $previous = $this->document_sink;
3174 + $this->document_sink = static function (string $name, string $xml) use (&$documents): void {
3175 + $documents[$name] = $xml;
3176 + };
3177 +
3178 + try {
3179 + $this->generate_and_save($settings);
3180 + } finally {
3181 + $this->document_sink = $previous;
3182 + }
3183 +
3184 + return $documents;
3185 + }
3186 +
3187 + /**
3188 + * The file names this site publishes, without building their contents.
3189 + *
3190 + * Used by the request router to decide whether a URL is ours before doing
3191 + * any work. Cheap: it reads the configured child list rather than querying
3192 + * for entries.
3193 + *
3194 + * @since 2.9.0
3195 + *
3196 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3197 + * @return string[] File names, including paginated pages that may exist.
3198 + */
3199 + public function published_document_names(?array $settings = null): array {
3200 + $settings = $settings ?? $this->get_settings('site');
3201 + $resolved = $this->maybe_promote_to_index($settings);
3202 +
3203 + $names = [$this->get_primary_sitemap_filename($settings), 'local-sitemap.xml'];
3204 +
3205 + foreach ((array) ($resolved['sitemap_urls'] ?? []) as $child) {
3206 + if (!is_array($child) || empty($child['enabled'])) {
3207 + continue;
3208 + }
3209 +
3210 + $path = (string) wp_parse_url((string) ($child['url'] ?? ''), PHP_URL_PATH);
3211 + if ('' !== $path) {
3212 + $names[] = basename($path);
3213 + }
3214 + }
3215 +
3216 + return array_values(array_unique(array_filter($names)));
3217 + }
3218 +
3219 + /**
3220 + * Does this site publish a document under that name?
3221 + *
3222 + * Not a plain membership test against {@see self::published_document_names()}:
3223 + * that lists the configured children, and a child over the per-file URL cap
3224 + * is split into `<stem>-2.xml`, `<stem>-3.xml` and so on, with every page
3225 + * listed in the index. Gating the request router on the base list alone
3226 + * therefore 404'd exactly the pages the index points at, which is worse than
3227 + * not serving them at all.
3228 + *
3229 + * Page counts are not knowable without building, so the stem is what is
3230 + * matched; a page that does not exist is answered by the build finding
3231 + * nothing for it, and is then cached as absent.
3232 + *
3233 + * @since 2.9.0
3234 + *
3235 + * @param string $name Requested file name.
3236 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3237 + * @return bool
3238 + */
3239 + public function publishes_document_name(string $name, ?array $settings = null): bool {
3240 + $names = $this->published_document_names($settings);
3241 +
3242 + if (in_array($name, $names, true)) {
3243 + return true;
3244 + }
3245 +
3246 + if (!preg_match('/^(.*)-\d+\.xml$/i', $name, $m)) {
3247 + return false;
3248 + }
3249 +
3250 + return in_array($m[1] . '.xml', $names, true);
3251 + }
3252 +
3253 + /**
3254 + * Transient key for a rendered document.
3255 + *
3256 + * @since 2.9.0
3257 + *
3258 + * @param string $filename Published file name.
3259 + * @return string
3260 + */
3261 + private function dynamic_cache_key(string $filename): string {
3262 + return self::DYNAMIC_CACHE_PREFIX . md5($filename);
3263 + }
3264 +
3265 + /**
3266 + * Drop every cached dynamic document.
3267 + *
3268 + * Called from the same places that mark the static files stale, so the two
3269 + * delivery modes invalidate on identical triggers.
3270 + *
3271 + * @since 2.9.0
3272 + *
3273 + * @return void
3274 + */
3275 + public function flush_dynamic_cache(): void {
3276 + foreach ($this->published_document_names() as $name) {
3277 + delete_transient($this->dynamic_cache_key($name));
3278 + }
3279 + }
3280 +
3281 + /**
1465 3282 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
1466 3283 *
1467 3284 * - When use_sitemap_index is on but no child sitemaps are configured, build
1468 3285 * the per-type segmented set so a real <sitemapindex> is produced (#127).
@@ -1473,15 +3290,90 @@
1473 3290 * @param array $settings Sitemap settings.
1474 3291 * @return array Possibly-updated settings.
1475 3292 */
1476 3293 public function maybe_promote_to_index(array $settings): array {
1477 - $has_children = count((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : [])) > 1;
3294 + $saved = $this->get_settings('site');
1478 3295
3296 + // Read from the *payload*, before the merge below folds the saved values
3297 + // in: "the caller named this" and "this has a value" are different
3298 + // questions, and the mode resolution turns on the former.
3299 + $mode_supplied = array_key_exists('use_sitemap_index', $settings);
3300 + $urls_supplied = is_array($settings['sitemap_urls'] ?? null);
3301 + $has_children = count($urls_supplied ? $settings['sitemap_urls'] : []) > 1;
3302 +
3303 + // Which inclusion flags did the caller actually name? The child list is
3304 + // the only thing that reads them, and inheriting a saved one skipped
3305 + // that — so on an index-mode site the include_* flags were enforced
3306 + // nowhere but in the browser, where SitemapGeneration.js recomputes
3307 + // sitemap_urls itself. Every non-UI client, the shipped
3308 + // `update-sitemap-settings` ability included, saved the flag and changed
3309 + // nothing (#398). Read from the payload for the same reason as above:
3310 + // after the merge every saved flag would look like one the caller named.
3311 + $named_inclusions = array_intersect(
3312 + array_keys(self::INCLUSION_CHILD_TYPES),
3313 + array_keys($settings)
3314 + );
3315 + $inclusions_supplied = (bool) $named_inclusions;
3316 +
1479 3317 // Inclusion flags may be absent from a partial payload (e.g. the manual
1480 3318 // generate endpoint) — fall back to saved settings so synthesized child
1481 3319 // sitemaps reflect the real include_posts/pages/categories choices.
1482 - $inclusions = array_merge($this->get_settings('site'), $settings);
3320 + $inclusions = array_merge($saved, $settings);
1483 3321
3322 + // Hand the generators a *complete* settings array. Only the mode was
3323 + // resolved before, so every other unnamed key reached them missing: a
3324 + // bare `{}` from a REST/MCP client republished the sitemap with
3325 + // enable_styling and include_images read as off, overwriting the live
3326 + // files with output that had lost its XSL stylesheet, its image
3327 + // namespace and its image entries. Presentation and inclusion settings
3328 + // are not something a generate call opts into — they are the site's
3329 + // configuration, and only a value actually present in the payload
3330 + // overrides them.
3331 + $settings = $inclusions;
3332 +
3333 + // The two keys that drive mode keep their own resolution rules below,
3334 + // so they must go back to "not specified" when the caller omitted them.
3335 + if (!$mode_supplied) {
3336 + unset($settings['use_sitemap_index']);
3337 + }
3338 + if (!$urls_supplied) {
3339 + unset($settings['sitemap_urls']);
3340 + }
3341 +
3342 + // An absent use_sitemap_index means "not specified", which is not the
3343 + // same as "single file". Reading it as the latter meant a partial payload
3344 + // — `{}` from a REST/MCP client, or anything short of the full settings
3345 + // object the admin bundle sends — republished one flat sitemap.xml on an
3346 + // index-mode site and left sitemap_index.xml and its children stale or
3347 + // missing. Inherit the saved mode instead; only a value actually present
3348 + // in the payload decides the mode.
3349 + if (!array_key_exists('use_sitemap_index', $settings)) {
3350 + $settings['use_sitemap_index'] = $saved['use_sitemap_index'] ?? '';
3351 +
3352 + // Inheriting the mode means inheriting its children too, unless the
3353 + // caller named its own set.
3354 + $saved_children = (is_array($saved['sitemap_urls'] ?? null) ? $saved['sitemap_urls'] : []);
3355 + if (!empty($settings['use_sitemap_index'])
3356 + && !$urls_supplied
3357 + && count($saved_children) > 1) {
3358 + // A named inclusion flag is applied *to* the inherited list, not
3359 + // used to regenerate it. build_segmented_sitemap_urls() also adds
3360 + // a child for every public custom post type, so rebuilding here
3361 + // would make `{include_pages: false}` — one thing off — silently
3362 + // switch on children the saved list never had (an Elementor
3363 + // internal CPT, a WooCommerce product feed). Only the flags the
3364 + // caller actually named change anything.
3365 + $settings['sitemap_urls'] = $inclusions_supplied
3366 + ? $this->apply_inclusion_flags_to_children($saved_children, $named_inclusions, $inclusions)
3367 + : $saved_children;
3368 +
3369 + // The children are resolved either way — including when the
3370 + // caller switched the last one off, which leaves a bare index and
3371 + // is what they asked for.
3372 + $has_children = true;
3373 + }
3374 + }
3375 +
1484 3376 if (!empty($settings['use_sitemap_index'])) {
1485 3377 if (!$has_children) {
1486 3378 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
1487 3379 }
@@ -1538,26 +3430,9 @@
1538 3430 * @param array $settings Sitemap settings.
1539 3431 * @return string Sitemap filename.
1540 3432 */
1541 3433 public function get_primary_sitemap_filename(array $settings): string {
1542 - $use_index = !empty($settings['use_sitemap_index']);
1543 -
1544 - foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
1545 - if (empty($config['enabled']) || empty($config['url'])) {
1546 - continue;
1547 - }
1548 -
1549 - if ($use_index !== (($config['type'] ?? '') === 'index')) {
1550 - continue;
1551 - }
1552 -
1553 - $path = wp_parse_url($config['url'], PHP_URL_PATH);
1554 - if (!empty($path)) {
1555 - return basename($path);
1556 - }
1557 - }
1558 -
1559 - return $use_index ? 'sitemap_index.xml' : 'sitemap.xml';
3434 + return thinkrank_webroot_primary_sitemap_filename($settings);
1560 3435 }
1561 3436
1562 3437 /**
1563 3438 * Public URL of the sitemap the site serves.
@@ -1599,8 +3474,18 @@
1599 3474 // File validation failed - error details available in exception
1600 3475 return false;
1601 3476 }
1602 3477
3478 + // Dynamic delivery: hand the document to the collector instead of the
3479 + // filesystem. Reported as published, because for this run it is — the
3480 + // caller's success/failure bookkeeping and the index assembly both key
3481 + // off this return value.
3482 + if ($this->document_sink !== null) {
3483 + ($this->document_sink)($filename, $sitemap_xml);
3484 +
3485 + return true;
3486 + }
3487 +
1603 3488 $sitemap_path = ABSPATH . $filename;
1604 3489
1605 3490 // Use WordPress filesystem API for better security
1606 3491 global $wp_filesystem;
@@ -1609,9 +3494,22 @@
1609 3494 WP_Filesystem();
1610 3495 }
1611 3496
1612 3497 if ($wp_filesystem) {
1613 - return $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3498 + $written = $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3499 +
3500 + if ($written) {
3501 + // Every sitemap this version writes carries the ownership
3502 + // marker, so once one has been written an unmarked file at one
3503 + // of our names cannot be ours. Recording that retires the
3504 + // legacy fallback for this install — see
3505 + // thinkrank_webroot_sitemap_is_ours().
3506 + if (get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1') {
3507 + update_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION, '1', false);
3508 + }
3509 + }
3510 +
3511 + return $written;
1614 3512 }
1615 3513
1616 3514 // WP_Filesystem initialization failed
1617 3515 return false;
@@ -1634,52 +3532,164 @@
1634 3532 */
1635 3533 public function build_segmented_sitemap_urls(array $inclusions): array {
1636 3534 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
1637 3535
1638 - $entry = static function (string $url, string $type): array {
1639 - return [
1640 - 'url' => $url,
1641 - 'type' => $type,
1642 - 'enabled' => true,
1643 - 'last_checked' => null,
1644 - 'status' => 'unknown',
1645 - ];
1646 - };
1647 - $child = static function (string $type) use ($pattern, $entry): array {
1648 - $file = str_replace('{type}', $type, $pattern);
1649 - if (strpos($file, '/') !== 0) {
1650 - $file = '/' . $file;
3536 + $urls = [$this->sitemap_child_entry('/sitemap_index.xml', 'index')];
3537 +
3538 + foreach (self::INCLUSION_CHILD_TYPES as $flag => $type) {
3539 + if (!empty($inclusions[$flag])) {
3540 + $urls[] = $this->build_child_sitemap_entry($type, $pattern);
1651 3541 }
1652 - return $entry($file, $type);
1653 - };
1654 -
1655 - $urls = [$entry('/sitemap_index.xml', 'index')];
1656 -
1657 - if (!empty($inclusions['include_posts'])) {
1658 - $urls[] = $child('posts');
1659 3542 }
1660 - if (!empty($inclusions['include_pages'])) {
1661 - $urls[] = $child('pages');
1662 - }
1663 - if (!empty($inclusions['include_categories'])) {
1664 - $urls[] = $child('categories');
1665 - }
1666 - if (!empty($inclusions['include_tags'])) {
1667 - $urls[] = $child('tags');
1668 - }
1669 3543
1670 3544 // Public custom post types each get a child sitemap (parity with the
1671 3545 // "complete" preset and with Rank Math, which lists every public CPT).
1672 3546 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
1673 - if ($this->should_include_post_type($cpt)) {
1674 - $urls[] = $child($cpt);
3547 + if (!$this->should_include_post_type($cpt)) {
3548 + continue;
1675 3549 }
3550 +
3551 + // ...and the per-content-type sitemap switch (#660).
3552 + // get_enabled_post_types() already honours it, but this list is what
3553 + // index mode builds its children from — so without the same test a
3554 + // CPT the user had switched off still got its own child sitemap,
3555 + // created and streamed in full. The flags live in $inclusions, which
3556 + // is the settings array these children are derived from.
3557 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $cpt, $inclusions)) {
3558 + continue;
3559 + }
3560 +
3561 + $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
1676 3562 }
1677 3563
3564 + // Public custom taxonomies get the same treatment (#690). Flat mode has
3565 + // always walked them through get_enabled_taxonomies(); index mode built
3566 + // its children from the list above and never consulted a taxonomy at
3567 + // all, so every custom-taxonomy archive silently vanished from the
3568 + // sitemap the moment a site switched modes — and the per-taxonomy switch
3569 + // the matrix writes had nothing to act on. Same two tests the post-type
3570 + // walk applies, in the same order.
3571 + $taken = array_column($urls, 'type');
3572 +
3573 + foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
3574 + if (!$this->should_include_taxonomy($taxonomy)) {
3575 + continue;
3576 + }
3577 +
3578 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $inclusions)) {
3579 + continue;
3580 + }
3581 +
3582 + // Post types and taxonomies are separate registries, so a site can
3583 + // hold both a `foo` post type and a `foo` taxonomy. They would
3584 + // resolve to one filename, and stream_type_entries() answers post
3585 + // types first, so the second child would list the first one's file
3586 + // twice in the index rather than adding anything.
3587 + if (in_array($taxonomy, $taken, true)) {
3588 + continue;
3589 + }
3590 +
3591 + $urls[] = $this->build_child_sitemap_entry($taxonomy, $pattern);
3592 + }
3593 +
1678 3594 return $urls;
1679 3595 }
1680 3596
1681 3597 /**
3598 + * Apply only the inclusion flags the caller named to an existing child list.
3599 + *
3600 + * The narrow counterpart to build_segmented_sitemap_urls(): that one
3601 + * regenerates the whole set from scratch, which is right when there is no set
3602 + * yet and wrong when there is. Rebuilding an existing list would add a child
3603 + * for every public custom post type it had never contained, so a payload that
3604 + * switches one thing off would switch others on. Here a flag adds or removes
3605 + * exactly its own child and leaves every other entry — custom post types,
3606 + * hand-added URLs, per-child enabled/status state — untouched (#398).
3607 + *
3608 + * @since 1.31.0
3609 + *
3610 + * @param array $children Existing child sitemap entries.
3611 + * @param string[] $named_inclusions Inclusion flag keys present in the payload.
3612 + * @param array $inclusions Merged settings, for the resolved flag
3613 + * values and custom_url_pattern.
3614 + * @return array Updated child sitemap entries.
3615 + */
3616 + private function apply_inclusion_flags_to_children(
3617 + array $children,
3618 + array $named_inclusions,
3619 + array $inclusions
3620 + ): array {
3621 + $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
3622 +
3623 + foreach ($named_inclusions as $flag) {
3624 + $type = self::INCLUSION_CHILD_TYPES[$flag];
3625 +
3626 + $present = false;
3627 + foreach ($children as $entry) {
3628 + if (($entry['type'] ?? '') === $type) {
3629 + $present = true;
3630 + break;
3631 + }
3632 + }
3633 +
3634 + if (empty($inclusions[$flag])) {
3635 + if ($present) {
3636 + $children = array_values(array_filter(
3637 + $children,
3638 + static function ($entry) use ($type): bool {
3639 + return (is_array($entry) ? ($entry['type'] ?? '') : '') !== $type;
3640 + }
3641 + ));
3642 + }
3643 + continue;
3644 + }
3645 +
3646 + if (!$present) {
3647 + $children[] = $this->build_child_sitemap_entry($type, $pattern);
3648 + }
3649 + }
3650 +
3651 + return $children;
3652 + }
3653 +
3654 + /**
3655 + * Build one child sitemap entry, resolving its filename from the url pattern.
3656 + *
3657 + * @since 1.31.0
3658 + *
3659 + * @param string $type Child sitemap type (posts, pages, a post type name).
3660 + * @param string $pattern Filename pattern containing {type}.
3661 + * @return array Sitemap URL config.
3662 + */
3663 + private function build_child_sitemap_entry(string $type, string $pattern): array {
3664 + $file = str_replace('{type}', $type, $pattern);
3665 + if (strpos($file, '/') !== 0) {
3666 + $file = '/' . $file;
3667 + }
3668 +
3669 + return $this->sitemap_child_entry($file, $type);
3670 + }
3671 +
3672 + /**
3673 + * The shape generate_multiple_sitemaps() expects of a sitemap_urls entry.
3674 + *
3675 + * @since 1.31.0
3676 + *
3677 + * @param string $url Sitemap path.
3678 + * @param string $type Entry type.
3679 + * @return array Sitemap URL config.
3680 + */
3681 + private function sitemap_child_entry(string $url, string $type): array {
3682 + return [
3683 + 'url' => $url,
3684 + 'type' => $type,
3685 + 'enabled' => true,
3686 + 'last_checked' => null,
3687 + 'status' => 'unknown',
3688 + ];
3689 + }
3690 +
3691 + /**
1682 3692 * Generate multiple sitemaps based on settings
1683 3693 *
1684 3694 * @since 1.0.0
1685 3695 * @param array $settings Sitemap settings
@@ -1728,13 +3738,53 @@
1728 3738 if (post_type_exists($type) && !$this->should_include_post_type($type)) {
1729 3739 continue;
1730 3740 }
1731 3741
3742 + // Same for the matrix switch: a child list saved before the user
3743 + // excluded this content type still names it, and regenerating from
3744 + // that list would rewrite the file they asked not to have. Built-in
3745 + // aggregates ('posts', 'pages', ...) are not post type names, so
3746 + // post_type_exists() keeps this to real custom post types.
3747 + if (post_type_exists($type)
3748 + && !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $type, $settings)) {
3749 + continue;
3750 + }
3751 +
3752 + // The taxonomy counterpart of the two guards above (#690). A child
3753 + // list saved while a taxonomy was still included keeps naming it, so
3754 + // without this, excluding one in the matrix would still rewrite and
3755 + // re-list the file the user asked not to have. The built-in
3756 + // aggregates are named 'categories'/'tags' rather than
3757 + // 'category'/'post_tag', so taxonomy_exists() leaves them to the
3758 + // inclusion-flag check below.
3759 + $child_taxonomy = self::CHILD_TYPE_ALIASES[$type] ?? $type;
3760 + if (taxonomy_exists($child_taxonomy)
3761 + && (!$this->should_include_taxonomy($child_taxonomy)
3762 + || !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $child_taxonomy, $settings))) {
3763 + continue;
3764 + }
3765 +
3766 + // The built-in aggregates carry their switch in the inclusion flag
3767 + // rather than under a post type name, and they are the four most
3768 + // people actually use. build_segmented_sitemap_urls() drops a child
3769 + // whose flag is empty; regenerating from a list saved while it was
3770 + // still on has to make the same decision, or turning Posts, Pages,
3771 + // Categories or Tags off in the matrix rewrites and re-lists the
3772 + // very file it was asked to remove.
3773 + // An absent flag means "not configured", which every other reader
3774 + // treats as included; only a flag that is present and off excludes.
3775 + $aggregate_flag = array_search($type, self::INCLUSION_CHILD_TYPES, true);
3776 + if ($aggregate_flag !== false
3777 + && array_key_exists($aggregate_flag, $settings)
3778 + && empty($settings[$aggregate_flag])) {
3779 + continue;
3780 + }
3781 +
1732 3782 try {
1733 - // The single "general"/"wordpress" sitemap is one un-paginated file
3783 + // The single "general"/"WordPress" sitemap is one un-paginated file
1734 3784 // (there is no index to reference extra pages); it no longer drops
1735 3785 // overflow URLs.
1736 - if ($type === 'general' || $type === 'wordpress') {
3786 + if ($type === 'general' || $type === 'wordpress') { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- lowercase on purpose: this is the stored type slug.
1737 3787 $xml = $this->generate_sitemap($settings);
1738 3788 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
1739 3789 continue;
1740 3790 }
@@ -1765,18 +3815,36 @@
1765 3815 $buffer = [];
1766 3816 }
1767 3817 }
1768 3818
1769 - // Flush the trailing partial page, or a single empty page when the
1770 - // type had no entries at all (parity with the previous behavior of
1771 - // always writing at least one page per configured child).
1772 - if (!empty($buffer) || $page === 0) {
3819 + // Flush the trailing partial page.
3820 + //
3821 + // A type that produced nothing writes no page at all in index
3822 + // mode (#836). It used to write one empty urlset and list it in
3823 + // the index, so a crawler was asked to fetch a file that
3824 + // answers with no URLs — Search Console reports an empty
3825 + // sitemap referenced from an index as a warning, and the fetch
3826 + // is wasted on every pass. On this site three of eleven
3827 + // children were empty: a post type with nothing published and
3828 + // two taxonomies with no terms.
3829 + //
3830 + // Single-file mode still writes its one page even when empty,
3831 + // because that file IS the site's /sitemap.xml and a 404 there
3832 + // is worse than an empty urlset. Nothing lists it, so it costs
3833 + // no crawl budget.
3834 + //
3835 + // Skipping the write also keeps the filename out of
3836 + // $results['sitemaps_generated'], which is what
3837 + // prune_orphaned_segments() treats as "written this run" — so
3838 + // a type that empties after previously publishing has its
3839 + // stale file deleted rather than left serving.
3840 + if (!empty($buffer) || ($page === 0 && $index_config === null)) {
1773 3841 $page++;
1774 3842 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
1775 3843 }
1776 3844
1777 3845 // Remove pages left over from a previous, larger generation.
1778 - $this->cleanup_stale_pages($sitemap_config['url'], $page);
3846 + $this->cleanup_stale_pages($sitemap_config['url'], $page, $settings);
1779 3847
1780 3848 } catch (\Exception $e) {
1781 3849 $results['errors'][] = "Error generating {$type} sitemap: " . $e->getMessage();
1782 3850 $results['success'] = false;
@@ -1795,10 +3863,16 @@
1795 3863 $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
1796 3864 $results['success'] = false;
1797 3865 }
1798 3866
1799 - // Build the index last, from the child files actually generated.
3867 + // Build the index last, from the child files actually generated, plus the
3868 + // sitemaps other plugins own: those serve their own URLs and write no
3869 + // file here, so they are appended to the index only (#104).
1800 3870 if ($index_config !== null) {
3871 + foreach (self::additional_sitemaps() as $extra) {
3872 + $index_children[] = ['url' => $extra];
3873 + }
3874 +
1801 3875 try {
1802 3876 $index_xml = $this->generate_sitemap_index($index_children, $settings);
1803 3877 $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
1804 3878
@@ -1818,12 +3892,111 @@
1818 3892 $results['success'] = false;
1819 3893 }
1820 3894 }
1821 3895
3896 + // Remove segments that are no longer part of the set. Publishing was
3897 + // purely additive: a type that dropped out (Categories unticked, a CPT
3898 + // that stopped qualifying) simply stopped being overwritten, so its file
3899 + // kept serving and — until the index happened to be rebuilt — kept being
3900 + // listed in it. The index above is built from the children actually
3901 + // generated, so pruning here leaves disk and index agreeing.
3902 + $this->prune_orphaned_segments($settings, $results['sitemaps_generated']);
3903 +
1822 3904 return $results;
1823 3905 }
1824 3906
1825 3907 /**
3908 + * Delete published segment files that this run did not write.
3909 + *
3910 + * Only filenames this site could have published under its own url pattern
3911 + * are considered, so another plugin's or core's sitemap in the web root is
3912 + * never a candidate — the same reason cleanup does not glob 'sitemap-*.xml'.
3913 + *
3914 + * @since 1.31.0
3915 + *
3916 + * @param array $settings Sitemap settings (read for `custom_url_pattern`).
3917 + * @param array $generated Entries from $results['sitemaps_generated'].
3918 + * @return string[] Basenames removed.
3919 + */
3920 + private function prune_orphaned_segments(array $settings, array $generated): array {
3921 + // Rendering for a request, not publishing: there is nothing on disk
3922 + // this run owns, and a dynamic render must never delete the files a
3923 + // site's previous static mode left behind.
3924 + if ($this->is_collecting()) {
3925 + return [];
3926 + }
3927 +
3928 + $kept = [];
3929 + foreach ($generated as $entry) {
3930 + if (!empty($entry['filename'])) {
3931 + $kept[strtolower((string) $entry['filename'])] = true;
3932 + }
3933 + }
3934 +
3935 + // The current mode's primary and the local business sitemap are written
3936 + // by their own paths and are never orphans here.
3937 + $primary = strtolower(basename($this->get_primary_sitemap_filename($settings)));
3938 + $kept[$primary] = true;
3939 + $kept['local-sitemap.xml'] = true;
3940 +
3941 + // The OTHER mode's primary is an orphan the moment the mode changes:
3942 + // index mode leaves sitemap.xml behind, flat mode leaves
3943 + // sitemap_index.xml and its children. Both used to be kept
3944 + // unconditionally, so the site served two sitemap trees and only ever
3945 + // refreshed one (#563). The children are already covered by the segment
3946 + // sweep below, which now sees them because the index is no longer kept.
3947 + $stale_primaries = array_diff(['sitemap.xml', 'sitemap_index.xml'], [$primary]);
3948 +
3949 + $removed = [];
3950 +
3951 + global $wp_filesystem;
3952 + if (!$wp_filesystem) {
3953 + require_once ABSPATH . 'wp-admin/includes/file.php';
3954 + WP_Filesystem();
3955 + }
3956 + if (!$wp_filesystem) {
3957 + return $removed;
3958 + }
3959 +
3960 + $candidates = array_merge($this->publishable_segment_filenames($settings), $stale_primaries);
3961 +
3962 + foreach ($candidates as $candidate) {
3963 + if (isset($kept[strtolower($candidate)])) {
3964 + continue;
3965 + }
3966 +
3967 + if (!preg_match('/^(.*)\.xml$/i', $candidate, $m)) {
3968 + continue;
3969 + }
3970 +
3971 + // The base file plus its numeric pagination pages.
3972 + $paths = [ABSPATH . $candidate];
3973 + foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
3974 + if (preg_match('/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i', basename($paged))) {
3975 + $paths[] = $paged;
3976 + }
3977 + }
3978 +
3979 + foreach ($paths as $path) {
3980 + if (!file_exists($path)) {
3981 + continue;
3982 + }
3983 + // A name we could have published is not proof we published
3984 + // this file: RankMath and core write at the same paths (#515).
3985 + if (!$this->webroot_sitemap_is_ours($path, $settings)) {
3986 + continue;
3987 + }
3988 + if ($wp_filesystem->delete($path)) {
3989 + $removed[] = basename($path);
3990 + }
3991 + }
3992 + }
3993 +
3994 + return $removed;
3995 + }
3996 +
3997 +
3998 + /**
1826 3999 * Collect the full (un-paginated) entry list for a content sitemap type.
1827 4000 *
1828 4001 * @since 1.14.0
1829 4002 *
@@ -1848,11 +4021,15 @@
1848 4021 $settings = $settings ?? $this->get_settings('site');
1849 4022 $entries = $this->collect_local_entries();
1850 4023
1851 4024 if (empty($entries)) {
1852 - // Business identity was cleared — drop any file left from before.
4025 + // Business identity was cleared — drop the file we left from
4026 + // before, but only ours. `local-sitemap.xml` is the name Rank Math
4027 + // publishes under too (this method mirrors it deliberately), so on
4028 + // a migrated site the file at that path may never have been ours
4029 + // to delete (#515).
1853 4030 $path = ABSPATH . 'local-sitemap.xml';
1854 - if (file_exists($path)) {
4031 + if (!$this->is_collecting() && file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
1855 4032 wp_delete_file($path);
1856 4033 }
1857 4034 return false;
1858 4035 }
@@ -1874,8 +4051,25 @@
1874 4051 *
1875 4052 * @since 1.15.x
1876 4053 * @return array Zero or one URL entry
1877 4054 */
4055 + /**
4056 + * Does this site publish a local business sitemap right now?
4057 + *
4058 + * The same gate {@see self::regenerate_local_sitemap()} applies, asked
4059 + * without writing anything. Callers that need to know whether the document
4060 + * exists must not test the filesystem: under dynamic delivery it is served
4061 + * from PHP and there is no file, which is how `local-sitemap.xml` came to be
4062 + * dropped from robots.txt on exactly those sites (#752).
4063 + *
4064 + * @since 2.9.0
4065 + *
4066 + * @return bool True when the local sitemap has content to publish.
4067 + */
4068 + public function publishes_local_sitemap(): bool {
4069 + return !empty($this->collect_local_entries());
4070 + }
4071 +
1878 4072 private function collect_local_entries(): array {
1879 4073 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
1880 4074 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
1881 4075 }
@@ -1947,17 +4141,43 @@
1947 4141 case 'products':
1948 4142 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
1949 4143 return ['entries' => $entries, 'image_ns' => true];
1950 4144
1951 - case 'product_categories':
1952 - $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
1953 - return ['entries' => $entries, 'image_ns' => false];
4145 + default:
4146 + // Resolve a preset's display name to the object it streams, so
4147 + // 'product_categories' is an ordinary taxonomy child rather than
4148 + // a case of its own (#690).
4149 + $alias = self::CHILD_TYPE_ALIASES[$type] ?? null;
4150 + $object = $alias ?? $type;
1954 4151
1955 - default:
1956 4152 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
1957 - if (in_array($type, $custom_post_types, true)) {
1958 - return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
4153 + if (in_array($object, $custom_post_types, true)) {
4154 + return ['entries' => $this->collect_post_entries_iter([$object], $settings), 'image_ns' => true];
1959 4155 }
4156 +
4157 + // Custom taxonomies reach index mode here, streamed through the
4158 + // same iterator flat mode uses so the two modes emit identical
4159 + // URLs for the same settings.
4160 + if (taxonomy_exists($object) && $this->should_include_taxonomy($object)) {
4161 + return ['entries' => $this->collect_taxonomy_entries_iter($object, $settings), 'image_ns' => false];
4162 + }
4163 +
4164 + // An aliased child whose object is gone (WooCommerce deactivated)
4165 + // keeps writing the empty file it always wrote. Returning null
4166 + // here would hand it the whole-site fallback instead, dumping
4167 + // every URL on the site into a file named for products.
4168 + //
4169 + // A registered taxonomy this generator will not emit
4170 + // (`post_format`, `nav_menu`, a non-public one) needs the same
4171 + // answer for the same reason. generate_multiple_sitemaps()
4172 + // skips those before they reach here, so nothing takes this
4173 + // path today — but it is the one branch where falling through
4174 + // to null is silently catastrophic rather than merely wrong,
4175 + // and the guard keeping it unreachable lives in another method.
4176 + if ($alias !== null || taxonomy_exists($object)) {
4177 + return ['entries' => [], 'image_ns' => false];
4178 + }
4179 +
1960 4180 return null;
1961 4181 }
1962 4182 }
1963 4183
@@ -2000,13 +4220,23 @@
2000 4220 * which has no -N suffix) is never touched.
2001 4221 *
2002 4222 * @since 1.14.0
2003 4223 *
4224 + * @since 2.1.1 Each candidate must pass the content ownership test — a
4225 + * `-N.xml` page of another plugin's sitemap paginates our
4226 + * stem exactly as ours does (#515).
4227 + *
2004 4228 * @param string $base_url Base (page 1) sitemap URL
2005 4229 * @param int $current_pages Number of pages generated this run
4230 + * @param array $settings Sitemap settings, for the ownership test.
2006 4231 * @return void
2007 4232 */
2008 - private function cleanup_stale_pages(string $base_url, int $current_pages): void {
4233 + private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
4234 + // See prune_orphaned_segments(): a dynamic render deletes nothing.
4235 + if ($this->is_collecting()) {
4236 + return;
4237 + }
4238 +
2009 4239 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2010 4240 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2011 4241 return;
2012 4242 }
@@ -2023,9 +4253,11 @@
2023 4253
2024 4254 $candidates = glob(ABSPATH . $stem . '-*.xml') ?: [];
2025 4255 foreach ($candidates as $path) {
2026 4256 // Only delete numeric-suffixed pages beyond the current count.
2027 - if (preg_match('/-(\d+)\.xml$/', basename($path), $mm) && (int) $mm[1] > $current_pages) {
4257 + if (preg_match('/-(\d+)\.xml$/', basename($path), $mm)
4258 + && (int) $mm[1] > $current_pages
4259 + && $this->webroot_sitemap_is_ours($path, $settings)) {
2028 4260 $wp_filesystem->delete($path);
2029 4261 }
2030 4262 }
2031 4263 }
@@ -2030,8 +4262,97 @@
2030 4262 }
2031 4263 }
2032 4264
2033 4265 /**
4266 + * Sitemap URLs contributed by other plugins.
4267 + *
4268 + * ThinkRank owns the sitemap index and the robots.txt `Sitemap:` lines, so a
4269 + * companion plugin that serves its own sitemap — Pro's news and video
4270 + * sitemaps, for instance — had no way to be discovered: it appeared in
4271 + * neither, leaving manual Search Console submission as the only route in
4272 + * (#104). Registering here puts a sitemap in the index when one exists, and
4273 + * in robots.txt when it does not.
4274 + *
4275 + * Callers get root-relative paths. Entries are normalised to a leading
4276 + * slash, de-duplicated, and anything that is not a non-empty string is
4277 + * dropped, so one badly-behaved callback cannot produce a malformed index.
4278 + *
4279 + * @since 2.3.1
4280 + *
4281 + * @return string[] Root-relative sitemap paths, e.g. ['/news-sitemap.xml'].
4282 + */
4283 + public static function additional_sitemaps(): array {
4284 + /**
4285 + * Filters the sitemaps contributed by other plugins.
4286 + *
4287 + * @since 2.3.1
4288 + *
4289 + * @param string[] $sitemaps Root-relative sitemap paths.
4290 + */
4291 + $sitemaps = apply_filters('thinkrank_additional_sitemaps', []);
4292 +
4293 + if (!is_array($sitemaps)) {
4294 + return [];
4295 + }
4296 +
4297 + // Both consumers resolve an entry with home_url(), which prefixes the
4298 + // install's own directory. Everything below is measured against that so
4299 + // an absolute URL is reduced to what home_url() will put back.
4300 + $home = wp_parse_url(home_url('/'));
4301 + $home_host = strtolower((string) ($home['host'] ?? ''));
4302 + $home_path = '/' . trim((string) ($home['path'] ?? ''), '/');
4303 +
4304 + $clean = [];
4305 + foreach ($sitemaps as $sitemap) {
4306 + if (!is_string($sitemap)) {
4307 + continue;
4308 + }
4309 +
4310 + $sitemap = trim($sitemap);
4311 + if ('' === $sitemap) {
4312 + continue;
4313 + }
4314 +
4315 + // A full URL on this site is accepted and reduced to the part
4316 + // home_url() does not already supply, so a caller that reached for
4317 + // home_url() still lands in the right place — including on a
4318 + // subdirectory install, where keeping the whole path would repeat
4319 + // the directory. A URL on another host is dropped rather than
4320 + // rewritten: the sitemaps protocol will not accept a cross-host
4321 + // child anyway, and reusing its path would advertise a URL on this
4322 + // site that does not exist.
4323 + if (preg_match('#^(https?:)?//#i', $sitemap)) {
4324 + $parts = wp_parse_url('//' === substr($sitemap, 0, 2) ? 'https:' . $sitemap : $sitemap);
4325 + if (!is_array($parts)) {
4326 + continue;
4327 + }
4328 +
4329 + if (strtolower((string) ($parts['host'] ?? '')) !== $home_host) {
4330 + continue;
4331 + }
4332 +
4333 + $path = (string) ($parts['path'] ?? '');
4334 + if ('' === $path) {
4335 + continue;
4336 + }
4337 +
4338 + if ('/' !== $home_path && ($path === $home_path || 0 === strpos($path, $home_path . '/'))) {
4339 + $path = substr($path, strlen($home_path));
4340 + }
4341 +
4342 + // A sitemap served from a query string keeps it; dropping the
4343 + // query would point at a different document.
4344 + $query = (string) ($parts['query'] ?? '');
4345 + $sitemap = $path . ('' !== $query ? '?' . $query : '');
4346 + }
4347 +
4348 + $clean[] = '/' . ltrim($sitemap, '/');
4349 + }
4350 +
4351 + return array_values(array_unique($clean));
4352 + }
4353 +
4354 + /**
2034 4355 * Generate sitemap index XML from the list of child sitemap files produced
2035 4356 * during generation (each already resolved to its final, possibly paginated,
2036 4357 * URL).
2037 4358 *
@@ -2040,14 +4361,9 @@
2040 4361 * @param array $settings Sitemap settings
2041 4362 * @return string Sitemap index XML
2042 4363 */
2043 4364 private function generate_sitemap_index(array $children, array $settings): string {
2044 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
2045 -
2046 - // Add XSL stylesheet only if styling is enabled
2047 - if (!empty($settings['enable_styling'])) {
2048 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap-index.xsl') . '"?>' . "\n";
2049 - }
4365 + $xml = $this->xml_prolog($settings, 'index');
2050 4366 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
2051 4367
2052 4368 $site_url = home_url();
2053 4369
@@ -2060,9 +4376,9 @@
2060 4376 $sitemap_url = $site_url . $sitemap_url;
2061 4377 }
2062 4378
2063 4379 $xml .= " <sitemap>\n";
2064 - $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
4380 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($sitemap_url)) . "</loc>\n";
2065 4381 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
2066 4382 $xml .= " </sitemap>\n";
2067 4383 }
2068 4384