PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.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 trunk 1.0.0 All 52 releases
← All changes | includes/seo/class-sitemap-generator.php +1315 -87 2.6.0 → 2.11.0 View file →
@@ -62,8 +62,25 @@
62 62 'include_tags' => 'tags',
63 63 ];
64 64
65 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 + /**
66 83 * Supported sitemap types
67 84 *
68 85 * @since 1.0.0
69 86 * @var array
@@ -127,8 +144,98 @@
127 144 */
128 145 public const REGENERATION_ERROR_OPTION = 'thinkrank_sitemap_regeneration_error';
129 146
130 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 + /**
131 238 * Transient guarding against two generations running at once. Shared with
132 239 * Sitemap_Endpoint's manual generate route so an automatic rebuild and a
133 240 * manual one cannot write the same files concurrently.
134 241 *
@@ -144,8 +251,33 @@
144 251 * @var int
145 252 */
146 253 private const TERM_WALK_CHUNK = 1000;
147 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 +
148 280 private array $sitemap_types = [
149 281 'posts' => [
150 282 'name' => 'Posts',
151 283 'post_types' => ['post'],
@@ -172,25 +304,29 @@
172 304 ]
173 305 ];
174 306
175 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 + /**
176 317 * Constructor
177 318 *
178 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().
179 323 *
180 - * @param bool $register_hooks Optional. Whether to register the auto-generation
181 - * hooks. Pass false for a read-only instance built
182 - * solely to query settings — the hooks are bound to
183 - * `$this`, so a second hook-registering instance
184 - * 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.
185 326 */
186 327 public function __construct(bool $register_hooks = true) {
187 328 parent::__construct('sitemap');
188 -
189 - // Initialize auto-generation hooks
190 - if ($register_hooks) {
191 - $this->init_auto_generation_hooks();
192 - }
193 329 }
194 330
195 331 /**
196 332 * Filter the args of a sitemap post query.
@@ -234,31 +370,57 @@
234 370 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
235 371 }
236 372
237 373 /**
238 - * Initialize WordPress hooks for auto-generation
374 + * Register the content-change listeners that queue an automatic rebuild.
239 375 *
240 - * @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
241 391 * @return void
242 392 */
243 - private function init_auto_generation_hooks(): void {
244 - // Content change hooks - use priority 20 to run after other plugins
245 - add_action('save_post', [$this, 'handle_content_change'], 20, 2);
246 - add_action('delete_post', [$this, 'handle_content_deletion'], 20);
247 - add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
248 - 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);
249 407
250 - // Taxonomy change hooks
251 - add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
252 - add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
253 - 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 + }
254 414
255 - // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
256 - // and thinkrank_regenerate_sitemap_settings) are registered at plugin
257 - // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
258 - // rather than here. A cron run never builds this class via the REST
259 - // endpoint (no rest_api_init), so registering them in the constructor
260 - // 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);
261 423 }
262 424
263 425 /**
264 426 * Generate XML sitemap
@@ -270,9 +432,9 @@
270 432 */
271 433 public function generate_sitemap(array $options = []): string {
272 434 $settings = $this->get_settings('site');
273 435
274 - $xml = $this->xml_prolog($settings, 'sitemap.xsl');
436 + $xml = $this->xml_prolog($settings, 'sitemap');
275 437
276 438 // Add image namespace if images are enabled
277 439 if (!empty($settings['include_images'])) {
278 440 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
@@ -435,8 +597,15 @@
435 597 ]
436 598 ],
437 599 'use_sitemap_index' => false,
438 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 +
439 608 // General Settings
440 609 'links_per_sitemap' => 1000,
441 610 'include_images' => true,
442 611 'include_featured_images' => false,
@@ -458,8 +627,17 @@
458 627 // Advanced Options
459 628 'enable_styling' => true,
460 629 'custom_url_pattern' => 'sitemap-{type}.xml',
461 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 +
462 640 // Generation tracking
463 641 'last_generated' => ''
464 642 ];
465 643 }
@@ -464,8 +642,49 @@
464 642 ];
465 643 }
466 644
467 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 + /**
468 687 * Get settings schema definition (implements interface)
469 688 *
470 689 * @since 1.0.0
471 690 *
@@ -479,8 +698,15 @@
479 698 'title' => 'Enable Sitemap',
480 699 'description' => 'Generate XML sitemap for search engines',
481 700 'default' => true
482 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 + ],
483 709 'include_posts' => [
484 710 'type' => 'boolean',
485 711 'title' => 'Include Posts',
486 712 'description' => 'Include blog posts in sitemap',
@@ -521,8 +747,32 @@
521 747 'type' => 'string',
522 748 'title' => 'Last Generated',
523 749 'description' => 'Timestamp of last sitemap generation',
524 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' => ''
525 775 ]
526 776 ];
527 777 }
528 778
@@ -539,9 +789,14 @@
539 789 * @return string XML URL entry
540 790 */
541 791 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
542 792 $xml = " <url>\n";
543 - $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";
544 799 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
545 800 // than no timestamp, and an absent lastmod is valid per the spec.
546 801 if (!empty($lastmod)) {
547 802 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
@@ -551,9 +806,9 @@
551 806
552 807 // Add image entries if provided
553 808 foreach ($images as $image) {
554 809 $xml .= " <image:image>\n";
555 - $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";
556 811
557 812 if (!empty($image['title'])) {
558 813 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
559 814 }
@@ -674,8 +929,11 @@
674 929 // well-bounded routine with no ceiling (#402).
675 930 $total = count($all_ids);
676 931
677 932 for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
933 + $this->assert_memory_headroom();
934 + $chunk_start = memory_get_usage(true);
935 +
678 936 $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
679 937
680 938 $posts = get_posts($this->filter_query_args([
681 939 'post_type' => $post_types,
@@ -684,8 +942,12 @@
684 942 'post__in' => $chunk,
685 943 'orderby' => 'post__in', // preserve the resolved order
686 944 ]));
687 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 +
688 950 foreach ($posts as $post) {
689 951 if ($this->should_include_in_sitemap($post, $settings)) {
690 952 /**
691 953 * Filter a sitemap entry's permalink.
@@ -706,14 +968,25 @@
706 968 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
707 969 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
708 970 $images = $this->extract_post_images($post, $settings);
709 971
972 + $thumbnail_id = (int) get_post_thumbnail_id($post);
973 + if ($thumbnail_id > 0) {
974 + $attachment_ids[] = $thumbnail_id;
975 + }
976 +
710 977 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
711 978 }
712 979 }
713 980
714 - // 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.
715 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 + );
716 989 }
717 990 }
718 991
719 992 /**
@@ -773,8 +1046,11 @@
773 1046 // Same moving window as the post walk above, for the same reason.
774 1047 $total = count($all_ids);
775 1048
776 1049 for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
1050 + $this->assert_memory_headroom();
1051 + $chunk_start = memory_get_usage(true);
1052 +
777 1053 $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
778 1054
779 1055 $terms = get_terms($this->filter_term_query_args([
780 1056 'taxonomy' => $taxonomy,
@@ -803,8 +1079,9 @@
803 1079 }
804 1080 }
805 1081
806 1082 unset($terms);
1083 + $this->release_walk_memory($chunk_start, ['terms', 'term_meta'], $chunk);
807 1084 }
808 1085 }
809 1086
810 1087 /**
@@ -819,19 +1096,25 @@
819 1096 * kept a shadowing file behind (#510) or had a competitor's deleted.
820 1097 *
821 1098 * @since 2.1.1
822 1099 *
823 - * @param array $settings Sitemap settings (read for `enable_styling`).
824 - * @param string $stylesheet Stylesheet basename in static/xsl/.
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`.
825 1108 * @return string Prolog lines, newline-terminated.
826 1109 */
827 - private function xml_prolog(array $settings, string $stylesheet): string {
1110 + private function xml_prolog(array $settings, string $variant): string {
828 1111 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
829 1112 $xml .= THINKRANK_SITEMAP_MARKER . "\n";
830 1113
831 1114 // The stylesheet is presentation only, so it stays opt-in.
832 1115 if (!empty($settings['enable_styling'])) {
833 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/' . $stylesheet) . '"?>' . "\n";
1116 + $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url(Sitemap_Stylesheet::url($variant)) . '"?>' . "\n";
834 1117 }
835 1118
836 1119 return $xml;
837 1120 }
@@ -846,9 +1129,9 @@
846 1129 * @param bool $with_image_ns Include the image sitemap namespace
847 1130 * @return string Full sitemap XML
848 1131 */
849 1132 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
850 - $xml = $this->xml_prolog($settings, 'sitemap.xsl');
1133 + $xml = $this->xml_prolog($settings, 'sitemap');
851 1134
852 1135 if ($with_image_ns && !empty($settings['include_images'])) {
853 1136 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
854 1137 } else {
@@ -1635,8 +1918,14 @@
1635 1918 * @param string $source Either 'content' or 'settings'.
1636 1919 * @return void
1637 1920 */
1638 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 +
1639 1928 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1640 1929 $pending = is_array($pending) ? $pending : [];
1641 1930
1642 1931 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
@@ -1642,11 +1931,13 @@
1642 1931 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1643 1932 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1644 1933 $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1645 1934
1935 + // Merged so the bookkeeping a rebuild keeps on the marker (`started`,
1936 + // `memory_limit`) survives an edit made while it is outstanding.
1646 1937 update_option(
1647 1938 self::REGENERATION_PENDING_OPTION,
1648 - [
1939 + array_merge($pending, [
1649 1940 'since' => $since,
1650 1941 'source' => $source,
1651 1942 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
1652 1943 'next_attempt' => !empty($pending['next_attempt'])
@@ -1654,9 +1945,9 @@
1654 1945 : time() + self::REGENERATION_TAKEOVER_GRACE,
1655 1946 // Bumped on every change so a rebuild can tell whether the edit
1656 1947 // it started for is still the newest one outstanding.
1657 1948 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
1658 - ],
1949 + ]),
1659 1950 true
1660 1951 );
1661 1952 }
1662 1953
@@ -1702,8 +1993,18 @@
1702 1993 $revision !== null
1703 1994 && is_array($pending)
1704 1995 && (int) ($pending['revision'] ?? 0) !== $revision
1705 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);
1706 2007 return;
1707 2008 }
1708 2009
1709 2010 delete_option(self::REGENERATION_PENDING_OPTION);
@@ -1709,8 +2010,69 @@
1709 2010 delete_option(self::REGENERATION_PENDING_OPTION);
1710 2011 }
1711 2012
1712 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 + /**
1713 2075 * Record a failed regeneration instead of discarding it.
1714 2076 *
1715 2077 * Keeps the pending marker in place so the rebuild is retried, but backs the
1716 2078 * next attempt off exponentially (capped) so a persistently failing
@@ -1716,22 +2078,30 @@
1716 2078 * next attempt off exponentially (capped) so a persistently failing
1717 2079 * generation cannot run on every admin request.
1718 2080 *
1719 2081 * @since 2.2.1
1720 - * @param string $message Failure detail.
1721 - * @param string $source Either 'content' or 'settings'.
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.
1722 2089 * @return void
1723 2090 */
1724 - private function record_regeneration_failure(string $message, string $source): void {
2091 + private function record_regeneration_failure(string $message, string $source, ?int $memory_limit = null): void {
1725 2092 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1726 2093 $pending = is_array($pending) ? $pending : [];
1727 - $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2094 + $attempts = !empty($pending['attempts']) ? (int) $pending['attempts'] : 0;
1728 2095
1729 - $backoff = min(
1730 - self::REGENERATION_TAKEOVER_GRACE * (2 ** min($attempts, 10)),
1731 - self::REGENERATION_MAX_BACKOFF
1732 - );
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);
1733 2101
2102 + $backoff = $this->regeneration_backoff($attempts);
2103 +
1734 2104 // Same precedence mark_regeneration_pending() enforces: a settings
1735 2105 // rebuild outranks a content one and must not be downgraded by a failed
1736 2106 // attempt. Overwriting it routed the retry back through the content
1737 2107 // path, where should_auto_generate() can be false and the completion
@@ -1740,20 +2110,24 @@
1740 2110 // whichever attempt actually failed.
1741 2111 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1742 2112 $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1743 2113
1744 - update_option(
1745 - self::REGENERATION_PENDING_OPTION,
1746 - [
1747 - 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
1748 - 'source' => $pending_source,
1749 - 'attempts' => $attempts,
1750 - 'next_attempt' => time() + $backoff,
1751 - 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
1752 - ],
1753 - true
1754 - );
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 + ];
1755 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 +
1756 2130 update_option(
1757 2131 self::REGENERATION_ERROR_OPTION,
1758 2132 [
1759 2133 'message' => $message,
@@ -1808,16 +2182,34 @@
1808 2182 if (!self::has_overdue_regeneration()) {
1809 2183 return;
1810 2184 }
1811 2185
1812 - // Cron is running: it is about to do exactly this work.
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.
1813 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)) {
1814 2209 return;
1815 2210 }
1816 2211
1817 - $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1818 - $source = (is_array($pending) && isset($pending['source'])) ? (string) $pending['source'] : 'content';
1819 -
1820 2212 if ($source === 'settings') {
1821 2213 $this->regenerate_sitemap_from_settings();
1822 2214 return;
1823 2215 }
@@ -1851,8 +2243,227 @@
1851 2243 delete_transient(self::GENERATION_LOCK_TRANSIENT);
1852 2244 }
1853 2245
1854 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 + __('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 + size_format($usage),
2414 + size_format($limit)
2415 + ),
2416 + self::MEMORY_ABORT_CODE
2417 + );
2418 + }
2419 + }
2420 +
2421 + /**
2422 + * Run an automatic rebuild's generation with the fatal-safe bookkeeping.
2423 + *
2424 + * @since 2.10.1
2425 + * @param array $settings Sitemap settings.
2426 + * @return bool Whatever generate_and_save() returned.
2427 + * @throws \Throwable Whatever generation throws, after the memory guard is
2428 + * switched back off.
2429 + */
2430 + private function generate_for_regeneration(array $settings): bool {
2431 + // Same headroom wp-admin gives itself; a no-op when the limit is
2432 + // already higher or unlimited.
2433 + wp_raise_memory_limit('admin');
2434 +
2435 + $this->claim_regeneration_attempt();
2436 + $this->memory_guard = true;
2437 + $this->walk_chunk_cost = 0;
2438 +
2439 + try {
2440 + return $this->generate_and_save($settings);
2441 + } finally {
2442 + $this->memory_guard = false;
2443 + }
2444 + }
2445 +
2446 + /**
2447 + * Record a failure thrown by an automatic rebuild.
2448 + *
2449 + * @since 2.10.1
2450 + * @param \Throwable $e What was thrown.
2451 + * @param string $source Either 'content' or 'settings'.
2452 + * @return void
2453 + */
2454 + private function record_thrown_regeneration_failure(\Throwable $e, string $source): void {
2455 + $memory_limit = null;
2456 +
2457 + if ($e instanceof \Error && $e->getCode() === self::MEMORY_ABORT_CODE) {
2458 + $memory_limit = $this->current_memory_limit();
2459 + $memory_limit = $memory_limit > 0 ? $memory_limit : null;
2460 + }
2461 +
2462 + $this->record_regeneration_failure($e->getMessage(), $source, $memory_limit);
2463 + }
2464 +
2465 + /**
1855 2466 * Report how automatic regeneration is faring, for the admin UI.
1856 2467 *
1857 2468 * The feature used to fail invisibly: `last_generated` simply stopped
1858 2469 * advancing and nothing drew attention to it (#629).
@@ -1950,16 +2561,20 @@
1950 2561 * auto_generate setting: the user deliberately changed inclusion rules and
1951 2562 * expects the served file to reflect them even if content-triggered
1952 2563 * auto-generation is turned off. Still respects the master `enabled` flag.
1953 2564 *
1954 - * @return void
2565 + * @since 2.10.0 Reports whether the served sitemap was actually rebuilt, so
2566 + * a caller can say so rather than assume it (#764). Existing
2567 + * callers that ignore the return are unaffected.
2568 + *
2569 + * @return bool True when the served sitemap now reflects the settings.
1955 2570 */
1956 - public function regenerate_sitemap_from_settings(): void {
2571 + public function regenerate_sitemap_from_settings(): bool {
1957 2572 if (!$this->acquire_generation_lock()) {
1958 2573 // A manual generation (or another request's takeover) is already
1959 2574 // writing the files; the pending marker survives so this rebuild is
1960 2575 // retried rather than lost.
1961 - return;
2576 + return false;
1962 2577 }
1963 2578
1964 2579 try {
1965 2580 $settings = $this->get_settings('site');
@@ -1966,25 +2581,56 @@
1966 2581 if (empty($settings['enabled'])) {
1967 2582 // The sitemap was disabled: remove the previously generated static
1968 2583 // files so the web server stops serving a stale sitemap that
1969 2584 // crawlers would otherwise keep fetching.
1970 - $this->delete_published_sitemaps();
2585 + //
2586 + // A file that could not be removed is still being served, so
2587 + // this is not a success. Reporting one here would tell a caller
2588 + // the sitemap was gone while the web server kept answering with
2589 + // it, which is the failure this return value exists to prevent
2590 + // (#764).
2591 + $removal = $this->delete_published_sitemaps($settings);
2592 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2593 +
2594 + if (!empty($stuck)) {
2595 + $this->record_regeneration_failure(
2596 + $this->stuck_files_message($stuck, true),
2597 + 'settings'
2598 + );
2599 +
2600 + return false;
2601 + }
2602 +
1971 2603 $this->mark_regeneration_complete();
1972 - return;
2604 +
2605 + return true;
1973 2606 }
1974 2607
1975 2608 $revision = $this->current_regeneration_revision();
1976 2609
1977 - if ($this->generate_and_save($settings)) {
2610 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2611 + // Returns false when a static file is stuck in the web root:
2612 + // the server keeps serving that file in preference to WordPress,
2613 + // so the switch has not taken effect (#764).
2614 + return $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2615 + }
2616 +
2617 + if ($this->generate_for_regeneration($settings)) {
1978 2618 $this->mark_regeneration_complete($revision);
1979 - } else {
1980 - $this->record_regeneration_failure(
1981 - __('The sitemap files could not be written to the site root.', 'thinkrank'),
1982 - 'settings'
1983 - );
2619 +
2620 + return true;
1984 2621 }
2622 +
2623 + $this->record_regeneration_failure(
2624 + $this->write_failure_message(),
2625 + 'settings'
2626 + );
2627 +
2628 + return false;
1985 2629 } catch (\Throwable $e) {
1986 - $this->record_regeneration_failure($e->getMessage(), 'settings');
2630 + $this->record_thrown_regeneration_failure($e, 'settings');
2631 +
2632 + return false;
1987 2633 } finally {
1988 2634 $this->release_generation_lock();
1989 2635 }
1990 2636 }
@@ -1989,8 +2635,27 @@
1989 2635 }
1990 2636 }
1991 2637
1992 2638 /**
2639 + * When a rebuild has been outstanding since, or 0 when none is.
2640 + *
2641 + * Lets a caller report an honest "saved, but the served file has not caught
2642 + * up yet" instead of a bare success (#764).
2643 + *
2644 + * @since 2.10.0
2645 + * @return int Unix timestamp, or 0 when nothing is pending.
2646 + */
2647 + public static function regeneration_pending_since(): int {
2648 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2649 +
2650 + if (!is_array($pending) || empty($pending['since'])) {
2651 + return 0;
2652 + }
2653 +
2654 + return (int) $pending['since'];
2655 + }
2656 +
2657 + /**
1993 2658 * Remove every static sitemap file ThinkRank publishes to the web root.
1994 2659 *
1995 2660 * Called when the sitemap feature is disabled, by the cleanup route, and by
1996 2661 * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
@@ -2089,10 +2754,17 @@
2089 2754 return;
2090 2755 }
2091 2756
2092 2757 $revision = $this->current_regeneration_revision();
2758 + $settings = $this->get_settings('site');
2093 2759
2094 - if ($this->generate_and_save($this->get_settings('site'))) {
2760 + // See regenerate_sitemap_from_settings(): nothing to write.
2761 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2762 + $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2763 + return;
2764 + }
2765 +
2766 + if ($this->generate_for_regeneration($settings)) {
2095 2767 $this->mark_regeneration_complete($revision);
2096 2768 } else {
2097 2769 // Previously this returned quietly and last_generated simply
2098 2770 // stopped advancing, leaving the site owner with no way to learn
@@ -2097,14 +2769,14 @@
2097 2769 // Previously this returned quietly and last_generated simply
2098 2770 // stopped advancing, leaving the site owner with no way to learn
2099 2771 // the sitemap had stopped updating (#629).
2100 2772 $this->record_regeneration_failure(
2101 - __('The sitemap files could not be written to the site root.', 'thinkrank'),
2773 + $this->write_failure_message(),
2102 2774 'content'
2103 2775 );
2104 2776 }
2105 2777 } catch (\Throwable $e) {
2106 - $this->record_regeneration_failure($e->getMessage(), 'content');
2778 + $this->record_thrown_regeneration_failure($e, 'content');
2107 2779 } finally {
2108 2780 $this->release_generation_lock();
2109 2781 }
2110 2782 }
@@ -2121,8 +2793,26 @@
2121 2793 * @param array $settings Sitemap settings.
2122 2794 * @return bool True when the sitemap files were written.
2123 2795 */
2124 2796 public function generate_and_save(array $settings): bool {
2797 + // Dynamic delivery publishes no files, so writing them here would put a
2798 + // static copy back in the web root for the server to serve in place of
2799 + // the dynamic route. Guarding at each call site left gaps — the
2800 + // snapshot migrator's post-import regeneration had none — so the rule
2801 + // lives with the writing instead.
2802 + //
2803 + // `is_collecting()` is the exception that makes dynamic delivery work
2804 + // at all: render_document() and collect_documents() reach this same
2805 + // method with the writer swapped for a collector, and that is precisely
2806 + // the dynamic build. Only a real write is skipped.
2807 + if (!$this->is_collecting() && 'dynamic' === $this->resolve_delivery_mode($settings)) {
2808 + // Whatever prompted this call changed the sitemap's content, so the
2809 + // rendered copies must not outlive it.
2810 + $this->flush_dynamic_cache();
2811 +
2812 + return true;
2813 + }
2814 +
2125 2815 // Index mode is driven by the use_sitemap_index toggle (not merely by how
2126 2816 // many sitemap_urls happen to be configured). When the toggle is on but
2127 2817 // no child sitemaps are set up yet, synthesize the per-type segmented set
2128 2818 // so we emit a real <sitemapindex> with paginated children instead of a
@@ -2149,9 +2839,13 @@
2149 2839 // names is never touched (#515).
2150 2840 $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
2151 2841 }
2152 2842
2153 - if ($written) {
2843 + // last_generated describes what is on disk. A dynamic render publishes
2844 + // nothing, so advancing it would report a static publication that never
2845 + // happened and would let primary_sitemap_file_exists() callers believe
2846 + // there is a file to serve.
2847 + if ($written && !$this->is_collecting()) {
2154 2848 $settings['last_generated'] = gmdate('c');
2155 2849 $this->save_settings('site', null, $settings);
2156 2850 }
2157 2851
@@ -2158,8 +2852,433 @@
2158 2852 return $written;
2159 2853 }
2160 2854
2161 2855 /**
2856 + * Whether this instance is rendering documents rather than publishing them.
2857 + *
2858 + * @since 2.9.0
2859 + *
2860 + * @return bool
2861 + */
2862 + private function is_collecting(): bool {
2863 + return $this->document_sink !== null;
2864 + }
2865 +
2866 + /**
2867 + * Complete a regeneration that delivers dynamically, retiring stale files.
2868 + *
2869 + * Dynamic delivery renders nothing to disk, but that is only half the job.
2870 + * A web server hands back an existing `/sitemap.xml` without ever loading
2871 + * WordPress, so any file left over from a previous static generation goes on
2872 + * being served forever and {@see \ThinkRank\Frontend\SEO_Manager
2873 + * ::maybe_serve_sitemap()} is never reached. Switching to dynamic while
2874 + * leaving those files in place would therefore appear to do nothing at all.
2875 + *
2876 + * Both transitions matter and they differ:
2877 + *
2878 + * - An explicit switch to `dynamic` happens on a site whose root is usually
2879 + * still writable, so the files can simply be removed.
2880 + * - An `auto` site that becomes read-only cannot remove them, because
2881 + * deleting an entry needs write permission on the directory that holds
2882 + * it. There the stale sitemap really is stuck in front of us, and the
2883 + * honest outcome is a recorded failure naming it rather than a rebuild
2884 + * reported as complete (#754 review).
2885 + *
2886 + * Ownership is tested per file by the shared helper, so another plugin's
2887 + * sitemap at one of our names is never deleted (#515).
2888 + *
2889 + * @since 2.9.0
2890 + *
2891 + * @param array $settings Sitemap settings.
2892 + * @param int $revision Revision this rebuild is completing.
2893 + * @param string $source 'settings' or 'content', for the failure record.
2894 + * @return void
2895 + */
2896 + private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): bool {
2897 + $this->flush_dynamic_cache();
2898 +
2899 + $removal = $this->delete_published_sitemaps($settings);
2900 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2901 +
2902 + if (!empty($stuck)) {
2903 + $this->record_regeneration_failure($this->stuck_files_message($stuck), $source);
2904 +
2905 + return false;
2906 + }
2907 +
2908 + $this->mark_regeneration_complete($revision);
2909 +
2910 + return true;
2911 + }
2912 +
2913 + /**
2914 + * Why a stale file left in the web root means the change has not landed.
2915 + *
2916 + * Shared by every path that removes published files, so they cannot
2917 + * describe the same situation differently (#764).
2918 + *
2919 + * The two situations that reach it differ in what WordPress is doing, and
2920 + * the message has to say which. After a switch to dynamic delivery
2921 + * WordPress IS serving the sitemap and the files shadow it. After the
2922 + * sitemap is switched off WordPress serves nothing, so the one message
2923 + * used to tell a site owner who had just disabled the sitemap that it was
2924 + * "being served from WordPress", which is the opposite of what they did.
2925 + *
2926 + * @since 2.10.0
2927 + * @since 2.10.0 Public, so the REST endpoint uses it rather than a copy;
2928 + * takes $sitemap_disabled for the disabled path.
2929 + *
2930 + * @param string[] $stuck Basenames that could not be removed.
2931 + * @param bool $sitemap_disabled True when the files outlived disabling
2932 + * the sitemap rather than a switch to
2933 + * dynamic delivery.
2934 + * @return string
2935 + */
2936 + public function stuck_files_message(array $stuck, bool $sitemap_disabled = false): string {
2937 + if ($sitemap_disabled) {
2938 + return sprintf(
2939 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2940 + __('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'),
2941 + implode(', ', $stuck),
2942 + untrailingslashit(ABSPATH)
2943 + );
2944 + }
2945 +
2946 + return sprintf(
2947 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2948 + __('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'),
2949 + implode(', ', $stuck),
2950 + untrailingslashit(ABSPATH)
2951 + );
2952 + }
2953 +
2954 + /**
2955 + * What to tell the site owner when publishing the files failed.
2956 + *
2957 + * The old wording stated the symptom and stopped there, so the reported
2958 + * cause was a guess and this reached support as a plugin fault rather than
2959 + * a folder permission (#752, #753). When the root is demonstrably
2960 + * unwritable, say that, and say what to do about it.
2961 + *
2962 + * @since 2.9.0
2963 + *
2964 + * @return string
2965 + */
2966 + private function write_failure_message(): string {
2967 + if (!wp_is_writable(ABSPATH)) {
2968 + return sprintf(
2969 + /* translators: %s: absolute path to the WordPress root. */
2970 + __('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'),
2971 + untrailingslashit(ABSPATH)
2972 + );
2973 + }
2974 +
2975 + return __('The sitemap files could not be written to the site root.', 'thinkrank');
2976 + }
2977 +
2978 + /**
2979 + * How this site delivers its sitemap.
2980 + *
2981 + * `auto` is resolved on whether the web root can be written. That is the
2982 + * right signal here (unlike llms.txt, where the question is whether the
2983 + * server applies the .htaccess charset block): a site whose root is
2984 + * read-only cannot publish a sitemap file at all, and before this existed
2985 + * the feature simply failed with "The sitemap files could not be written to
2986 + * the site root." and served nothing (#752).
2987 + *
2988 + * @since 2.9.0
2989 + *
2990 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2991 + * @return string One of 'static' or 'dynamic'. Never 'auto'.
2992 + */
2993 + public function resolve_delivery_mode(?array $settings = null): string {
2994 + $settings = $settings ?? $this->get_settings('site');
2995 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
2996 +
2997 + if ('static' === $mode || 'dynamic' === $mode) {
2998 + return $mode;
2999 + }
3000 +
3001 + return wp_is_writable(ABSPATH) ? 'static' : 'dynamic';
3002 + }
3003 +
3004 + /**
3005 + * Render one published sitemap document without touching the filesystem.
3006 + *
3007 + * Runs the ordinary build pipeline with the writer swapped for a collector,
3008 + * so the bytes returned here are the bytes the static path would have
3009 + * written. `SitemapDeliveryParityTest` asserts that equivalence rather than
3010 + * trusting it.
3011 + *
3012 + * The whole set is built to answer for one file, because the index can only
3013 + * be assembled from the children that were actually produced. The result is
3014 + * cached per document, so that cost is paid once per change and not once
3015 + * per crawler request.
3016 + *
3017 + * @since 2.9.0
3018 + *
3019 + * @param string $filename Published file name, e.g. 'sitemap.xml'.
3020 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3021 + * @return string|null XML, or null when this site does not publish that name.
3022 + */
3023 + public function render_document(string $filename, ?array $settings = null): ?string {
3024 + $filename = basename($filename);
3025 + $settings = $settings ?? $this->get_settings('site');
3026 +
3027 + if (empty($settings['enabled'])) {
3028 + return null;
3029 + }
3030 +
3031 + $cached = get_transient($this->dynamic_cache_key($filename));
3032 + if (self::ABSENT_MARKER === $cached) {
3033 + return null;
3034 + }
3035 + if (is_string($cached) && '' !== $cached) {
3036 + return $cached;
3037 + }
3038 +
3039 + // A miss builds the whole set, because the index can only be assembled
3040 + // from the children that were actually produced. Caching only the
3041 + // requested document therefore made a crawler walking the index and its
3042 + // children rebuild the entire site's sitemap once per file — every post
3043 + // and taxonomy query repeated N times on a public endpoint (#754
3044 + // review). The set is built once and stored in full.
3045 + return $this->stream_documents($settings, $filename);
3046 + }
3047 +
3048 + /**
3049 + * Build every document, caching each as it is produced, keeping one.
3050 + *
3051 + * A miss has to build the whole set, because the index can only be
3052 + * assembled from the children that were actually produced. It does not have
3053 + * to *hold* the whole set: the static path never keeps more than one page
3054 + * in memory, writing each to disk as it goes, and buffering every
3055 + * document's XML to return one of them undid that on the request path,
3056 + * where a large site's entire sitemap corpus would sit in a single PHP
3057 + * process (#754 review).
3058 + *
3059 + * So the sink writes each document straight to its cache entry and lets it
3060 + * go, retaining only the one this request is answering. Peak retention is
3061 + * one document, whatever the site's size.
3062 + *
3063 + * Concurrency: the first request through takes a short lock and does the
3064 + * work. One that finds the lock held waits a bounded moment for the winner
3065 + * to publish, then builds anyway, because serving a correct sitemap late
3066 + * beats serving none.
3067 + *
3068 + * @since 2.9.0
3069 + *
3070 + * @param array $settings Sitemap settings.
3071 + * @param string $wanted Document this request is answering.
3072 + * @return string|null XML for $wanted, or null when the site does not publish it.
3073 + */
3074 + private function stream_documents(array $settings, string $wanted): ?string {
3075 + $lock = self::DYNAMIC_CACHE_PREFIX . 'lock';
3076 +
3077 + if (!$this->acquire_render_lock($lock)) {
3078 + for ($attempt = 0; $attempt < self::RENDER_LOCK_WAIT_ATTEMPTS; $attempt++) {
3079 + usleep(self::RENDER_LOCK_WAIT_MICROSECONDS);
3080 +
3081 + $cached = get_transient($this->dynamic_cache_key($wanted));
3082 + if (self::ABSENT_MARKER === $cached) {
3083 + return null;
3084 + }
3085 + if (is_string($cached) && '' !== $cached) {
3086 + return $cached;
3087 + }
3088 + }
3089 + }
3090 +
3091 + $kept = null;
3092 + // Names only. Keeping the bodies here would be the very retention this
3093 + // method exists to avoid.
3094 + $produced = [];
3095 +
3096 + $previous = $this->document_sink;
3097 + $this->document_sink = function (string $name, string $xml) use (&$kept, &$produced, $wanted): void {
3098 + $produced[$name] = true;
3099 + set_transient($this->dynamic_cache_key($name), $xml, self::DYNAMIC_CACHE_TTL);
3100 +
3101 + if ($name === $wanted) {
3102 + $kept = $xml;
3103 + }
3104 + };
3105 +
3106 + try {
3107 + $this->generate_and_save($settings);
3108 +
3109 + // Names the configuration lists but this build did not produce get
3110 + // a negative entry, so asking for one again is a cache hit rather
3111 + // than another full rebuild.
3112 + $absent = $this->published_document_names($settings);
3113 +
3114 + // Also the exact name this request asked for: a paginated page past
3115 + // the end of a stem is a legitimate request shape that the base
3116 + // list cannot enumerate, and without an entry it would rebuild on
3117 + // every hit.
3118 + $absent[] = $wanted;
3119 +
3120 + foreach (array_unique($absent) as $name) {
3121 + if (!isset($produced[$name])) {
3122 + set_transient($this->dynamic_cache_key($name), self::ABSENT_MARKER, self::DYNAMIC_CACHE_TTL);
3123 + }
3124 + }
3125 + } finally {
3126 + $this->document_sink = $previous;
3127 + delete_transient($lock);
3128 + }
3129 +
3130 + return $kept;
3131 + }
3132 +
3133 + /**
3134 + * Take the render lock, if it is free.
3135 + *
3136 + * Not atomic across processes, and deliberately so: the fallback for losing
3137 + * a race is duplicated work, never a wrong or missing sitemap, so a
3138 + * heavier primitive would buy nothing here.
3139 + *
3140 + * @since 2.9.0
3141 + *
3142 + * @param string $lock Lock transient name.
3143 + * @return bool True when this request holds the lock.
3144 + */
3145 + private function acquire_render_lock(string $lock): bool {
3146 + if (false !== get_transient($lock)) {
3147 + return false;
3148 + }
3149 +
3150 + set_transient($lock, time(), self::RENDER_LOCK_TTL);
3151 +
3152 + return true;
3153 + }
3154 +
3155 + /**
3156 + * Build every document this site publishes and return them all.
3157 + *
3158 + * Verification and tooling only. This retains the whole set in memory, so
3159 + * it must never be used to answer a request: {@see self::stream_documents()}
3160 + * is the serving path and keeps one document at a time regardless of site
3161 + * size (#754 review). `SitemapDeliveryParityTest` enforces that separation
3162 + * by failing if the request path routes back through here.
3163 + *
3164 + * @since 2.9.0
3165 + *
3166 + * @param array $settings Sitemap settings.
3167 + * @return array<string,string> Filename => XML.
3168 + */
3169 + public function collect_documents(array $settings): array {
3170 + $documents = [];
3171 +
3172 + $previous = $this->document_sink;
3173 + $this->document_sink = static function (string $name, string $xml) use (&$documents): void {
3174 + $documents[$name] = $xml;
3175 + };
3176 +
3177 + try {
3178 + $this->generate_and_save($settings);
3179 + } finally {
3180 + $this->document_sink = $previous;
3181 + }
3182 +
3183 + return $documents;
3184 + }
3185 +
3186 + /**
3187 + * The file names this site publishes, without building their contents.
3188 + *
3189 + * Used by the request router to decide whether a URL is ours before doing
3190 + * any work. Cheap: it reads the configured child list rather than querying
3191 + * for entries.
3192 + *
3193 + * @since 2.9.0
3194 + *
3195 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3196 + * @return string[] File names, including paginated pages that may exist.
3197 + */
3198 + public function published_document_names(?array $settings = null): array {
3199 + $settings = $settings ?? $this->get_settings('site');
3200 + $resolved = $this->maybe_promote_to_index($settings);
3201 +
3202 + $names = [$this->get_primary_sitemap_filename($settings), 'local-sitemap.xml'];
3203 +
3204 + foreach ((array) ($resolved['sitemap_urls'] ?? []) as $child) {
3205 + if (!is_array($child) || empty($child['enabled'])) {
3206 + continue;
3207 + }
3208 +
3209 + $path = (string) wp_parse_url((string) ($child['url'] ?? ''), PHP_URL_PATH);
3210 + if ('' !== $path) {
3211 + $names[] = basename($path);
3212 + }
3213 + }
3214 +
3215 + return array_values(array_unique(array_filter($names)));
3216 + }
3217 +
3218 + /**
3219 + * Does this site publish a document under that name?
3220 + *
3221 + * Not a plain membership test against {@see self::published_document_names()}:
3222 + * that lists the configured children, and a child over the per-file URL cap
3223 + * is split into `<stem>-2.xml`, `<stem>-3.xml` and so on, with every page
3224 + * listed in the index. Gating the request router on the base list alone
3225 + * therefore 404'd exactly the pages the index points at, which is worse than
3226 + * not serving them at all.
3227 + *
3228 + * Page counts are not knowable without building, so the stem is what is
3229 + * matched; a page that does not exist is answered by the build finding
3230 + * nothing for it, and is then cached as absent.
3231 + *
3232 + * @since 2.9.0
3233 + *
3234 + * @param string $name Requested file name.
3235 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3236 + * @return bool
3237 + */
3238 + public function publishes_document_name(string $name, ?array $settings = null): bool {
3239 + $names = $this->published_document_names($settings);
3240 +
3241 + if (in_array($name, $names, true)) {
3242 + return true;
3243 + }
3244 +
3245 + if (!preg_match('/^(.*)-\d+\.xml$/i', $name, $m)) {
3246 + return false;
3247 + }
3248 +
3249 + return in_array($m[1] . '.xml', $names, true);
3250 + }
3251 +
3252 + /**
3253 + * Transient key for a rendered document.
3254 + *
3255 + * @since 2.9.0
3256 + *
3257 + * @param string $filename Published file name.
3258 + * @return string
3259 + */
3260 + private function dynamic_cache_key(string $filename): string {
3261 + return self::DYNAMIC_CACHE_PREFIX . md5($filename);
3262 + }
3263 +
3264 + /**
3265 + * Drop every cached dynamic document.
3266 + *
3267 + * Called from the same places that mark the static files stale, so the two
3268 + * delivery modes invalidate on identical triggers.
3269 + *
3270 + * @since 2.9.0
3271 + *
3272 + * @return void
3273 + */
3274 + public function flush_dynamic_cache(): void {
3275 + foreach ($this->published_document_names() as $name) {
3276 + delete_transient($this->dynamic_cache_key($name));
3277 + }
3278 + }
3279 +
3280 + /**
2162 3281 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
2163 3282 *
2164 3283 * - When use_sitemap_index is on but no child sitemaps are configured, build
2165 3284 * the per-type segmented set so a real <sitemapindex> is produced (#127).
@@ -2354,8 +3473,18 @@
2354 3473 // File validation failed - error details available in exception
2355 3474 return false;
2356 3475 }
2357 3476
3477 + // Dynamic delivery: hand the document to the collector instead of the
3478 + // filesystem. Reported as published, because for this run it is — the
3479 + // caller's success/failure bookkeeping and the index assembly both key
3480 + // off this return value.
3481 + if ($this->document_sink !== null) {
3482 + ($this->document_sink)($filename, $sitemap_xml);
3483 +
3484 + return true;
3485 + }
3486 +
2358 3487 $sitemap_path = ABSPATH . $filename;
2359 3488
2360 3489 // Use WordPress filesystem API for better security
2361 3490 global $wp_filesystem;
@@ -2430,8 +3559,38 @@
2430 3559
2431 3560 $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
2432 3561 }
2433 3562
3563 + // Public custom taxonomies get the same treatment (#690). Flat mode has
3564 + // always walked them through get_enabled_taxonomies(); index mode built
3565 + // its children from the list above and never consulted a taxonomy at
3566 + // all, so every custom-taxonomy archive silently vanished from the
3567 + // sitemap the moment a site switched modes — and the per-taxonomy switch
3568 + // the matrix writes had nothing to act on. Same two tests the post-type
3569 + // walk applies, in the same order.
3570 + $taken = array_column($urls, 'type');
3571 +
3572 + foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
3573 + if (!$this->should_include_taxonomy($taxonomy)) {
3574 + continue;
3575 + }
3576 +
3577 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $inclusions)) {
3578 + continue;
3579 + }
3580 +
3581 + // Post types and taxonomies are separate registries, so a site can
3582 + // hold both a `foo` post type and a `foo` taxonomy. They would
3583 + // resolve to one filename, and stream_type_entries() answers post
3584 + // types first, so the second child would list the first one's file
3585 + // twice in the index rather than adding anything.
3586 + if (in_array($taxonomy, $taken, true)) {
3587 + continue;
3588 + }
3589 +
3590 + $urls[] = $this->build_child_sitemap_entry($taxonomy, $pattern);
3591 + }
3592 +
2434 3593 return $urls;
2435 3594 }
2436 3595
2437 3596 /**
@@ -2588,8 +3747,22 @@
2588 3747 && !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $type, $settings)) {
2589 3748 continue;
2590 3749 }
2591 3750
3751 + // The taxonomy counterpart of the two guards above (#690). A child
3752 + // list saved while a taxonomy was still included keeps naming it, so
3753 + // without this, excluding one in the matrix would still rewrite and
3754 + // re-list the file the user asked not to have. The built-in
3755 + // aggregates are named 'categories'/'tags' rather than
3756 + // 'category'/'post_tag', so taxonomy_exists() leaves them to the
3757 + // inclusion-flag check below.
3758 + $child_taxonomy = self::CHILD_TYPE_ALIASES[$type] ?? $type;
3759 + if (taxonomy_exists($child_taxonomy)
3760 + && (!$this->should_include_taxonomy($child_taxonomy)
3761 + || !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $child_taxonomy, $settings))) {
3762 + continue;
3763 + }
3764 +
2592 3765 // The built-in aggregates carry their switch in the inclusion flag
2593 3766 // rather than under a post type name, and they are the four most
2594 3767 // people actually use. build_segmented_sitemap_urls() drops a child
2595 3768 // whose flag is empty; regenerating from a list saved while it was
@@ -2725,8 +3898,15 @@
2725 3898 * @param array $generated Entries from $results['sitemaps_generated'].
2726 3899 * @return string[] Basenames removed.
2727 3900 */
2728 3901 private function prune_orphaned_segments(array $settings, array $generated): array {
3902 + // Rendering for a request, not publishing: there is nothing on disk
3903 + // this run owns, and a dynamic render must never delete the files a
3904 + // site's previous static mode left behind.
3905 + if ($this->is_collecting()) {
3906 + return [];
3907 + }
3908 +
2729 3909 $kept = [];
2730 3910 foreach ($generated as $entry) {
2731 3911 if (!empty($entry['filename'])) {
2732 3912 $kept[strtolower((string) $entry['filename'])] = true;
@@ -2828,9 +4008,9 @@
2828 4008 // publishes under too (this method mirrors it deliberately), so on
2829 4009 // a migrated site the file at that path may never have been ours
2830 4010 // to delete (#515).
2831 4011 $path = ABSPATH . 'local-sitemap.xml';
2832 - if (file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
4012 + if (!$this->is_collecting() && file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
2833 4013 wp_delete_file($path);
2834 4014 }
2835 4015 return false;
2836 4016 }
@@ -2852,8 +4032,25 @@
2852 4032 *
2853 4033 * @since 1.15.x
2854 4034 * @return array Zero or one URL entry
2855 4035 */
4036 + /**
4037 + * Does this site publish a local business sitemap right now?
4038 + *
4039 + * The same gate {@see self::regenerate_local_sitemap()} applies, asked
4040 + * without writing anything. Callers that need to know whether the document
4041 + * exists must not test the filesystem: under dynamic delivery it is served
4042 + * from PHP and there is no file, which is how `local-sitemap.xml` came to be
4043 + * dropped from robots.txt on exactly those sites (#752).
4044 + *
4045 + * @since 2.9.0
4046 + *
4047 + * @return bool True when the local sitemap has content to publish.
4048 + */
4049 + public function publishes_local_sitemap(): bool {
4050 + return !empty($this->collect_local_entries());
4051 + }
4052 +
2856 4053 private function collect_local_entries(): array {
2857 4054 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2858 4055 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2859 4056 }
@@ -2925,17 +4122,43 @@
2925 4122 case 'products':
2926 4123 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
2927 4124 return ['entries' => $entries, 'image_ns' => true];
2928 4125
2929 - case 'product_categories':
2930 - $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
2931 - return ['entries' => $entries, 'image_ns' => false];
4126 + default:
4127 + // Resolve a preset's display name to the object it streams, so
4128 + // 'product_categories' is an ordinary taxonomy child rather than
4129 + // a case of its own (#690).
4130 + $alias = self::CHILD_TYPE_ALIASES[$type] ?? null;
4131 + $object = $alias ?? $type;
2932 4132
2933 - default:
2934 4133 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2935 - if (in_array($type, $custom_post_types, true)) {
2936 - return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
4134 + if (in_array($object, $custom_post_types, true)) {
4135 + return ['entries' => $this->collect_post_entries_iter([$object], $settings), 'image_ns' => true];
2937 4136 }
4137 +
4138 + // Custom taxonomies reach index mode here, streamed through the
4139 + // same iterator flat mode uses so the two modes emit identical
4140 + // URLs for the same settings.
4141 + if (taxonomy_exists($object) && $this->should_include_taxonomy($object)) {
4142 + return ['entries' => $this->collect_taxonomy_entries_iter($object, $settings), 'image_ns' => false];
4143 + }
4144 +
4145 + // An aliased child whose object is gone (WooCommerce deactivated)
4146 + // keeps writing the empty file it always wrote. Returning null
4147 + // here would hand it the whole-site fallback instead, dumping
4148 + // every URL on the site into a file named for products.
4149 + //
4150 + // A registered taxonomy this generator will not emit
4151 + // (`post_format`, `nav_menu`, a non-public one) needs the same
4152 + // answer for the same reason. generate_multiple_sitemaps()
4153 + // skips those before they reach here, so nothing takes this
4154 + // path today — but it is the one branch where falling through
4155 + // to null is silently catastrophic rather than merely wrong,
4156 + // and the guard keeping it unreachable lives in another method.
4157 + if ($alias !== null || taxonomy_exists($object)) {
4158 + return ['entries' => [], 'image_ns' => false];
4159 + }
4160 +
2938 4161 return null;
2939 4162 }
2940 4163 }
2941 4164
@@ -2988,8 +4211,13 @@
2988 4211 * @param array $settings Sitemap settings, for the ownership test.
2989 4212 * @return void
2990 4213 */
2991 4214 private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
4215 + // See prune_orphaned_segments(): a dynamic render deletes nothing.
4216 + if ($this->is_collecting()) {
4217 + return;
4218 + }
4219 +
2992 4220 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2993 4221 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2994 4222 return;
2995 4223 }
@@ -3114,9 +4342,9 @@
3114 4342 * @param array $settings Sitemap settings
3115 4343 * @return string Sitemap index XML
3116 4344 */
3117 4345 private function generate_sitemap_index(array $children, array $settings): string {
3118 - $xml = $this->xml_prolog($settings, 'sitemap-index.xsl');
4346 + $xml = $this->xml_prolog($settings, 'index');
3119 4347 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
3120 4348
3121 4349 $site_url = home_url();
3122 4350
@@ -3129,9 +4357,9 @@
3129 4357 $sitemap_url = $site_url . $sitemap_url;
3130 4358 }
3131 4359
3132 4360 $xml .= " <sitemap>\n";
3133 - $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
4361 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($sitemap_url)) . "</loc>\n";
3134 4362 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
3135 4363 $xml .= " </sitemap>\n";
3136 4364 }
3137 4365