PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 All 57 releases
← All changes | includes/seo/class-sitemap-generator.php +1274 -121 2.7.0 → 2.14.2 View file →
@@ -144,8 +144,98 @@
144 144 */
145 145 public const REGENERATION_ERROR_OPTION = 'thinkrank_sitemap_regeneration_error';
146 146
147 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 + /**
148 238 * Transient guarding against two generations running at once. Shared with
149 239 * Sitemap_Endpoint's manual generate route so an automatic rebuild and a
150 240 * manual one cannot write the same files concurrently.
151 241 *
@@ -161,8 +251,33 @@
161 251 * @var int
162 252 */
163 253 private const TERM_WALK_CHUNK = 1000;
164 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 +
165 280 private array $sitemap_types = [
166 281 'posts' => [
167 282 'name' => 'Posts',
168 283 'post_types' => ['post'],
@@ -189,25 +304,29 @@
189 304 ]
190 305 ];
191 306
192 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 + /**
193 317 * Constructor
194 318 *
195 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().
196 323 *
197 - * @param bool $register_hooks Optional. Whether to register the auto-generation
198 - * hooks. Pass false for a read-only instance built
199 - * solely to query settings — the hooks are bound to
200 - * `$this`, so a second hook-registering instance
201 - * would run `handle_content_change()` twice per save.
324 + * @param bool $register_hooks Unused since 2.10.1; kept so existing callers,
325 + * Pro's included, keep working.
202 326 */
203 327 public function __construct(bool $register_hooks = true) {
204 328 parent::__construct('sitemap');
205 -
206 - // Initialize auto-generation hooks
207 - if ($register_hooks) {
208 - $this->init_auto_generation_hooks();
209 - }
210 329 }
211 330
212 331 /**
213 332 * Filter the args of a sitemap post query.
@@ -251,34 +370,154 @@
251 370 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
252 371 }
253 372
254 373 /**
255 - * Initialize WordPress hooks for auto-generation
374 + * Register the content-change listeners that queue an automatic rebuild.
256 375 *
257 - * @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
258 391 * @return void
259 392 */
260 - private function init_auto_generation_hooks(): void {
261 - // Content change hooks - use priority 20 to run after other plugins
262 - add_action('save_post', [$this, 'handle_content_change'], 20, 2);
263 - add_action('delete_post', [$this, 'handle_content_deletion'], 20);
264 - add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
265 - 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);
266 407
267 - // Taxonomy change hooks
268 - add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
269 - add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
270 - 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 + }
271 413
272 - // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
273 - // and thinkrank_regenerate_sitemap_settings) are registered at plugin
274 - // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
275 - // rather than here. A cron run never builds this class via the REST
276 - // endpoint (no rest_api_init), so registering them in the constructor
277 - // would leave the scheduled events with no listener at cron time.
414 + // The sitemap leaves out what the robots tag noindexes and what
415 + // ThinkRank redirects (#911), so a change to either decides what the
416 + // published files should list. Neither is a post or term save, and
417 + // without these the files kept the old answer until unrelated content
418 + // changed.
419 + foreach (self::ROBOTS_SETTINGS_OPTIONS as $option) {
420 + add_action('update_option_' . $option, static function ($old_value, $value): void {
421 + self::handle_robots_settings_change($old_value, $value);
422 + }, 20, 2);
423 + add_action('add_option_' . $option, static function ($name, $value): void {
424 + self::handle_robots_settings_change([], $value);
425 + }, 20, 2);
426 + }
427 +
428 + add_action('thinkrank_object_redirect_saved', static function (): void {
429 + self::listener()->schedule_regeneration();
430 + }, 20, 0);
278 431 }
279 432
280 433 /**
434 + * Options holding robots directives the sitemap's item filter reads.
435 + *
436 + * @since 2.15.0
437 + * @var string[]
438 + */
439 + private const ROBOTS_SETTINGS_OPTIONS = [
440 + 'thinkrank_global_seo_settings',
441 + 'thinkrank_global_robot_meta_settings',
442 + ];
443 +
444 + /**
445 + * Queue a rebuild when a saved robots setting changes a noindex decision.
446 + *
447 + * Both options carry far more than robots directives (titles, schema,
448 + * feature switches), so only a change to a noindex outcome rebuilds.
449 + *
450 + * @since 2.15.0
451 + *
452 + * @param mixed $old_value Previous option value.
453 + * @param mixed $value New option value.
454 + * @return void
455 + */
456 + public static function handle_robots_settings_change($old_value, $value): void {
457 + if (self::noindex_fingerprint($old_value) === self::noindex_fingerprint($value)) {
458 + return;
459 + }
460 +
461 + // The matrix memoises the option for the request, and a rebuild that
462 + // runs in this request must read the value just saved.
463 + Content_Type_Settings::flush_cache();
464 +
465 + self::listener()->schedule_regeneration();
466 + }
467 +
468 + /**
469 + * The noindex decisions a robots option makes, keyed by what they apply to.
470 + *
471 + * Reads both shapes: the flat site-wide directives, and the per-entity
472 + * rows, where a row's directives apply only while its robots switch is on.
473 + * A row that is on but stores no `noindex` key changes nothing, matching
474 + * the array_merge() the robots tag does.
475 + *
476 + * @since 2.15.0
477 + *
478 + * @param mixed $value Option value.
479 + * @return array<string, bool|null>
480 + */
481 + private static function noindex_fingerprint($value): array {
482 + if (!is_array($value)) {
483 + return [];
484 + }
485 +
486 + $decisions = [];
487 +
488 + if (array_key_exists('noindex', $value) && !is_array($value['noindex'])) {
489 + $decisions['*'] = !empty($value['noindex']);
490 + }
491 +
492 + foreach ($value as $key => $row) {
493 + if (!is_array($row)) {
494 + continue;
495 + }
496 +
497 + $robots = $row['robots_meta'] ?? null;
498 +
499 + $decisions[(string) $key] = !empty($row['robots_meta_enabled']) && is_array($robots) && array_key_exists('noindex', $robots)
500 + ? !empty($robots['noindex'])
501 + : null;
502 + }
503 +
504 + ksort($decisions);
505 +
506 + return $decisions;
507 + }
508 +
509 + /**
510 + * The generator the content-change listeners share.
511 + *
512 + * @since 2.10.1
513 + * @return self
514 + */
515 + private static function listener(): self {
516 + return self::$listener ??= new self(false);
517 + }
518 +
519 + /**
281 520 * Generate XML sitemap
282 521 *
283 522 * @since 1.0.0
284 523 *
@@ -452,8 +691,15 @@
452 691 ]
453 692 ],
454 693 'use_sitemap_index' => false,
455 694
695 + // How the sitemap reaches crawlers. 'auto' keeps the historical
696 + // behaviour wherever the web root is writable, and only falls back
697 + // to serving the sitemap from PHP where writing a file is
698 + // impossible — previously a hard failure with nothing served
699 + // (#752).
700 + 'delivery_mode' => 'auto',
701 +
456 702 // General Settings
457 703 'links_per_sitemap' => 1000,
458 704 'include_images' => true,
459 705 'include_featured_images' => false,
@@ -519,8 +765,16 @@
519 765 if (array_key_exists('styling_logo_url', $sanitized)) {
520 766 $sanitized['styling_logo_url'] = esc_url_raw((string) $sanitized['styling_logo_url']);
521 767 }
522 768
769 + // A mode this build cannot act on has to be stored as the fallback
770 + // rather than kept verbatim, or get-sitemap-settings reports a delivery
771 + // mode the site does not actually apply.
772 + if (array_key_exists('delivery_mode', $sanitized)) {
773 + $mode = sanitize_key((string) $sanitized['delivery_mode']);
774 + $sanitized['delivery_mode'] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
775 + }
776 +
523 777 return $sanitized;
524 778 }
525 779
526 780 /**
@@ -538,8 +792,15 @@
538 792 'title' => 'Enable Sitemap',
539 793 'description' => 'Generate XML sitemap for search engines',
540 794 'default' => true
541 795 ],
796 + 'delivery_mode' => [
797 + 'type' => 'string',
798 + 'title' => 'Sitemap Delivery',
799 + 'description' => 'How the sitemap is served: auto picks static when the WordPress root is writable and dynamic when it is not, static writes files to the web root, dynamic serves the sitemap from WordPress with no files written',
800 + 'enum' => self::DELIVERY_MODES,
801 + 'default' => 'auto'
802 + ],
542 803 'include_posts' => [
543 804 'type' => 'boolean',
544 805 'title' => 'Include Posts',
545 806 'description' => 'Include blog posts in sitemap',
@@ -639,9 +900,12 @@
639 900
640 901 // Add image entries if provided
641 902 foreach ($images as $image) {
642 903 $xml .= " <image:image>\n";
643 - $xml .= " <image:loc>" . esc_url(Url_Scheme::apply((string) $image['url'])) . "</image:loc>\n";
904 + // The sitemap protocol wants an escaped URL, and WordPress hands back
905 + // attachment URLs with non-ASCII filenames unencoded (esc_url() does
906 + // not encode them either), so write the percent-encoded form (#924).
907 + $xml .= " <image:loc>" . esc_url(\ThinkRank\Core\Url_Validator::to_ascii(Url_Scheme::apply((string) $image['url']))) . "</image:loc>\n";
644 908
645 909 if (!empty($image['title'])) {
646 910 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
647 911 }
@@ -762,8 +1026,11 @@
762 1026 // well-bounded routine with no ceiling (#402).
763 1027 $total = count($all_ids);
764 1028
765 1029 for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
1030 + $this->assert_memory_headroom();
1031 + $chunk_start = memory_get_usage(true);
1032 +
766 1033 $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
767 1034
768 1035 $posts = get_posts($this->filter_query_args([
769 1036 'post_type' => $post_types,
@@ -772,8 +1039,12 @@
772 1039 'post__in' => $chunk,
773 1040 'orderby' => 'post__in', // preserve the resolved order
774 1041 ]));
775 1042
1043 + // get_featured_image() hydrates each featured image under the
1044 + // attachment's own ID, which the chunk's post IDs do not reach.
1045 + $attachment_ids = [];
1046 +
776 1047 foreach ($posts as $post) {
777 1048 if ($this->should_include_in_sitemap($post, $settings)) {
778 1049 /**
779 1050 * Filter a sitemap entry's permalink.
@@ -794,14 +1065,25 @@
794 1065 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
795 1066 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
796 1067 $images = $this->extract_post_images($post, $settings);
797 1068
1069 + $thumbnail_id = (int) get_post_thumbnail_id($post);
1070 + if ($thumbnail_id > 0) {
1071 + $attachment_ids[] = $thumbnail_id;
1072 + }
1073 +
798 1074 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
799 1075 }
800 1076 }
801 1077
802 - // Free the hydrated chunk before loading the next one.
1078 + // Free the hydrated chunk before loading the next one — including
1079 + // the copies get_posts() left in the runtime object cache.
803 1080 unset($posts);
1081 + $this->release_walk_memory(
1082 + $chunk_start,
1083 + $this->chunk_post_cache_groups($post_types),
1084 + array_merge($chunk, $attachment_ids)
1085 + );
804 1086 }
805 1087 }
806 1088
807 1089 /**
@@ -861,8 +1143,11 @@
861 1143 // Same moving window as the post walk above, for the same reason.
862 1144 $total = count($all_ids);
863 1145
864 1146 for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
1147 + $this->assert_memory_headroom();
1148 + $chunk_start = memory_get_usage(true);
1149 +
865 1150 $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
866 1151
867 1152 $terms = get_terms($this->filter_term_query_args([
868 1153 'taxonomy' => $taxonomy,
@@ -875,12 +1160,12 @@
875 1160 continue;
876 1161 }
877 1162
878 1163 foreach ($terms as $term) {
879 - // A term the user marked noindex must not be advertised in the
880 - // sitemap: the robots tag now honours term meta, so listing it
881 - // here would have the sitemap contradict the page's own tag.
882 - if ($this->term_is_noindexed((int) $term->term_id)) {
1164 + // A term whose archive says noindex (its own override or its
1165 + // taxonomy's), or that redirects, must not be advertised: the
1166 + // sitemap would contradict the page's own signal (#911).
1167 + if (!Indexability::is_indexable_term($term)) {
883 1168 continue;
884 1169 }
885 1170
886 1171 $url = get_term_link($term);
@@ -891,8 +1176,9 @@
891 1176 }
892 1177 }
893 1178
894 1179 unset($terms);
1180 + $this->release_walk_memory($chunk_start, ['terms', 'term_meta'], $chunk);
895 1181 }
896 1182 }
897 1183
898 1184 /**
@@ -1067,9 +1353,9 @@
1067 1353 if (preg_match('/src=["\']([^"\']+)["\']/', $img_tag, $src_match)) {
1068 1354 $image_url = $src_match[1];
1069 1355
1070 1356 // Skip if not a valid URL or external image
1071 - if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
1357 + if (!\ThinkRank\Core\Url_Validator::is_valid($image_url)) {
1072 1358 continue;
1073 1359 }
1074 1360
1075 1361 // Extract title and alt attributes
@@ -1184,14 +1470,15 @@
1184 1470 if (is_wp_error($all_terms)) {
1185 1471 return [];
1186 1472 }
1187 1473
1188 - // Drop terms the user marked noindex. This path feeds the single general
1189 - // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
1190 - // so both need the filter or the two disagree about the same term.
1474 + // Drop terms that are not indexable destinations. This path feeds the
1475 + // single general sitemap while collect_taxonomy_entries_iter() feeds
1476 + // the segmented ones, so both need the filter or the two disagree about
1477 + // the same term.
1191 1478 $all_terms = array_values(array_filter(
1192 1479 $all_terms,
1193 - fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1480 + static fn($term) => $term instanceof \WP_Term && Indexability::is_indexable_term($term)
1194 1481 ));
1195 1482
1196 1483 // Group terms by taxonomy
1197 1484 return $this->group_terms_by_taxonomy($all_terms);
@@ -1373,17 +1660,16 @@
1373 1660 if (!in_array($post->post_status, ['publish', 'private'], true)) {
1374 1661 return false;
1375 1662 }
1376 1663
1377 - // Check if post overrides robots and sets noindex.
1378 - if ((bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true)) {
1379 - $raw = get_post_meta($post->ID, '_thinkrank_robots_meta', true);
1380 - if (is_string($raw) && $raw !== '') {
1381 - $robots = json_decode($raw, true);
1382 - if (is_array($robots) && !empty($robots['noindex'])) {
1383 - return false;
1384 - }
1385 - }
1664 + // A URL whose page says noindex, or that ThinkRank redirects, is not a
1665 + // destination. Only the per-post noindex used to be read here, so a
1666 + // post type set to No-index still had every item listed, and redirected
1667 + // posts were submitted as "Page with redirect" (#911). The password
1668 + // check stays with the setting above: listing protected posts is a
1669 + // choice this sitemap has always offered.
1670 + if (Indexability::is_post_noindexed($post) || Indexability::is_post_redirected($post)) {
1671 + return false;
1386 1672 }
1387 1673
1388 1674 return true;
1389 1675 }
@@ -1422,35 +1708,8 @@
1422 1708 return $ids;
1423 1709 }
1424 1710
1425 1711 /**
1426 - * Whether a term carries an explicit noindex override.
1427 - *
1428 - * Mirrors the post-side check in should_include_post(); terms store the same
1429 - * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1430 - * by the update-term-seo ability and by the SEO importer.
1431 - *
1432 - * @since 1.31.0
1433 - *
1434 - * @param int $term_id Term to test.
1435 - * @return bool True when the term is marked noindex.
1436 - */
1437 - private function term_is_noindexed(int $term_id): bool {
1438 - if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1439 - return false;
1440 - }
1441 -
1442 - $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1443 - if (!is_string($raw) || $raw === '') {
1444 - return false;
1445 - }
1446 -
1447 - $robots = json_decode($raw, true);
1448 -
1449 - return is_array($robots) && !empty($robots['noindex']);
1450 - }
1451 -
1452 - /**
1453 1712 * Count total URLs in sitemap
1454 1713 *
1455 1714 * @since 1.0.0
1456 1715 *
@@ -1729,8 +1988,14 @@
1729 1988 * @param string $source Either 'content' or 'settings'.
1730 1989 * @return void
1731 1990 */
1732 1991 private function mark_regeneration_pending(string $source): void {
1992 + // Whatever made the static files stale made the rendered ones stale
1993 + // too. Invalidating here rather than only on the rebuild keeps the two
1994 + // delivery modes reacting to exactly the same triggers, which is the
1995 + // only way a dynamic site stays as fresh as a static one (#752).
1996 + $this->flush_dynamic_cache();
1997 +
1733 1998 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1734 1999 $pending = is_array($pending) ? $pending : [];
1735 2000
1736 2001 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
@@ -1736,11 +2001,13 @@
1736 2001 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1737 2002 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1738 2003 $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1739 2004
2005 + // Merged so the bookkeeping a rebuild keeps on the marker (`started`,
2006 + // `memory_limit`) survives an edit made while it is outstanding.
1740 2007 update_option(
1741 2008 self::REGENERATION_PENDING_OPTION,
1742 - [
2009 + array_merge($pending, [
1743 2010 'since' => $since,
1744 2011 'source' => $source,
1745 2012 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
1746 2013 'next_attempt' => !empty($pending['next_attempt'])
@@ -1748,9 +2015,9 @@
1748 2015 : time() + self::REGENERATION_TAKEOVER_GRACE,
1749 2016 // Bumped on every change so a rebuild can tell whether the edit
1750 2017 // it started for is still the newest one outstanding.
1751 2018 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
1752 - ],
2019 + ]),
1753 2020 true
1754 2021 );
1755 2022 }
1756 2023
@@ -1796,8 +2063,18 @@
1796 2063 $revision !== null
1797 2064 && is_array($pending)
1798 2065 && (int) ($pending['revision'] ?? 0) !== $revision
1799 2066 ) {
2067 + // This attempt succeeded, so drop what it claimed: the newer change
2068 + // waits the grace a fresh edit gets, not a failure backoff it never
2069 + // earned. Not zero: that edit queued its own debounced event, and
2070 + // a marker due at once had the next admin request rebuild in its
2071 + // shutdown and the event rebuild again seconds later.
2072 + unset($pending['started'], $pending['memory_limit']);
2073 + $pending['attempts'] = 0;
2074 + $pending['next_attempt'] = time() + self::REGENERATION_TAKEOVER_GRACE;
2075 +
2076 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
1800 2077 return;
1801 2078 }
1802 2079
1803 2080 delete_option(self::REGENERATION_PENDING_OPTION);
@@ -1803,8 +2080,69 @@
1803 2080 delete_option(self::REGENERATION_PENDING_OPTION);
1804 2081 }
1805 2082
1806 2083 /**
2084 + * Record the attempt that is about to run before it runs.
2085 + *
2086 + * A PHP fatal — the memory limit or max_execution_time — is not a
2087 + * Throwable, so no catch or finally around the generation runs when the
2088 + * process dies, and a failure recorded afterwards was never recorded at
2089 + * all: `attempts` stayed 0, the backoff never applied, and the next request
2090 + * started the same doomed rebuild again (a fatal every few minutes for as
2091 + * long as an admin was logged in). Claiming the attempt up front makes the
2092 + * backoff hold even when nothing after this line gets to run, and leaves a
2093 + * `started` stamp the next attempt can recognise as an interrupted one.
2094 + *
2095 + * @since 2.10.1
2096 + * @return void
2097 + */
2098 + private function claim_regeneration_attempt(): void {
2099 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
2100 +
2101 + // Nothing outstanding (e.g. a manual generation already satisfied it):
2102 + // there is no marker to retry from, so nothing to claim.
2103 + if (!is_array($pending) || empty($pending['since'])) {
2104 + return;
2105 + }
2106 +
2107 + if (!empty($pending['started'])) {
2108 + // The previous attempt claimed itself and never reported back.
2109 + update_option(
2110 + self::REGENERATION_ERROR_OPTION,
2111 + [
2112 + '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'),
2113 + 'source' => isset($pending['source']) ? (string) $pending['source'] : 'content',
2114 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 1,
2115 + 'time' => (int) $pending['started'],
2116 + ],
2117 + false
2118 + );
2119 + }
2120 +
2121 + $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2122 +
2123 + $pending['attempts'] = $attempts;
2124 + $pending['next_attempt'] = time() + $this->regeneration_backoff($attempts);
2125 + $pending['started'] = time();
2126 +
2127 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2128 + }
2129 +
2130 + /**
2131 + * Delay before the next takeover after the given number of attempts.
2132 + *
2133 + * @since 2.10.1
2134 + * @param int $attempts Attempts made so far (1 or more).
2135 + * @return int Seconds.
2136 + */
2137 + private function regeneration_backoff(int $attempts): int {
2138 + return (int) min(
2139 + self::REGENERATION_TAKEOVER_GRACE * (2 ** min(max($attempts, 1), 10)),
2140 + self::REGENERATION_MAX_BACKOFF
2141 + );
2142 + }
2143 +
2144 + /**
1807 2145 * Record a failed regeneration instead of discarding it.
1808 2146 *
1809 2147 * Keeps the pending marker in place so the rebuild is retried, but backs the
1810 2148 * next attempt off exponentially (capped) so a persistently failing
@@ -1810,22 +2148,30 @@
1810 2148 * next attempt off exponentially (capped) so a persistently failing
1811 2149 * generation cannot run on every admin request.
1812 2150 *
1813 2151 * @since 2.2.1
1814 - * @param string $message Failure detail.
1815 - * @param string $source Either 'content' or 'settings'.
2152 + * @since 2.10.1 Accepts the memory limit a rebuild had to stop short of, and
2153 + * does not count an attempt claim_regeneration_attempt()
2154 + * already counted.
2155 + * @param string $message Failure detail.
2156 + * @param string $source Either 'content' or 'settings'.
2157 + * @param int|null $memory_limit Memory limit (bytes) the rebuild stopped
2158 + * short of, when that was the failure.
1816 2159 * @return void
1817 2160 */
1818 - private function record_regeneration_failure(string $message, string $source): void {
2161 + private function record_regeneration_failure(string $message, string $source, ?int $memory_limit = null): void {
1819 2162 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1820 2163 $pending = is_array($pending) ? $pending : [];
1821 - $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2164 + $attempts = !empty($pending['attempts']) ? (int) $pending['attempts'] : 0;
1822 2165
1823 - $backoff = min(
1824 - self::REGENERATION_TAKEOVER_GRACE * (2 ** min($attempts, 10)),
1825 - self::REGENERATION_MAX_BACKOFF
1826 - );
2166 + // An attempt that claimed itself up front has already been counted.
2167 + if (empty($pending['started'])) {
2168 + $attempts++;
2169 + }
2170 + $attempts = max($attempts, 1);
1827 2171
2172 + $backoff = $this->regeneration_backoff($attempts);
2173 +
1828 2174 // Same precedence mark_regeneration_pending() enforces: a settings
1829 2175 // rebuild outranks a content one and must not be downgraded by a failed
1830 2176 // attempt. Overwriting it routed the retry back through the content
1831 2177 // path, where should_auto_generate() can be false and the completion
@@ -1834,20 +2180,24 @@
1834 2180 // whichever attempt actually failed.
1835 2181 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1836 2182 $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1837 2183
1838 - update_option(
1839 - self::REGENERATION_PENDING_OPTION,
1840 - [
1841 - 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
1842 - 'source' => $pending_source,
1843 - 'attempts' => $attempts,
1844 - 'next_attempt' => time() + $backoff,
1845 - 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
1846 - ],
1847 - true
1848 - );
2184 + $marker = [
2185 + 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
2186 + 'source' => $pending_source,
2187 + 'attempts' => $attempts,
2188 + 'next_attempt' => time() + $backoff,
2189 + 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
2190 + ];
1849 2191
2192 + // Remembered so has_memory_for_retry() can keep requests with no more
2193 + // memory than this from repeating the same attempt.
2194 + if ($memory_limit !== null && $memory_limit > 0) {
2195 + $marker['memory_limit'] = $memory_limit;
2196 + }
2197 +
2198 + update_option(self::REGENERATION_PENDING_OPTION, $marker, true);
2199 +
1850 2200 update_option(
1851 2201 self::REGENERATION_ERROR_OPTION,
1852 2202 [
1853 2203 'message' => $message,
@@ -1902,16 +2252,34 @@
1902 2252 if (!self::has_overdue_regeneration()) {
1903 2253 return;
1904 2254 }
1905 2255
1906 - // Cron is running: it is about to do exactly this work.
2256 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2257 + $pending = is_array($pending) ? $pending : [];
2258 + $source = isset($pending['source']) ? (string) $pending['source'] : 'content';
2259 +
2260 + // Cron is running and its own event for this rebuild is still queued
2261 + // and due: it is about to do exactly this work. Only then — an event
2262 + // that has already been consumed (e.g. it fired while another process
2263 + // held the generation lock) is never re-queued, and returning here
2264 + // unconditionally left the rebuild to requests that could not finish it.
1907 2265 if (wp_doing_cron()) {
2266 + $hook = $source === 'settings' ? 'thinkrank_regenerate_sitemap_settings' : 'thinkrank_regenerate_sitemap';
2267 + $next = wp_next_scheduled($hook);
2268 +
2269 + if ($next !== false && $next <= time()) {
2270 + return;
2271 + }
2272 + }
2273 +
2274 + // The last attempt had to stop short of this process's memory limit.
2275 + // Retrying at the same (or a lower) limit only repeats that, so leave
2276 + // the rebuild to a process with more room — WP-CLI, a system cron, or a
2277 + // host with a higher limit — instead of burning it on every request.
2278 + if (!$this->has_memory_for_retry($pending)) {
1908 2279 return;
1909 2280 }
1910 2281
1911 - $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1912 - $source = (is_array($pending) && isset($pending['source'])) ? (string) $pending['source'] : 'content';
1913 -
1914 2282 if ($source === 'settings') {
1915 2283 $this->regenerate_sitemap_from_settings();
1916 2284 return;
1917 2285 }
@@ -1945,8 +2313,228 @@
1945 2313 delete_transient(self::GENERATION_LOCK_TRANSIENT);
1946 2314 }
1947 2315
1948 2316 /**
2317 + * This process's PHP memory limit in bytes.
2318 + *
2319 + * @since 2.10.1
2320 + * @return int Bytes, or -1 when unlimited (or unreadable).
2321 + */
2322 + private function current_memory_limit(): int {
2323 + $limit = (string) ini_get('memory_limit');
2324 +
2325 + if ($limit === '' || $limit === '-1') {
2326 + return -1;
2327 + }
2328 +
2329 + $bytes = (int) wp_convert_hr_to_bytes($limit);
2330 +
2331 + return $bytes > 0 ? $bytes : -1;
2332 + }
2333 +
2334 + /**
2335 + * May this process retry a rebuild that last stopped at the memory limit?
2336 + *
2337 + * Raises the limit the way wp-admin does first, so a request that can get
2338 + * more room than the failed attempt had is still allowed to try.
2339 + *
2340 + * @since 2.10.1
2341 + * @param array $pending The pending marker.
2342 + * @return bool True when there is no recorded memory failure, or this
2343 + * process has more memory than the attempt that failed.
2344 + */
2345 + private function has_memory_for_retry(array $pending): bool {
2346 + if (empty($pending['memory_limit'])) {
2347 + return true;
2348 + }
2349 +
2350 + wp_raise_memory_limit('admin');
2351 +
2352 + $limit = $this->current_memory_limit();
2353 +
2354 + return $limit === -1 || $limit > (int) $pending['memory_limit'];
2355 + }
2356 +
2357 + /**
2358 + * Release what one walked chunk left behind.
2359 + *
2360 + * Hydrating a chunk through get_posts()/get_terms() also stores every
2361 + * object and its meta in the in-process object cache, which nothing
2362 + * empties until the request ends. Unsetting the chunk therefore freed
2363 + * nothing, and the walk grew with the size of the site instead of the size
2364 + * of a chunk — about 1.3 GB on a 45k-post site.
2365 + *
2366 + * A persistent object cache that supports it drops only its in-process
2367 + * copy (`flush_runtime`); the shared store keeps its data. WordPress's
2368 + * default cache has no shared store, and flushing it would empty every
2369 + * group for the rest of the request (options, the queried object, other
2370 + * plugins' data), so there only the chunk's own entries are deleted. A
2371 + * persistent cache without `flush_runtime` is left alone: deleting from it
2372 + * would evict the objects for every other request too.
2373 + *
2374 + * @since 2.10.1
2375 + * @param int $chunk_start memory_get_usage(true) before the chunk was hydrated.
2376 + * @param array $groups Cache groups keyed by the chunk's object IDs.
2377 + * @param int[] $ids The chunk's object IDs, plus any objects it
2378 + * hydrated under their own (featured images).
2379 + * @return void
2380 + * @throws \Error See assert_memory_headroom().
2381 + */
2382 + private function release_walk_memory(int $chunk_start, array $groups, array $ids): void {
2383 + // What one chunk costs before it is released: the margin the next one
2384 + // needs. Measured in the same real allocated size assert_memory_headroom()
2385 + // compares against the limit, so the two are the same unit.
2386 + $this->walk_chunk_cost = max($this->walk_chunk_cost, memory_get_usage(true) - $chunk_start);
2387 +
2388 + if (wp_using_ext_object_cache()) {
2389 + if (
2390 + function_exists('wp_cache_supports')
2391 + && wp_cache_supports('flush_runtime')
2392 + && function_exists('wp_cache_flush_runtime')
2393 + ) {
2394 + wp_cache_flush_runtime();
2395 + }
2396 + } elseif (!empty($ids)) {
2397 + foreach ($groups as $group) {
2398 + wp_cache_delete_multiple($ids, $group);
2399 + }
2400 + }
2401 +
2402 + $this->assert_memory_headroom();
2403 + }
2404 +
2405 + /**
2406 + * Cache groups get_posts() fills per post for the given post types.
2407 + *
2408 + * The post, its meta, and one relationships group per taxonomy the post
2409 + * type uses (update_object_term_cache()). Term objects themselves are
2410 + * bounded by the number of terms, not posts, so they are left cached.
2411 + *
2412 + * @since 2.10.1
2413 + * @param string[] $post_types Post types being walked.
2414 + * @return string[] Cache groups keyed by post ID.
2415 + */
2416 + private function chunk_post_cache_groups(array $post_types): array {
2417 + $groups = ['posts', 'post_meta'];
2418 +
2419 + foreach (get_object_taxonomies($post_types) as $taxonomy) {
2420 + $groups[] = $taxonomy . '_relationships';
2421 + }
2422 +
2423 + return array_values(array_unique($groups));
2424 + }
2425 +
2426 + /**
2427 + * Stop an automatic rebuild before the memory limit rather than at it.
2428 + *
2429 + * A PHP memory fatal skips every catch and finally, so the lock, the
2430 + * failure record and the backoff are all lost with it, while stopping here
2431 + * is an ordinary, fully recorded failure. Checked before each chunk is
2432 + * hydrated, against a margin of at least the largest chunk seen so far.
2433 + *
2434 + * It throws an \Error, not an \Exception, on purpose: the per-segment
2435 + * catch (\Exception) blocks in generate_multiple_sitemaps() would otherwise
2436 + * swallow it and carry on — writing an index without the aborted segments
2437 + * and then pruning their files as orphans. Only the automatic rebuild's
2438 + * catch (\Throwable) is meant to see it.
2439 + *
2440 + * @since 2.10.1
2441 + * @return void
2442 + * @throws \Error When the automatic rebuild is close to the memory limit.
2443 + */
2444 + private function assert_memory_headroom(): void {
2445 + if (!$this->memory_guard) {
2446 + return;
2447 + }
2448 +
2449 + $limit = $this->current_memory_limit();
2450 + if ($limit === -1) {
2451 + return;
2452 + }
2453 +
2454 + // A fifth of the limit (at least 32 MB) for writing the files, or one
2455 + // and a half of the costliest chunk if that is more — and never more
2456 + // than half the limit either way. Without that outer cap a single
2457 + // anomalously expensive chunk (500 posts of serialised page-builder or
2458 + // ACF meta reaches hundreds of megabytes) puts the margin above the
2459 + // limit itself, so every later check aborts at any usage at all, the
2460 + // failure records this process's limit, and has_memory_for_retry()
2461 + // then refuses every process that has the same limit. A site that
2462 + // never actually ran out of memory would stop rebuilding until WP-CLI
2463 + // or a system cron happened to run.
2464 + $headroom = (int) min(
2465 + max(
2466 + min(max($limit * 0.2, 32 * MB_IN_BYTES), $limit * 0.5),
2467 + $this->walk_chunk_cost * 1.5
2468 + ),
2469 + $limit * 0.5
2470 + );
2471 +
2472 + // The real allocated size, which is what PHP enforces memory_limit
2473 + // against; memory_get_usage(false) reports only what is handed out of
2474 + // those allocations and so understates the margin by the allocator's
2475 + // slack.
2476 + $usage = memory_get_usage(true);
2477 +
2478 + if ($usage > $limit - $headroom) {
2479 + throw new \Error(
2480 + sprintf(
2481 + /* translators: 1: memory in use, 2: PHP memory limit. */
2482 + 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'),
2483 + esc_html((string) size_format($usage)),
2484 + esc_html((string) size_format($limit))
2485 + ),
2486 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- an integer class constant, not output.
2487 + self::MEMORY_ABORT_CODE
2488 + );
2489 + }
2490 + }
2491 +
2492 + /**
2493 + * Run an automatic rebuild's generation with the fatal-safe bookkeeping.
2494 + *
2495 + * @since 2.10.1
2496 + * @param array $settings Sitemap settings.
2497 + * @return bool Whatever generate_and_save() returned.
2498 + * @throws \Throwable Whatever generation throws, after the memory guard is
2499 + * switched back off.
2500 + */
2501 + private function generate_for_regeneration(array $settings): bool {
2502 + // Same headroom wp-admin gives itself; a no-op when the limit is
2503 + // already higher or unlimited.
2504 + wp_raise_memory_limit('admin');
2505 +
2506 + $this->claim_regeneration_attempt();
2507 + $this->memory_guard = true;
2508 + $this->walk_chunk_cost = 0;
2509 +
2510 + try {
2511 + return $this->generate_and_save($settings);
2512 + } finally {
2513 + $this->memory_guard = false;
2514 + }
2515 + }
2516 +
2517 + /**
2518 + * Record a failure thrown by an automatic rebuild.
2519 + *
2520 + * @since 2.10.1
2521 + * @param \Throwable $e What was thrown.
2522 + * @param string $source Either 'content' or 'settings'.
2523 + * @return void
2524 + */
2525 + private function record_thrown_regeneration_failure(\Throwable $e, string $source): void {
2526 + $memory_limit = null;
2527 +
2528 + if ($e instanceof \Error && $e->getCode() === self::MEMORY_ABORT_CODE) {
2529 + $memory_limit = $this->current_memory_limit();
2530 + $memory_limit = $memory_limit > 0 ? $memory_limit : null;
2531 + }
2532 +
2533 + $this->record_regeneration_failure($e->getMessage(), $source, $memory_limit);
2534 + }
2535 +
2536 + /**
1949 2537 * Report how automatic regeneration is faring, for the admin UI.
1950 2538 *
1951 2539 * The feature used to fail invisibly: `last_generated` simply stopped
1952 2540 * advancing and nothing drew attention to it (#629).
@@ -2044,16 +2632,20 @@
2044 2632 * auto_generate setting: the user deliberately changed inclusion rules and
2045 2633 * expects the served file to reflect them even if content-triggered
2046 2634 * auto-generation is turned off. Still respects the master `enabled` flag.
2047 2635 *
2048 - * @return void
2636 + * @since 2.10.0 Reports whether the served sitemap was actually rebuilt, so
2637 + * a caller can say so rather than assume it (#764). Existing
2638 + * callers that ignore the return are unaffected.
2639 + *
2640 + * @return bool True when the served sitemap now reflects the settings.
2049 2641 */
2050 - public function regenerate_sitemap_from_settings(): void {
2642 + public function regenerate_sitemap_from_settings(): bool {
2051 2643 if (!$this->acquire_generation_lock()) {
2052 2644 // A manual generation (or another request's takeover) is already
2053 2645 // writing the files; the pending marker survives so this rebuild is
2054 2646 // retried rather than lost.
2055 - return;
2647 + return false;
2056 2648 }
2057 2649
2058 2650 try {
2059 2651 $settings = $this->get_settings('site');
@@ -2060,25 +2652,56 @@
2060 2652 if (empty($settings['enabled'])) {
2061 2653 // The sitemap was disabled: remove the previously generated static
2062 2654 // files so the web server stops serving a stale sitemap that
2063 2655 // crawlers would otherwise keep fetching.
2064 - $this->delete_published_sitemaps();
2656 + //
2657 + // A file that could not be removed is still being served, so
2658 + // this is not a success. Reporting one here would tell a caller
2659 + // the sitemap was gone while the web server kept answering with
2660 + // it, which is the failure this return value exists to prevent
2661 + // (#764).
2662 + $removal = $this->delete_published_sitemaps($settings);
2663 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2664 +
2665 + if (!empty($stuck)) {
2666 + $this->record_regeneration_failure(
2667 + $this->stuck_files_message($stuck, true),
2668 + 'settings'
2669 + );
2670 +
2671 + return false;
2672 + }
2673 +
2065 2674 $this->mark_regeneration_complete();
2066 - return;
2675 +
2676 + return true;
2067 2677 }
2068 2678
2069 2679 $revision = $this->current_regeneration_revision();
2070 2680
2071 - if ($this->generate_and_save($settings)) {
2681 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2682 + // Returns false when a static file is stuck in the web root:
2683 + // the server keeps serving that file in preference to WordPress,
2684 + // so the switch has not taken effect (#764).
2685 + return $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2686 + }
2687 +
2688 + if ($this->generate_for_regeneration($settings)) {
2072 2689 $this->mark_regeneration_complete($revision);
2073 - } else {
2074 - $this->record_regeneration_failure(
2075 - __('The sitemap files could not be written to the site root.', 'thinkrank'),
2076 - 'settings'
2077 - );
2690 +
2691 + return true;
2078 2692 }
2693 +
2694 + $this->record_regeneration_failure(
2695 + $this->write_failure_message(),
2696 + 'settings'
2697 + );
2698 +
2699 + return false;
2079 2700 } catch (\Throwable $e) {
2080 - $this->record_regeneration_failure($e->getMessage(), 'settings');
2701 + $this->record_thrown_regeneration_failure($e, 'settings');
2702 +
2703 + return false;
2081 2704 } finally {
2082 2705 $this->release_generation_lock();
2083 2706 }
2084 2707 }
@@ -2083,8 +2706,27 @@
2083 2706 }
2084 2707 }
2085 2708
2086 2709 /**
2710 + * When a rebuild has been outstanding since, or 0 when none is.
2711 + *
2712 + * Lets a caller report an honest "saved, but the served file has not caught
2713 + * up yet" instead of a bare success (#764).
2714 + *
2715 + * @since 2.10.0
2716 + * @return int Unix timestamp, or 0 when nothing is pending.
2717 + */
2718 + public static function regeneration_pending_since(): int {
2719 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2720 +
2721 + if (!is_array($pending) || empty($pending['since'])) {
2722 + return 0;
2723 + }
2724 +
2725 + return (int) $pending['since'];
2726 + }
2727 +
2728 + /**
2087 2729 * Remove every static sitemap file ThinkRank publishes to the web root.
2088 2730 *
2089 2731 * Called when the sitemap feature is disabled, by the cleanup route, and by
2090 2732 * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
@@ -2183,10 +2825,17 @@
2183 2825 return;
2184 2826 }
2185 2827
2186 2828 $revision = $this->current_regeneration_revision();
2829 + $settings = $this->get_settings('site');
2187 2830
2188 - if ($this->generate_and_save($this->get_settings('site'))) {
2831 + // See regenerate_sitemap_from_settings(): nothing to write.
2832 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2833 + $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2834 + return;
2835 + }
2836 +
2837 + if ($this->generate_for_regeneration($settings)) {
2189 2838 $this->mark_regeneration_complete($revision);
2190 2839 } else {
2191 2840 // Previously this returned quietly and last_generated simply
2192 2841 // stopped advancing, leaving the site owner with no way to learn
@@ -2191,14 +2840,14 @@
2191 2840 // Previously this returned quietly and last_generated simply
2192 2841 // stopped advancing, leaving the site owner with no way to learn
2193 2842 // the sitemap had stopped updating (#629).
2194 2843 $this->record_regeneration_failure(
2195 - __('The sitemap files could not be written to the site root.', 'thinkrank'),
2844 + $this->write_failure_message(),
2196 2845 'content'
2197 2846 );
2198 2847 }
2199 2848 } catch (\Throwable $e) {
2200 - $this->record_regeneration_failure($e->getMessage(), 'content');
2849 + $this->record_thrown_regeneration_failure($e, 'content');
2201 2850 } finally {
2202 2851 $this->release_generation_lock();
2203 2852 }
2204 2853 }
@@ -2215,8 +2864,26 @@
2215 2864 * @param array $settings Sitemap settings.
2216 2865 * @return bool True when the sitemap files were written.
2217 2866 */
2218 2867 public function generate_and_save(array $settings): bool {
2868 + // Dynamic delivery publishes no files, so writing them here would put a
2869 + // static copy back in the web root for the server to serve in place of
2870 + // the dynamic route. Guarding at each call site left gaps — the
2871 + // snapshot migrator's post-import regeneration had none — so the rule
2872 + // lives with the writing instead.
2873 + //
2874 + // `is_collecting()` is the exception that makes dynamic delivery work
2875 + // at all: render_document() and collect_documents() reach this same
2876 + // method with the writer swapped for a collector, and that is precisely
2877 + // the dynamic build. Only a real write is skipped.
2878 + if (!$this->is_collecting() && 'dynamic' === $this->resolve_delivery_mode($settings)) {
2879 + // Whatever prompted this call changed the sitemap's content, so the
2880 + // rendered copies must not outlive it.
2881 + $this->flush_dynamic_cache();
2882 +
2883 + return true;
2884 + }
2885 +
2219 2886 // Index mode is driven by the use_sitemap_index toggle (not merely by how
2220 2887 // many sitemap_urls happen to be configured). When the toggle is on but
2221 2888 // no child sitemaps are set up yet, synthesize the per-type segmented set
2222 2889 // so we emit a real <sitemapindex> with paginated children instead of a
@@ -2243,9 +2910,13 @@
2243 2910 // names is never touched (#515).
2244 2911 $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
2245 2912 }
2246 2913
2247 - if ($written) {
2914 + // last_generated describes what is on disk. A dynamic render publishes
2915 + // nothing, so advancing it would report a static publication that never
2916 + // happened and would let primary_sitemap_file_exists() callers believe
2917 + // there is a file to serve.
2918 + if ($written && !$this->is_collecting()) {
2248 2919 $settings['last_generated'] = gmdate('c');
2249 2920 $this->save_settings('site', null, $settings);
2250 2921 }
2251 2922
@@ -2252,8 +2923,433 @@
2252 2923 return $written;
2253 2924 }
2254 2925
2255 2926 /**
2927 + * Whether this instance is rendering documents rather than publishing them.
2928 + *
2929 + * @since 2.9.0
2930 + *
2931 + * @return bool
2932 + */
2933 + private function is_collecting(): bool {
2934 + return $this->document_sink !== null;
2935 + }
2936 +
2937 + /**
2938 + * Complete a regeneration that delivers dynamically, retiring stale files.
2939 + *
2940 + * Dynamic delivery renders nothing to disk, but that is only half the job.
2941 + * A web server hands back an existing `/sitemap.xml` without ever loading
2942 + * WordPress, so any file left over from a previous static generation goes on
2943 + * being served forever and {@see \ThinkRank\Frontend\SEO_Manager
2944 + * ::maybe_serve_sitemap()} is never reached. Switching to dynamic while
2945 + * leaving those files in place would therefore appear to do nothing at all.
2946 + *
2947 + * Both transitions matter and they differ:
2948 + *
2949 + * - An explicit switch to `dynamic` happens on a site whose root is usually
2950 + * still writable, so the files can simply be removed.
2951 + * - An `auto` site that becomes read-only cannot remove them, because
2952 + * deleting an entry needs write permission on the directory that holds
2953 + * it. There the stale sitemap really is stuck in front of us, and the
2954 + * honest outcome is a recorded failure naming it rather than a rebuild
2955 + * reported as complete (#754 review).
2956 + *
2957 + * Ownership is tested per file by the shared helper, so another plugin's
2958 + * sitemap at one of our names is never deleted (#515).
2959 + *
2960 + * @since 2.9.0
2961 + *
2962 + * @param array $settings Sitemap settings.
2963 + * @param int $revision Revision this rebuild is completing.
2964 + * @param string $source 'settings' or 'content', for the failure record.
2965 + * @return void
2966 + */
2967 + private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): bool {
2968 + $this->flush_dynamic_cache();
2969 +
2970 + $removal = $this->delete_published_sitemaps($settings);
2971 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2972 +
2973 + if (!empty($stuck)) {
2974 + $this->record_regeneration_failure($this->stuck_files_message($stuck), $source);
2975 +
2976 + return false;
2977 + }
2978 +
2979 + $this->mark_regeneration_complete($revision);
2980 +
2981 + return true;
2982 + }
2983 +
2984 + /**
2985 + * Why a stale file left in the web root means the change has not landed.
2986 + *
2987 + * Shared by every path that removes published files, so they cannot
2988 + * describe the same situation differently (#764).
2989 + *
2990 + * The two situations that reach it differ in what WordPress is doing, and
2991 + * the message has to say which. After a switch to dynamic delivery
2992 + * WordPress IS serving the sitemap and the files shadow it. After the
2993 + * sitemap is switched off WordPress serves nothing, so the one message
2994 + * used to tell a site owner who had just disabled the sitemap that it was
2995 + * "being served from WordPress", which is the opposite of what they did.
2996 + *
2997 + * @since 2.10.0
2998 + * @since 2.10.0 Public, so the REST endpoint uses it rather than a copy;
2999 + * takes $sitemap_disabled for the disabled path.
3000 + *
3001 + * @param string[] $stuck Basenames that could not be removed.
3002 + * @param bool $sitemap_disabled True when the files outlived disabling
3003 + * the sitemap rather than a switch to
3004 + * dynamic delivery.
3005 + * @return string
3006 + */
3007 + public function stuck_files_message(array $stuck, bool $sitemap_disabled = false): string {
3008 + if ($sitemap_disabled) {
3009 + return sprintf(
3010 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
3011 + __('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'),
3012 + implode(', ', $stuck),
3013 + untrailingslashit(ABSPATH)
3014 + );
3015 + }
3016 +
3017 + return sprintf(
3018 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
3019 + __('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'),
3020 + implode(', ', $stuck),
3021 + untrailingslashit(ABSPATH)
3022 + );
3023 + }
3024 +
3025 + /**
3026 + * What to tell the site owner when publishing the files failed.
3027 + *
3028 + * The old wording stated the symptom and stopped there, so the reported
3029 + * cause was a guess and this reached support as a plugin fault rather than
3030 + * a folder permission (#752, #753). When the root is demonstrably
3031 + * unwritable, say that, and say what to do about it.
3032 + *
3033 + * @since 2.9.0
3034 + *
3035 + * @return string
3036 + */
3037 + private function write_failure_message(): string {
3038 + if (!wp_is_writable(ABSPATH)) {
3039 + return sprintf(
3040 + /* translators: %s: absolute path to the WordPress root. */
3041 + __('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'),
3042 + untrailingslashit(ABSPATH)
3043 + );
3044 + }
3045 +
3046 + return __('The sitemap files could not be written to the site root.', 'thinkrank');
3047 + }
3048 +
3049 + /**
3050 + * How this site delivers its sitemap.
3051 + *
3052 + * `auto` is resolved on whether the web root can be written. That is the
3053 + * right signal here (unlike llms.txt, where the question is whether the
3054 + * server applies the .htaccess charset block): a site whose root is
3055 + * read-only cannot publish a sitemap file at all, and before this existed
3056 + * the feature simply failed with "The sitemap files could not be written to
3057 + * the site root." and served nothing (#752).
3058 + *
3059 + * @since 2.9.0
3060 + *
3061 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3062 + * @return string One of 'static' or 'dynamic'. Never 'auto'.
3063 + */
3064 + public function resolve_delivery_mode(?array $settings = null): string {
3065 + $settings = $settings ?? $this->get_settings('site');
3066 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
3067 +
3068 + if ('static' === $mode || 'dynamic' === $mode) {
3069 + return $mode;
3070 + }
3071 +
3072 + return wp_is_writable(ABSPATH) ? 'static' : 'dynamic';
3073 + }
3074 +
3075 + /**
3076 + * Render one published sitemap document without touching the filesystem.
3077 + *
3078 + * Runs the ordinary build pipeline with the writer swapped for a collector,
3079 + * so the bytes returned here are the bytes the static path would have
3080 + * written. `SitemapDeliveryParityTest` asserts that equivalence rather than
3081 + * trusting it.
3082 + *
3083 + * The whole set is built to answer for one file, because the index can only
3084 + * be assembled from the children that were actually produced. The result is
3085 + * cached per document, so that cost is paid once per change and not once
3086 + * per crawler request.
3087 + *
3088 + * @since 2.9.0
3089 + *
3090 + * @param string $filename Published file name, e.g. 'sitemap.xml'.
3091 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3092 + * @return string|null XML, or null when this site does not publish that name.
3093 + */
3094 + public function render_document(string $filename, ?array $settings = null): ?string {
3095 + $filename = basename($filename);
3096 + $settings = $settings ?? $this->get_settings('site');
3097 +
3098 + if (empty($settings['enabled'])) {
3099 + return null;
3100 + }
3101 +
3102 + $cached = get_transient($this->dynamic_cache_key($filename));
3103 + if (self::ABSENT_MARKER === $cached) {
3104 + return null;
3105 + }
3106 + if (is_string($cached) && '' !== $cached) {
3107 + return $cached;
3108 + }
3109 +
3110 + // A miss builds the whole set, because the index can only be assembled
3111 + // from the children that were actually produced. Caching only the
3112 + // requested document therefore made a crawler walking the index and its
3113 + // children rebuild the entire site's sitemap once per file — every post
3114 + // and taxonomy query repeated N times on a public endpoint (#754
3115 + // review). The set is built once and stored in full.
3116 + return $this->stream_documents($settings, $filename);
3117 + }
3118 +
3119 + /**
3120 + * Build every document, caching each as it is produced, keeping one.
3121 + *
3122 + * A miss has to build the whole set, because the index can only be
3123 + * assembled from the children that were actually produced. It does not have
3124 + * to *hold* the whole set: the static path never keeps more than one page
3125 + * in memory, writing each to disk as it goes, and buffering every
3126 + * document's XML to return one of them undid that on the request path,
3127 + * where a large site's entire sitemap corpus would sit in a single PHP
3128 + * process (#754 review).
3129 + *
3130 + * So the sink writes each document straight to its cache entry and lets it
3131 + * go, retaining only the one this request is answering. Peak retention is
3132 + * one document, whatever the site's size.
3133 + *
3134 + * Concurrency: the first request through takes a short lock and does the
3135 + * work. One that finds the lock held waits a bounded moment for the winner
3136 + * to publish, then builds anyway, because serving a correct sitemap late
3137 + * beats serving none.
3138 + *
3139 + * @since 2.9.0
3140 + *
3141 + * @param array $settings Sitemap settings.
3142 + * @param string $wanted Document this request is answering.
3143 + * @return string|null XML for $wanted, or null when the site does not publish it.
3144 + */
3145 + private function stream_documents(array $settings, string $wanted): ?string {
3146 + $lock = self::DYNAMIC_CACHE_PREFIX . 'lock';
3147 +
3148 + if (!$this->acquire_render_lock($lock)) {
3149 + for ($attempt = 0; $attempt < self::RENDER_LOCK_WAIT_ATTEMPTS; $attempt++) {
3150 + usleep(self::RENDER_LOCK_WAIT_MICROSECONDS);
3151 +
3152 + $cached = get_transient($this->dynamic_cache_key($wanted));
3153 + if (self::ABSENT_MARKER === $cached) {
3154 + return null;
3155 + }
3156 + if (is_string($cached) && '' !== $cached) {
3157 + return $cached;
3158 + }
3159 + }
3160 + }
3161 +
3162 + $kept = null;
3163 + // Names only. Keeping the bodies here would be the very retention this
3164 + // method exists to avoid.
3165 + $produced = [];
3166 +
3167 + $previous = $this->document_sink;
3168 + $this->document_sink = function (string $name, string $xml) use (&$kept, &$produced, $wanted): void {
3169 + $produced[$name] = true;
3170 + set_transient($this->dynamic_cache_key($name), $xml, self::DYNAMIC_CACHE_TTL);
3171 +
3172 + if ($name === $wanted) {
3173 + $kept = $xml;
3174 + }
3175 + };
3176 +
3177 + try {
3178 + $this->generate_and_save($settings);
3179 +
3180 + // Names the configuration lists but this build did not produce get
3181 + // a negative entry, so asking for one again is a cache hit rather
3182 + // than another full rebuild.
3183 + $absent = $this->published_document_names($settings);
3184 +
3185 + // Also the exact name this request asked for: a paginated page past
3186 + // the end of a stem is a legitimate request shape that the base
3187 + // list cannot enumerate, and without an entry it would rebuild on
3188 + // every hit.
3189 + $absent[] = $wanted;
3190 +
3191 + foreach (array_unique($absent) as $name) {
3192 + if (!isset($produced[$name])) {
3193 + set_transient($this->dynamic_cache_key($name), self::ABSENT_MARKER, self::DYNAMIC_CACHE_TTL);
3194 + }
3195 + }
3196 + } finally {
3197 + $this->document_sink = $previous;
3198 + delete_transient($lock);
3199 + }
3200 +
3201 + return $kept;
3202 + }
3203 +
3204 + /**
3205 + * Take the render lock, if it is free.
3206 + *
3207 + * Not atomic across processes, and deliberately so: the fallback for losing
3208 + * a race is duplicated work, never a wrong or missing sitemap, so a
3209 + * heavier primitive would buy nothing here.
3210 + *
3211 + * @since 2.9.0
3212 + *
3213 + * @param string $lock Lock transient name.
3214 + * @return bool True when this request holds the lock.
3215 + */
3216 + private function acquire_render_lock(string $lock): bool {
3217 + if (false !== get_transient($lock)) {
3218 + return false;
3219 + }
3220 +
3221 + set_transient($lock, time(), self::RENDER_LOCK_TTL);
3222 +
3223 + return true;
3224 + }
3225 +
3226 + /**
3227 + * Build every document this site publishes and return them all.
3228 + *
3229 + * Verification and tooling only. This retains the whole set in memory, so
3230 + * it must never be used to answer a request: {@see self::stream_documents()}
3231 + * is the serving path and keeps one document at a time regardless of site
3232 + * size (#754 review). `SitemapDeliveryParityTest` enforces that separation
3233 + * by failing if the request path routes back through here.
3234 + *
3235 + * @since 2.9.0
3236 + *
3237 + * @param array $settings Sitemap settings.
3238 + * @return array<string,string> Filename => XML.
3239 + */
3240 + public function collect_documents(array $settings): array {
3241 + $documents = [];
3242 +
3243 + $previous = $this->document_sink;
3244 + $this->document_sink = static function (string $name, string $xml) use (&$documents): void {
3245 + $documents[$name] = $xml;
3246 + };
3247 +
3248 + try {
3249 + $this->generate_and_save($settings);
3250 + } finally {
3251 + $this->document_sink = $previous;
3252 + }
3253 +
3254 + return $documents;
3255 + }
3256 +
3257 + /**
3258 + * The file names this site publishes, without building their contents.
3259 + *
3260 + * Used by the request router to decide whether a URL is ours before doing
3261 + * any work. Cheap: it reads the configured child list rather than querying
3262 + * for entries.
3263 + *
3264 + * @since 2.9.0
3265 + *
3266 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3267 + * @return string[] File names, including paginated pages that may exist.
3268 + */
3269 + public function published_document_names(?array $settings = null): array {
3270 + $settings = $settings ?? $this->get_settings('site');
3271 + $resolved = $this->maybe_promote_to_index($settings);
3272 +
3273 + $names = [$this->get_primary_sitemap_filename($settings), 'local-sitemap.xml'];
3274 +
3275 + foreach ((array) ($resolved['sitemap_urls'] ?? []) as $child) {
3276 + if (!is_array($child) || empty($child['enabled'])) {
3277 + continue;
3278 + }
3279 +
3280 + $path = (string) wp_parse_url((string) ($child['url'] ?? ''), PHP_URL_PATH);
3281 + if ('' !== $path) {
3282 + $names[] = basename($path);
3283 + }
3284 + }
3285 +
3286 + return array_values(array_unique(array_filter($names)));
3287 + }
3288 +
3289 + /**
3290 + * Does this site publish a document under that name?
3291 + *
3292 + * Not a plain membership test against {@see self::published_document_names()}:
3293 + * that lists the configured children, and a child over the per-file URL cap
3294 + * is split into `<stem>-2.xml`, `<stem>-3.xml` and so on, with every page
3295 + * listed in the index. Gating the request router on the base list alone
3296 + * therefore 404'd exactly the pages the index points at, which is worse than
3297 + * not serving them at all.
3298 + *
3299 + * Page counts are not knowable without building, so the stem is what is
3300 + * matched; a page that does not exist is answered by the build finding
3301 + * nothing for it, and is then cached as absent.
3302 + *
3303 + * @since 2.9.0
3304 + *
3305 + * @param string $name Requested file name.
3306 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3307 + * @return bool
3308 + */
3309 + public function publishes_document_name(string $name, ?array $settings = null): bool {
3310 + $names = $this->published_document_names($settings);
3311 +
3312 + if (in_array($name, $names, true)) {
3313 + return true;
3314 + }
3315 +
3316 + if (!preg_match('/^(.*)-\d+\.xml$/i', $name, $m)) {
3317 + return false;
3318 + }
3319 +
3320 + return in_array($m[1] . '.xml', $names, true);
3321 + }
3322 +
3323 + /**
3324 + * Transient key for a rendered document.
3325 + *
3326 + * @since 2.9.0
3327 + *
3328 + * @param string $filename Published file name.
3329 + * @return string
3330 + */
3331 + private function dynamic_cache_key(string $filename): string {
3332 + return self::DYNAMIC_CACHE_PREFIX . md5($filename);
3333 + }
3334 +
3335 + /**
3336 + * Drop every cached dynamic document.
3337 + *
3338 + * Called from the same places that mark the static files stale, so the two
3339 + * delivery modes invalidate on identical triggers.
3340 + *
3341 + * @since 2.9.0
3342 + *
3343 + * @return void
3344 + */
3345 + public function flush_dynamic_cache(): void {
3346 + foreach ($this->published_document_names() as $name) {
3347 + delete_transient($this->dynamic_cache_key($name));
3348 + }
3349 + }
3350 +
3351 + /**
2256 3352 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
2257 3353 *
2258 3354 * - When use_sitemap_index is on but no child sitemaps are configured, build
2259 3355 * the per-type segmented set so a real <sitemapindex> is produced (#127).
@@ -2448,8 +3544,18 @@
2448 3544 // File validation failed - error details available in exception
2449 3545 return false;
2450 3546 }
2451 3547
3548 + // Dynamic delivery: hand the document to the collector instead of the
3549 + // filesystem. Reported as published, because for this run it is — the
3550 + // caller's success/failure bookkeeping and the index assembly both key
3551 + // off this return value.
3552 + if ($this->document_sink !== null) {
3553 + ($this->document_sink)($filename, $sitemap_xml);
3554 +
3555 + return true;
3556 + }
3557 +
2452 3558 $sitemap_path = ABSPATH . $filename;
2453 3559
2454 3560 // Use WordPress filesystem API for better security
2455 3561 global $wp_filesystem;
@@ -2779,12 +3885,30 @@
2779 3885 $buffer = [];
2780 3886 }
2781 3887 }
2782 3888
2783 - // Flush the trailing partial page, or a single empty page when the
2784 - // type had no entries at all (parity with the previous behavior of
2785 - // always writing at least one page per configured child).
2786 - if (!empty($buffer) || $page === 0) {
3889 + // Flush the trailing partial page.
3890 + //
3891 + // A type that produced nothing writes no page at all in index
3892 + // mode (#836). It used to write one empty urlset and list it in
3893 + // the index, so a crawler was asked to fetch a file that
3894 + // answers with no URLs — Search Console reports an empty
3895 + // sitemap referenced from an index as a warning, and the fetch
3896 + // is wasted on every pass. On this site three of eleven
3897 + // children were empty: a post type with nothing published and
3898 + // two taxonomies with no terms.
3899 + //
3900 + // Single-file mode still writes its one page even when empty,
3901 + // because that file IS the site's /sitemap.xml and a 404 there
3902 + // is worse than an empty urlset. Nothing lists it, so it costs
3903 + // no crawl budget.
3904 + //
3905 + // Skipping the write also keeps the filename out of
3906 + // $results['sitemaps_generated'], which is what
3907 + // prune_orphaned_segments() treats as "written this run" — so
3908 + // a type that empties after previously publishing has its
3909 + // stale file deleted rather than left serving.
3910 + if (!empty($buffer) || ($page === 0 && $index_config === null)) {
2787 3911 $page++;
2788 3912 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
2789 3913 }
2790 3914
@@ -2863,8 +3987,15 @@
2863 3987 * @param array $generated Entries from $results['sitemaps_generated'].
2864 3988 * @return string[] Basenames removed.
2865 3989 */
2866 3990 private function prune_orphaned_segments(array $settings, array $generated): array {
3991 + // Rendering for a request, not publishing: there is nothing on disk
3992 + // this run owns, and a dynamic render must never delete the files a
3993 + // site's previous static mode left behind.
3994 + if ($this->is_collecting()) {
3995 + return [];
3996 + }
3997 +
2867 3998 $kept = [];
2868 3999 foreach ($generated as $entry) {
2869 4000 if (!empty($entry['filename'])) {
2870 4001 $kept[strtolower((string) $entry['filename'])] = true;
@@ -2966,9 +4097,9 @@
2966 4097 // publishes under too (this method mirrors it deliberately), so on
2967 4098 // a migrated site the file at that path may never have been ours
2968 4099 // to delete (#515).
2969 4100 $path = ABSPATH . 'local-sitemap.xml';
2970 - if (file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
4101 + if (!$this->is_collecting() && file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
2971 4102 wp_delete_file($path);
2972 4103 }
2973 4104 return false;
2974 4105 }
@@ -2990,8 +4121,25 @@
2990 4121 *
2991 4122 * @since 1.15.x
2992 4123 * @return array Zero or one URL entry
2993 4124 */
4125 + /**
4126 + * Does this site publish a local business sitemap right now?
4127 + *
4128 + * The same gate {@see self::regenerate_local_sitemap()} applies, asked
4129 + * without writing anything. Callers that need to know whether the document
4130 + * exists must not test the filesystem: under dynamic delivery it is served
4131 + * from PHP and there is no file, which is how `local-sitemap.xml` came to be
4132 + * dropped from robots.txt on exactly those sites (#752).
4133 + *
4134 + * @since 2.9.0
4135 + *
4136 + * @return bool True when the local sitemap has content to publish.
4137 + */
4138 + public function publishes_local_sitemap(): bool {
4139 + return !empty($this->collect_local_entries());
4140 + }
4141 +
2994 4142 private function collect_local_entries(): array {
2995 4143 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2996 4144 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2997 4145 }
@@ -3152,8 +4300,13 @@
3152 4300 * @param array $settings Sitemap settings, for the ownership test.
3153 4301 * @return void
3154 4302 */
3155 4303 private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
4304 + // See prune_orphaned_segments(): a dynamic render deletes nothing.
4305 + if ($this->is_collecting()) {
4306 + return;
4307 + }
4308 +
3156 4309 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
3157 4310 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
3158 4311 return;
3159 4312 }