PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
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 1.27.0 All 56 releases
← All changes | includes/seo/class-sitemap-generator.php +2144 -151 2.1.0 → 2.14.1 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
@@ -76,8 +93,159 @@
76 93 */
77 94 private const ID_WALK_CHUNK = 500;
78 95
79 96 /**
97 + * How long a content or settings change is debounced before the sitemap is
98 + * rebuilt, in seconds. Coalesces bulk edits into a single regeneration.
99 + *
100 + * @since 2.2.1
101 + * @var int
102 + */
103 + private const REGENERATION_DEBOUNCE = 30;
104 +
105 + /**
106 + * How far past its due time the scheduled rebuild may sit before a request
107 + * takes it over, in seconds.
108 + *
109 + * WP-Cron is request-driven, so on a site running DISABLE_WP_CRON, blocking
110 + * loopback requests, or seeing very little traffic the event never fires and
111 + * the sitemap silently stops updating (#629). The grace keeps the fast path
112 + * (cron) in charge under normal conditions.
113 + *
114 + * @since 2.2.1
115 + * @var int
116 + */
117 + private const REGENERATION_TAKEOVER_GRACE = 120;
118 +
119 + /**
120 + * Longest backoff between takeover attempts after a failed rebuild, so a
121 + * persistently failing generation cannot run on every admin request.
122 + *
123 + * @since 2.2.1
124 + * @var int
125 + */
126 + private const REGENERATION_MAX_BACKOFF = 3600;
127 +
128 + /**
129 + * Option holding the rebuild that content/settings changes are still waiting
130 + * on: `since`, `source` ('content'|'settings'), `attempts`, `next_attempt`.
131 + * Absent means the served sitemap is up to date with what triggered it.
132 + *
133 + * @since 2.2.1
134 + * @var string
135 + */
136 + public const REGENERATION_PENDING_OPTION = 'thinkrank_sitemap_regeneration_pending';
137 +
138 + /**
139 + * Option holding the last automatic-regeneration failure (`message`,
140 + * `source`, `time`), so the failure is visible instead of swallowed.
141 + *
142 + * @since 2.2.1
143 + * @var string
144 + */
145 + public const REGENERATION_ERROR_OPTION = 'thinkrank_sitemap_regeneration_error';
146 +
147 + /**
148 + * Delivery modes accepted by the `delivery_mode` setting.
149 + *
150 + * Mirrors LLMs_Txt_Manager::DELIVERY_MODES, which solved the same problem
151 + * for llms.txt. `auto` is the only value a site should normally need.
152 + *
153 + * @since 2.9.0
154 + * @var string[]
155 + */
156 + public const DELIVERY_MODES = ['auto', 'static', 'dynamic'];
157 +
158 + /**
159 + * Cache group for documents rendered on the dynamic path.
160 + *
161 + * @since 2.9.0
162 + * @var string
163 + */
164 + private const DYNAMIC_CACHE_PREFIX = 'thinkrank_sitemap_doc_';
165 +
166 + /**
167 + * How long a dynamically rendered document is cached.
168 + *
169 + * Invalidated by content and settings changes through
170 + * {@see self::flush_dynamic_cache()}, so this is only the backstop for a
171 + * change nothing hooked.
172 + *
173 + * @since 2.9.0
174 + * @var int
175 + */
176 + private const DYNAMIC_CACHE_TTL = 12 * HOUR_IN_SECONDS;
177 +
178 + /**
179 + * How long the render lock is held before it is assumed abandoned.
180 + *
181 + * Long enough for a large site's full build, short enough that a request
182 + * killed mid-build does not lock the endpoint out for meaningfully long.
183 + *
184 + * @since 2.9.0
185 + * @var int
186 + */
187 + private const RENDER_LOCK_TTL = 60;
188 +
189 + /**
190 + * Cached stand-in for "this site does not publish that name".
191 + *
192 + * published_document_names() lists what the configuration *could* produce,
193 + * but a child whose type is excluded produces nothing. Without a negative
194 + * entry those names miss the cache forever, so every request for one
195 + * rebuilt the entire sitemap — the same cost the positive cache exists to
196 + * avoid, on a public endpoint (#754 review).
197 + *
198 + * @since 2.9.0
199 + * @var string
200 + */
201 + private const ABSENT_MARKER = "\0thinkrank-absent";
202 +
203 + /**
204 + * How many times, and how long, a losing request waits for the winner.
205 + *
206 + * Bounded at roughly a second in total: past that, building a second copy
207 + * costs less than making a crawler wait.
208 + *
209 + * @since 2.9.0
210 + * @var int
211 + */
212 + private const RENDER_LOCK_WAIT_ATTEMPTS = 4;
213 +
214 + /**
215 + * @since 2.9.0
216 + * @var int
217 + */
218 + private const RENDER_LOCK_WAIT_MICROSECONDS = 250000;
219 +
220 + /**
221 + * Where generated documents go instead of disk, when set.
222 + *
223 + * Every sitemap document this class produces — segments, the index, the
224 + * single flat file and local-sitemap.xml — is published through the one
225 + * writer, {@see self::save_sitemap_to_file()}. Swapping that writer for a
226 + * collector is therefore all it takes to render the same bytes without a
227 + * filesystem, which is what dynamic delivery needs (#752). Doing it here
228 + * rather than duplicating the build pipeline is deliberate: a second
229 + * pipeline would drift from this one, and the index in particular is
230 + * assembled from whatever the children actually produced.
231 + *
232 + * @since 2.9.0
233 + * @var callable|null
234 + */
235 + private $document_sink = null;
236 +
237 + /**
238 + * Transient guarding against two generations running at once. Shared with
239 + * Sitemap_Endpoint's manual generate route so an automatic rebuild and a
240 + * manual one cannot write the same files concurrently.
241 + *
242 + * @since 2.2.1
243 + * @var string
244 + */
245 + public const GENERATION_LOCK_TRANSIENT = 'thinkrank_sitemap_generation_lock';
246 +
247 + /**
80 248 * How many term IDs to hydrate at a time while walking a taxonomy.
81 249 *
82 250 * @since 2.0.1
83 251 * @var int
@@ -83,8 +251,33 @@
83 251 * @var int
84 252 */
85 253 private const TERM_WALK_CHUNK = 1000;
86 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 +
87 280 private array $sitemap_types = [
88 281 'posts' => [
89 282 'name' => 'Posts',
90 283 'post_types' => ['post'],
@@ -111,25 +304,29 @@
111 304 ]
112 305 ];
113 306
114 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 + /**
115 317 * Constructor
116 318 *
117 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().
118 323 *
119 - * @param bool $register_hooks Optional. Whether to register the auto-generation
120 - * hooks. Pass false for a read-only instance built
121 - * solely to query settings — the hooks are bound to
122 - * `$this`, so a second hook-registering instance
123 - * 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.
124 326 */
125 327 public function __construct(bool $register_hooks = true) {
126 328 parent::__construct('sitemap');
127 -
128 - // Initialize auto-generation hooks
129 - if ($register_hooks) {
130 - $this->init_auto_generation_hooks();
131 - }
132 329 }
133 330
134 331 /**
135 332 * Filter the args of a sitemap post query.
@@ -173,34 +370,154 @@
173 370 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
174 371 }
175 372
176 373 /**
177 - * Initialize WordPress hooks for auto-generation
374 + * Register the content-change listeners that queue an automatic rebuild.
178 375 *
179 - * @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
180 391 * @return void
181 392 */
182 - private function init_auto_generation_hooks(): void {
183 - // Content change hooks - use priority 20 to run after other plugins
184 - add_action('save_post', [$this, 'handle_content_change'], 20, 2);
185 - add_action('delete_post', [$this, 'handle_content_deletion'], 20);
186 - add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
187 - 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);
188 407
189 - // Taxonomy change hooks
190 - add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
191 - add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
192 - 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 + }
193 413
194 - // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
195 - // and thinkrank_regenerate_sitemap_settings) are registered at plugin
196 - // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
197 - // rather than here. A cron run never builds this class via the REST
198 - // endpoint (no rest_api_init), so registering them in the constructor
199 - // 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);
200 431 }
201 432
202 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 + /**
203 520 * Generate XML sitemap
204 521 *
205 522 * @since 1.0.0
206 523 *
@@ -209,15 +526,10 @@
209 526 */
210 527 public function generate_sitemap(array $options = []): string {
211 528 $settings = $this->get_settings('site');
212 529
213 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
530 + $xml = $this->xml_prolog($settings, 'sitemap');
214 531
215 - // Add XSL stylesheet only if styling is enabled
216 - if (!empty($settings['enable_styling'])) {
217 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
218 - }
219 -
220 532 // Add image namespace if images are enabled
221 533 if (!empty($settings['include_images'])) {
222 534 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
223 535 } else {
@@ -379,8 +691,15 @@
379 691 ]
380 692 ],
381 693 'use_sitemap_index' => false,
382 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 +
383 702 // General Settings
384 703 'links_per_sitemap' => 1000,
385 704 'include_images' => true,
386 705 'include_featured_images' => false,
@@ -402,8 +721,17 @@
402 721 // Advanced Options
403 722 'enable_styling' => true,
404 723 'custom_url_pattern' => 'sitemap-{type}.xml',
405 724
725 + // Stylesheet branding (#639). Both colours default to empty, not
726 + // to the stock hexes: empty means the stylesheet's own value
727 + // stands, so a site that never opens this screen renders exactly
728 + // as it did before the setting existed.
729 + 'styling_logo' => false,
730 + 'styling_logo_url' => '',
731 + 'styling_color_main' => '',
732 + 'styling_color_accent' => '',
733 +
406 734 // Generation tracking
407 735 'last_generated' => ''
408 736 ];
409 737 }
@@ -408,8 +736,49 @@
408 736 ];
409 737 }
410 738
411 739 /**
740 + * Normalize the stylesheet branding values on the way into the store.
741 + *
742 + * The generic sanitizer only runs `sanitize_text_field()` over a string,
743 + * which happily keeps "red" or "rebeccapurple" as a colour. Nothing
744 + * downstream can use those — {@see Sitemap_Stylesheet::render()} skips any
745 + * value it cannot read as a hex colour — so storing them would report a
746 + * successful save of a setting that changes nothing, and `get-sitemap-
747 + * settings` would hand an agent back a colour the sitemap does not use.
748 + * Reducing here instead keeps the store and the rendering in agreement.
749 + *
750 + * @since 2.7.0
751 + *
752 + * @param array $settings Settings to sanitize.
753 + * @param string $context_type Context the save is for.
754 + * @return array
755 + */
756 + protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
757 + $sanitized = parent::sanitize_settings($settings, $context_type);
758 +
759 + foreach (['styling_color_main', 'styling_color_accent'] as $key) {
760 + if (array_key_exists($key, $sanitized)) {
761 + $sanitized[$key] = Sitemap_Stylesheet::hex($sanitized[$key]);
762 + }
763 + }
764 +
765 + if (array_key_exists('styling_logo_url', $sanitized)) {
766 + $sanitized['styling_logo_url'] = esc_url_raw((string) $sanitized['styling_logo_url']);
767 + }
768 +
769 + // A mode this build cannot act on has to be stored as the fallback
770 + // rather than kept verbatim, or get-sitemap-settings reports a delivery
771 + // mode the site does not actually apply.
772 + if (array_key_exists('delivery_mode', $sanitized)) {
773 + $mode = sanitize_key((string) $sanitized['delivery_mode']);
774 + $sanitized['delivery_mode'] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
775 + }
776 +
777 + return $sanitized;
778 + }
779 +
780 + /**
412 781 * Get settings schema definition (implements interface)
413 782 *
414 783 * @since 1.0.0
415 784 *
@@ -423,8 +792,15 @@
423 792 'title' => 'Enable Sitemap',
424 793 'description' => 'Generate XML sitemap for search engines',
425 794 'default' => true
426 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 + ],
427 803 'include_posts' => [
428 804 'type' => 'boolean',
429 805 'title' => 'Include Posts',
430 806 'description' => 'Include blog posts in sitemap',
@@ -465,8 +841,32 @@
465 841 'type' => 'string',
466 842 'title' => 'Last Generated',
467 843 'description' => 'Timestamp of last sitemap generation',
468 844 'default' => ''
845 + ],
846 + 'styling_logo' => [
847 + 'type' => 'boolean',
848 + 'title' => 'Show Logo On Sitemap',
849 + 'description' => 'Show a logo above the sitemap heading',
850 + 'default' => false
851 + ],
852 + 'styling_logo_url' => [
853 + 'type' => 'string',
854 + 'title' => 'Sitemap Logo',
855 + 'description' => 'Logo image URL. Empty falls back to the site icon',
856 + 'default' => ''
857 + ],
858 + 'styling_color_main' => [
859 + 'type' => 'string',
860 + 'title' => 'Sitemap Main Color',
861 + 'description' => 'Hex color for the sitemap header, links and table head. Empty keeps the stock palette',
862 + 'default' => ''
863 + ],
864 + 'styling_color_accent' => [
865 + 'type' => 'string',
866 + 'title' => 'Sitemap Accent Color',
867 + 'description' => 'Hex color for the header gradient and link hovers. Empty keeps the stock palette',
868 + 'default' => ''
469 869 ]
470 870 ];
471 871 }
472 872
@@ -483,9 +883,14 @@
483 883 * @return string XML URL entry
484 884 */
485 885 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
486 886 $xml = " <url>\n";
487 - $xml .= " <loc>" . esc_url($url) . "</loc>\n";
887 + // Every <loc> in every sitemap passes through here, which is why the
888 + // scheme preference is applied at this one point rather than at each
889 + // of the dozen collectors that build URLs (#638). An http sitemap on
890 + // an https site hands search engines the wrong address for the whole
891 + // site at once.
892 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($url)) . "</loc>\n";
488 893 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
489 894 // than no timestamp, and an absent lastmod is valid per the spec.
490 895 if (!empty($lastmod)) {
491 896 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
@@ -495,9 +900,9 @@
495 900
496 901 // Add image entries if provided
497 902 foreach ($images as $image) {
498 903 $xml .= " <image:image>\n";
499 - $xml .= " <image:loc>" . esc_url($image['url']) . "</image:loc>\n";
904 + $xml .= " <image:loc>" . esc_url(Url_Scheme::apply((string) $image['url'])) . "</image:loc>\n";
500 905
501 906 if (!empty($image['title'])) {
502 907 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
503 908 }
@@ -618,8 +1023,11 @@
618 1023 // well-bounded routine with no ceiling (#402).
619 1024 $total = count($all_ids);
620 1025
621 1026 for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
1027 + $this->assert_memory_headroom();
1028 + $chunk_start = memory_get_usage(true);
1029 +
622 1030 $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
623 1031
624 1032 $posts = get_posts($this->filter_query_args([
625 1033 'post_type' => $post_types,
@@ -628,8 +1036,12 @@
628 1036 'post__in' => $chunk,
629 1037 'orderby' => 'post__in', // preserve the resolved order
630 1038 ]));
631 1039
1040 + // get_featured_image() hydrates each featured image under the
1041 + // attachment's own ID, which the chunk's post IDs do not reach.
1042 + $attachment_ids = [];
1043 +
632 1044 foreach ($posts as $post) {
633 1045 if ($this->should_include_in_sitemap($post, $settings)) {
634 1046 /**
635 1047 * Filter a sitemap entry's permalink.
@@ -650,14 +1062,25 @@
650 1062 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
651 1063 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
652 1064 $images = $this->extract_post_images($post, $settings);
653 1065
1066 + $thumbnail_id = (int) get_post_thumbnail_id($post);
1067 + if ($thumbnail_id > 0) {
1068 + $attachment_ids[] = $thumbnail_id;
1069 + }
1070 +
654 1071 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
655 1072 }
656 1073 }
657 1074
658 - // Free the hydrated chunk before loading the next one.
1075 + // Free the hydrated chunk before loading the next one — including
1076 + // the copies get_posts() left in the runtime object cache.
659 1077 unset($posts);
1078 + $this->release_walk_memory(
1079 + $chunk_start,
1080 + $this->chunk_post_cache_groups($post_types),
1081 + array_merge($chunk, $attachment_ids)
1082 + );
660 1083 }
661 1084 }
662 1085
663 1086 /**
@@ -717,8 +1140,11 @@
717 1140 // Same moving window as the post walk above, for the same reason.
718 1141 $total = count($all_ids);
719 1142
720 1143 for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
1144 + $this->assert_memory_headroom();
1145 + $chunk_start = memory_get_usage(true);
1146 +
721 1147 $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
722 1148
723 1149 $terms = get_terms($this->filter_term_query_args([
724 1150 'taxonomy' => $taxonomy,
@@ -731,12 +1157,12 @@
731 1157 continue;
732 1158 }
733 1159
734 1160 foreach ($terms as $term) {
735 - // A term the user marked noindex must not be advertised in the
736 - // sitemap: the robots tag now honours term meta, so listing it
737 - // here would have the sitemap contradict the page's own tag.
738 - if ($this->term_is_noindexed((int) $term->term_id)) {
1161 + // A term whose archive says noindex (its own override or its
1162 + // taxonomy's), or that redirects, must not be advertised: the
1163 + // sitemap would contradict the page's own signal (#911).
1164 + if (!Indexability::is_indexable_term($term)) {
739 1165 continue;
740 1166 }
741 1167
742 1168 $url = get_term_link($term);
@@ -747,12 +1173,48 @@
747 1173 }
748 1174 }
749 1175
750 1176 unset($terms);
1177 + $this->release_walk_memory($chunk_start, ['terms', 'term_meta'], $chunk);
751 1178 }
752 1179 }
753 1180
754 1181 /**
1182 + * The XML declaration, ownership marker and optional stylesheet every
1183 + * sitemap document opens with.
1184 + *
1185 + * The marker is written unconditionally, and that is the point: removal on
1186 + * deactivate and uninstall deletes a web-root sitemap only when the file
1187 + * says it is ours, and our filenames are the canonical ones another SEO
1188 + * plugin writes too (#515). Tying the proof to `enable_styling` — the one
1189 + * marker older versions left — would mean a site with styling off either
1190 + * kept a shadowing file behind (#510) or had a competitor's deleted.
1191 + *
1192 + * @since 2.1.1
1193 + *
1194 + * The stylesheet URL is served by {@see Sitemap_Stylesheet}, not read off
1195 + * disk by the web server, because a static file cannot carry the site's own
1196 + * logo and colours (#639). It is a fixed URL: the palette is applied per
1197 + * request, so changing a brand colour needs no regeneration and shows up on
1198 + * sitemaps published long before.
1199 + *
1200 + * @param array $settings Sitemap settings (read for `enable_styling`).
1201 + * @param string $variant Stylesheet variant, `sitemap` or `index`.
1202 + * @return string Prolog lines, newline-terminated.
1203 + */
1204 + private function xml_prolog(array $settings, string $variant): string {
1205 + $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1206 + $xml .= THINKRANK_SITEMAP_MARKER . "\n";
1207 +
1208 + // The stylesheet is presentation only, so it stays opt-in.
1209 + if (!empty($settings['enable_styling'])) {
1210 + $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url(Sitemap_Stylesheet::url($variant)) . '"?>' . "\n";
1211 + }
1212 +
1213 + return $xml;
1214 + }
1215 +
1216 + /**
755 1217 * Wrap a set of <url> entry strings in a complete <urlset> document.
756 1218 *
757 1219 * @since 1.14.0
758 1220 *
@@ -761,14 +1223,10 @@
761 1223 * @param bool $with_image_ns Include the image sitemap namespace
762 1224 * @return string Full sitemap XML
763 1225 */
764 1226 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
765 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1227 + $xml = $this->xml_prolog($settings, 'sitemap');
766 1228
767 - if (!empty($settings['enable_styling'])) {
768 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
769 - }
770 -
771 1229 if ($with_image_ns && !empty($settings['include_images'])) {
772 1230 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
773 1231 } else {
774 1232 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
@@ -945,9 +1403,16 @@
945 1403 '_builtin' => false
946 1404 ], 'names');
947 1405
948 1406 foreach ($custom_taxonomies as $taxonomy) {
949 - if ($this->should_include_taxonomy($taxonomy)) {
1407 + if (!$this->should_include_taxonomy($taxonomy)) {
1408 + continue;
1409 + }
1410 +
1411 + // Same as the post-type walk above: an explicit per-taxonomy flag
1412 + // now decides, and an unset flag keeps the previous "included"
1413 + // behaviour (#660).
1414 + if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $settings)) {
950 1415 $taxonomies[] = $taxonomy;
951 1416 }
952 1417 }
953 1418
@@ -1002,14 +1467,15 @@
1002 1467 if (is_wp_error($all_terms)) {
1003 1468 return [];
1004 1469 }
1005 1470
1006 - // Drop terms the user marked noindex. This path feeds the single general
1007 - // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
1008 - // so both need the filter or the two disagree about the same term.
1471 + // Drop terms that are not indexable destinations. This path feeds the
1472 + // single general sitemap while collect_taxonomy_entries_iter() feeds
1473 + // the segmented ones, so both need the filter or the two disagree about
1474 + // the same term.
1009 1475 $all_terms = array_values(array_filter(
1010 1476 $all_terms,
1011 - fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1477 + static fn($term) => $term instanceof \WP_Term && Indexability::is_indexable_term($term)
1012 1478 ));
1013 1479
1014 1480 // Group terms by taxonomy
1015 1481 return $this->group_terms_by_taxonomy($all_terms);
@@ -1191,17 +1657,16 @@
1191 1657 if (!in_array($post->post_status, ['publish', 'private'], true)) {
1192 1658 return false;
1193 1659 }
1194 1660
1195 - // Check if post overrides robots and sets noindex.
1196 - if ((bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true)) {
1197 - $raw = get_post_meta($post->ID, '_thinkrank_robots_meta', true);
1198 - if (is_string($raw) && $raw !== '') {
1199 - $robots = json_decode($raw, true);
1200 - if (is_array($robots) && !empty($robots['noindex'])) {
1201 - return false;
1202 - }
1203 - }
1661 + // A URL whose page says noindex, or that ThinkRank redirects, is not a
1662 + // destination. Only the per-post noindex used to be read here, so a
1663 + // post type set to No-index still had every item listed, and redirected
1664 + // posts were submitted as "Page with redirect" (#911). The password
1665 + // check stays with the setting above: listing protected posts is a
1666 + // choice this sitemap has always offered.
1667 + if (Indexability::is_post_noindexed($post) || Indexability::is_post_redirected($post)) {
1668 + return false;
1204 1669 }
1205 1670
1206 1671 return true;
1207 1672 }
@@ -1240,35 +1705,8 @@
1240 1705 return $ids;
1241 1706 }
1242 1707
1243 1708 /**
1244 - * Whether a term carries an explicit noindex override.
1245 - *
1246 - * Mirrors the post-side check in should_include_post(); terms store the same
1247 - * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1248 - * by the update-term-seo ability and by the SEO importer.
1249 - *
1250 - * @since 1.31.0
1251 - *
1252 - * @param int $term_id Term to test.
1253 - * @return bool True when the term is marked noindex.
1254 - */
1255 - private function term_is_noindexed(int $term_id): bool {
1256 - if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1257 - return false;
1258 - }
1259 -
1260 - $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1261 - if (!is_string($raw) || $raw === '') {
1262 - return false;
1263 - }
1264 -
1265 - $robots = json_decode($raw, true);
1266 -
1267 - return is_array($robots) && !empty($robots['noindex']);
1268 - }
1269 -
1270 - /**
1271 1709 * Count total URLs in sitemap
1272 1710 *
1273 1711 * @since 1.0.0
1274 1712 *
@@ -1431,9 +1869,15 @@
1431 1869 if (!empty($settings['include_pages'])) {
1432 1870 $post_types[] = 'page';
1433 1871 }
1434 1872
1435 - // Auto-detect public custom post types that should be included
1873 + // Auto-detect public custom post types that should be included.
1874 + //
1875 + // A custom type's `include_<slug>` / `exclude_<slug>` flag is honoured
1876 + // here (#660). It was previously stored — additional_setting_keys()
1877 + // has always let those keys through — but never read, so a CPT was in
1878 + // the sitemap whatever the setting said. Unset still means included, so
1879 + // a site that never touched the flag is unaffected.
1436 1880 $custom_post_types = get_post_types([
1437 1881 'public' => true,
1438 1882 '_builtin' => false
1439 1883 ], 'names');
@@ -1438,9 +1882,13 @@
1438 1882 '_builtin' => false
1439 1883 ], 'names');
1440 1884
1441 1885 foreach ($custom_post_types as $post_type) {
1442 - if ($this->should_include_post_type($post_type)) {
1886 + if (!$this->should_include_post_type($post_type)) {
1887 + continue;
1888 + }
1889 +
1890 + if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $post_type, $settings)) {
1443 1891 $post_types[] = $post_type;
1444 1892 }
1445 1893 }
1446 1894
@@ -1461,18 +1909,11 @@
1461 1909 // Templately's internal `templately_library` store — which are template
1462 1910 // records, not standalone indexable URLs. BetterDocs `docs` and
1463 1911 // WooCommerce `product` register exclude_from_search => false, so they
1464 1912 // remain included.
1465 - if (!is_post_type_viewable($post_type)) {
1466 - return false;
1467 - }
1468 -
1469 - $post_type_obj = get_post_type_object($post_type);
1470 - if (!$post_type_obj || !empty($post_type_obj->exclude_from_search)) {
1471 - return false;
1472 - }
1473 -
1474 - return true;
1913 + // The predicate lives in Content_Type_Settings so the matrix can ask
1914 + // the same question before offering a switch for this post type.
1915 + return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_post_type($post_type);
1475 1916 }
1476 1917
1477 1918 /**
1478 1919 * Check if taxonomy should trigger regeneration
@@ -1481,11 +1922,13 @@
1481 1922 * @param string $taxonomy Taxonomy slug
1482 1923 * @return bool True if taxonomy should trigger regeneration
1483 1924 */
1484 1925 private function should_include_taxonomy(string $taxonomy): bool {
1485 - // Include all public taxonomies (presets control which sitemaps are created)
1486 - $taxonomy_obj = get_taxonomy($taxonomy);
1487 - return $taxonomy_obj && $taxonomy_obj->public;
1926 + // Public taxonomies only, and only the ones this generator can actually
1927 + // emit — the same predicate the content-type matrix asks before it
1928 + // offers a sitemap switch for one (presets still control which
1929 + // sitemaps are created).
1930 + return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_taxonomy($taxonomy);
1488 1931 }
1489 1932
1490 1933 /**
1491 1934 * Schedule debounced sitemap regeneration
@@ -1493,16 +1936,679 @@
1493 1936 * @since 1.0.0
1494 1937 * @return void
1495 1938 */
1496 1939 private function schedule_debounced_regeneration(): void {
1497 - // Clear any existing scheduled regeneration
1498 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap');
1940 + $this->mark_regeneration_pending('content');
1941 + $this->debounce_event('thinkrank_regenerate_sitemap');
1942 + }
1499 1943
1500 - // Schedule regeneration in 30 seconds to debounce rapid changes
1501 - wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap');
1944 + /**
1945 + * Schedule (or keep) the debounced single event behind a regeneration hook.
1946 + *
1947 + * An event that is already due is left alone. WP-Cron only runs when a
1948 + * request arrives, so on a site with DISABLE_WP_CRON, a blocked loopback or
1949 + * little traffic an overdue event can sit in the queue for a long time —
1950 + * clearing and re-scheduling it on every save pushed the rebuild
1951 + * permanently 30 seconds into the future and the sitemap never updated
1952 + * (#629). Debouncing only against an event that has not come due yet keeps
1953 + * the bulk-edit coalescing without starving the rebuild.
1954 + *
1955 + * @since 2.2.1
1956 + * @param string $hook Regeneration hook to debounce.
1957 + * @return void
1958 + */
1959 + private function debounce_event(string $hook): void {
1960 + $next = wp_next_scheduled($hook);
1961 +
1962 + if ($next !== false) {
1963 + if ($next <= time()) {
1964 + return;
1965 + }
1966 +
1967 + wp_clear_scheduled_hook($hook);
1968 + }
1969 +
1970 + wp_schedule_single_event(time() + self::REGENERATION_DEBOUNCE, $hook);
1502 1971 }
1503 1972
1504 1973 /**
1974 + * Record that a rebuild is outstanding, so an overdue one can be taken over
1975 + * by a later request and its staleness surfaced in the UI.
1976 + *
1977 + * `since` is the *oldest* outstanding change: it is what the takeover grace
1978 + * and the admin staleness warning are measured from, so successive edits
1979 + * must not push it forward. A settings change outranks a content change —
1980 + * it rebuilds regardless of the auto_generate toggle and handles a sitemap
1981 + * that has just been disabled — so once one is outstanding it stays the
1982 + * recorded source until the rebuild lands.
1983 + *
1984 + * @since 2.2.1
1985 + * @param string $source Either 'content' or 'settings'.
1986 + * @return void
1987 + */
1988 + private function mark_regeneration_pending(string $source): void {
1989 + // Whatever made the static files stale made the rendered ones stale
1990 + // too. Invalidating here rather than only on the rebuild keeps the two
1991 + // delivery modes reacting to exactly the same triggers, which is the
1992 + // only way a dynamic site stays as fresh as a static one (#752).
1993 + $this->flush_dynamic_cache();
1994 +
1995 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1996 + $pending = is_array($pending) ? $pending : [];
1997 +
1998 + $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1999 + $current = isset($pending['source']) ? (string) $pending['source'] : '';
2000 + $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
2001 +
2002 + // Merged so the bookkeeping a rebuild keeps on the marker (`started`,
2003 + // `memory_limit`) survives an edit made while it is outstanding.
2004 + update_option(
2005 + self::REGENERATION_PENDING_OPTION,
2006 + array_merge($pending, [
2007 + 'since' => $since,
2008 + 'source' => $source,
2009 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
2010 + 'next_attempt' => !empty($pending['next_attempt'])
2011 + ? (int) $pending['next_attempt']
2012 + : time() + self::REGENERATION_TAKEOVER_GRACE,
2013 + // Bumped on every change so a rebuild can tell whether the edit
2014 + // it started for is still the newest one outstanding.
2015 + 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
2016 + ]),
2017 + true
2018 + );
2019 + }
2020 +
2021 + /**
2022 + * The revision of the outstanding rebuild, for
2023 + * {@see mark_regeneration_complete()} to compare against once it is done.
2024 + *
2025 + * @since 2.2.1
2026 + * @return int Current revision, 0 when nothing is outstanding.
2027 + */
2028 + private function current_regeneration_revision(): int {
2029 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2030 +
2031 + return (is_array($pending) && !empty($pending['revision'])) ? (int) $pending['revision'] : 0;
2032 + }
2033 +
2034 + /**
2035 + * Clear the outstanding-rebuild marker and any recorded failure.
2036 + *
2037 + * Public because a manual generation satisfies whatever the automatic path
2038 + * was still waiting to write.
2039 + *
2040 + * @since 2.2.1
2041 + * @return void
2042 + */
2043 + public function mark_regeneration_complete(?int $revision = null): void {
2044 + // The write succeeded, so whatever failure was on record is history.
2045 + if (get_option(self::REGENERATION_ERROR_OPTION, null) !== null) {
2046 + delete_option(self::REGENERATION_ERROR_OPTION);
2047 + }
2048 +
2049 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
2050 +
2051 + if ($pending === null) {
2052 + return;
2053 + }
2054 +
2055 + // A change that landed while this rebuild was running is not covered by
2056 + // the files it just wrote, so it has to stay outstanding — otherwise, on
2057 + // a site where WP-Cron never fires, clearing the marker would strand it
2058 + // exactly the way #629 stranded everything.
2059 + if (
2060 + $revision !== null
2061 + && is_array($pending)
2062 + && (int) ($pending['revision'] ?? 0) !== $revision
2063 + ) {
2064 + // This attempt succeeded, so drop what it claimed: the newer change
2065 + // waits the grace a fresh edit gets, not a failure backoff it never
2066 + // earned. Not zero: that edit queued its own debounced event, and
2067 + // a marker due at once had the next admin request rebuild in its
2068 + // shutdown and the event rebuild again seconds later.
2069 + unset($pending['started'], $pending['memory_limit']);
2070 + $pending['attempts'] = 0;
2071 + $pending['next_attempt'] = time() + self::REGENERATION_TAKEOVER_GRACE;
2072 +
2073 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2074 + return;
2075 + }
2076 +
2077 + delete_option(self::REGENERATION_PENDING_OPTION);
2078 + }
2079 +
2080 + /**
2081 + * Record the attempt that is about to run before it runs.
2082 + *
2083 + * A PHP fatal — the memory limit or max_execution_time — is not a
2084 + * Throwable, so no catch or finally around the generation runs when the
2085 + * process dies, and a failure recorded afterwards was never recorded at
2086 + * all: `attempts` stayed 0, the backoff never applied, and the next request
2087 + * started the same doomed rebuild again (a fatal every few minutes for as
2088 + * long as an admin was logged in). Claiming the attempt up front makes the
2089 + * backoff hold even when nothing after this line gets to run, and leaves a
2090 + * `started` stamp the next attempt can recognise as an interrupted one.
2091 + *
2092 + * @since 2.10.1
2093 + * @return void
2094 + */
2095 + private function claim_regeneration_attempt(): void {
2096 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
2097 +
2098 + // Nothing outstanding (e.g. a manual generation already satisfied it):
2099 + // there is no marker to retry from, so nothing to claim.
2100 + if (!is_array($pending) || empty($pending['since'])) {
2101 + return;
2102 + }
2103 +
2104 + if (!empty($pending['started'])) {
2105 + // The previous attempt claimed itself and never reported back.
2106 + update_option(
2107 + self::REGENERATION_ERROR_OPTION,
2108 + [
2109 + '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'),
2110 + 'source' => isset($pending['source']) ? (string) $pending['source'] : 'content',
2111 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 1,
2112 + 'time' => (int) $pending['started'],
2113 + ],
2114 + false
2115 + );
2116 + }
2117 +
2118 + $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2119 +
2120 + $pending['attempts'] = $attempts;
2121 + $pending['next_attempt'] = time() + $this->regeneration_backoff($attempts);
2122 + $pending['started'] = time();
2123 +
2124 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2125 + }
2126 +
2127 + /**
2128 + * Delay before the next takeover after the given number of attempts.
2129 + *
2130 + * @since 2.10.1
2131 + * @param int $attempts Attempts made so far (1 or more).
2132 + * @return int Seconds.
2133 + */
2134 + private function regeneration_backoff(int $attempts): int {
2135 + return (int) min(
2136 + self::REGENERATION_TAKEOVER_GRACE * (2 ** min(max($attempts, 1), 10)),
2137 + self::REGENERATION_MAX_BACKOFF
2138 + );
2139 + }
2140 +
2141 + /**
2142 + * Record a failed regeneration instead of discarding it.
2143 + *
2144 + * Keeps the pending marker in place so the rebuild is retried, but backs the
2145 + * next attempt off exponentially (capped) so a persistently failing
2146 + * generation cannot run on every admin request.
2147 + *
2148 + * @since 2.2.1
2149 + * @since 2.10.1 Accepts the memory limit a rebuild had to stop short of, and
2150 + * does not count an attempt claim_regeneration_attempt()
2151 + * already counted.
2152 + * @param string $message Failure detail.
2153 + * @param string $source Either 'content' or 'settings'.
2154 + * @param int|null $memory_limit Memory limit (bytes) the rebuild stopped
2155 + * short of, when that was the failure.
2156 + * @return void
2157 + */
2158 + private function record_regeneration_failure(string $message, string $source, ?int $memory_limit = null): void {
2159 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2160 + $pending = is_array($pending) ? $pending : [];
2161 + $attempts = !empty($pending['attempts']) ? (int) $pending['attempts'] : 0;
2162 +
2163 + // An attempt that claimed itself up front has already been counted.
2164 + if (empty($pending['started'])) {
2165 + $attempts++;
2166 + }
2167 + $attempts = max($attempts, 1);
2168 +
2169 + $backoff = $this->regeneration_backoff($attempts);
2170 +
2171 + // Same precedence mark_regeneration_pending() enforces: a settings
2172 + // rebuild outranks a content one and must not be downgraded by a failed
2173 + // attempt. Overwriting it routed the retry back through the content
2174 + // path, where should_auto_generate() can be false and the completion
2175 + // marker then discards the settings rebuild entirely. Only the
2176 + // outstanding rebuild is upgraded — the recorded error keeps reporting
2177 + // whichever attempt actually failed.
2178 + $current = isset($pending['source']) ? (string) $pending['source'] : '';
2179 + $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
2180 +
2181 + $marker = [
2182 + 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
2183 + 'source' => $pending_source,
2184 + 'attempts' => $attempts,
2185 + 'next_attempt' => time() + $backoff,
2186 + 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
2187 + ];
2188 +
2189 + // Remembered so has_memory_for_retry() can keep requests with no more
2190 + // memory than this from repeating the same attempt.
2191 + if ($memory_limit !== null && $memory_limit > 0) {
2192 + $marker['memory_limit'] = $memory_limit;
2193 + }
2194 +
2195 + update_option(self::REGENERATION_PENDING_OPTION, $marker, true);
2196 +
2197 + update_option(
2198 + self::REGENERATION_ERROR_OPTION,
2199 + [
2200 + 'message' => $message,
2201 + 'source' => $source,
2202 + 'attempts' => $attempts,
2203 + 'time' => time(),
2204 + ],
2205 + false
2206 + );
2207 +
2208 + if (defined('WP_DEBUG') && WP_DEBUG) {
2209 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2210 + error_log(sprintf('ThinkRank: sitemap %s regeneration failed — %s', $source, $message));
2211 + }
2212 + }
2213 +
2214 + /**
2215 + * Is a rebuild outstanding and past the point where WP-Cron should have run
2216 + * it?
2217 + *
2218 + * Deliberately cheap — one autoloaded option read — because it is consulted
2219 + * on every admin request to decide whether the takeover is needed.
2220 + *
2221 + * @since 2.2.1
2222 + * @return bool True when a request should rebuild the sitemap itself.
2223 + */
2224 + public static function has_overdue_regeneration(): bool {
2225 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2226 +
2227 + if (!is_array($pending) || empty($pending['since'])) {
2228 + return false;
2229 + }
2230 +
2231 + $due = !empty($pending['next_attempt'])
2232 + ? (int) $pending['next_attempt']
2233 + : (int) $pending['since'] + self::REGENERATION_TAKEOVER_GRACE;
2234 +
2235 + return time() >= $due;
2236 + }
2237 +
2238 + /**
2239 + * Rebuild the sitemap in-request when WP-Cron has not delivered.
2240 + *
2241 + * Hooked on `shutdown` for admin, REST and CLI requests only (see
2242 + * Plugin::register_sitemap_cron_listeners()), so the work happens after the
2243 + * response has been sent and never adds latency to a visitor page view.
2244 + *
2245 + * @since 2.2.1
2246 + * @return void
2247 + */
2248 + public function run_overdue_regeneration(): void {
2249 + if (!self::has_overdue_regeneration()) {
2250 + return;
2251 + }
2252 +
2253 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2254 + $pending = is_array($pending) ? $pending : [];
2255 + $source = isset($pending['source']) ? (string) $pending['source'] : 'content';
2256 +
2257 + // Cron is running and its own event for this rebuild is still queued
2258 + // and due: it is about to do exactly this work. Only then — an event
2259 + // that has already been consumed (e.g. it fired while another process
2260 + // held the generation lock) is never re-queued, and returning here
2261 + // unconditionally left the rebuild to requests that could not finish it.
2262 + if (wp_doing_cron()) {
2263 + $hook = $source === 'settings' ? 'thinkrank_regenerate_sitemap_settings' : 'thinkrank_regenerate_sitemap';
2264 + $next = wp_next_scheduled($hook);
2265 +
2266 + if ($next !== false && $next <= time()) {
2267 + return;
2268 + }
2269 + }
2270 +
2271 + // The last attempt had to stop short of this process's memory limit.
2272 + // Retrying at the same (or a lower) limit only repeats that, so leave
2273 + // the rebuild to a process with more room — WP-CLI, a system cron, or a
2274 + // host with a higher limit — instead of burning it on every request.
2275 + if (!$this->has_memory_for_retry($pending)) {
2276 + return;
2277 + }
2278 +
2279 + if ($source === 'settings') {
2280 + $this->regenerate_sitemap_from_settings();
2281 + return;
2282 + }
2283 +
2284 + $this->auto_regenerate_sitemap();
2285 + }
2286 +
2287 + /**
2288 + * Acquire the shared generation lock.
2289 + *
2290 + * @since 2.2.1
2291 + * @return bool True when this process may generate.
2292 + */
2293 + private function acquire_generation_lock(): bool {
2294 + if (get_transient(self::GENERATION_LOCK_TRANSIENT)) {
2295 + return false;
2296 + }
2297 +
2298 + set_transient(self::GENERATION_LOCK_TRANSIENT, time(), 5 * MINUTE_IN_SECONDS);
2299 +
2300 + return true;
2301 + }
2302 +
2303 + /**
2304 + * Release the shared generation lock.
2305 + *
2306 + * @since 2.2.1
2307 + * @return void
2308 + */
2309 + private function release_generation_lock(): void {
2310 + delete_transient(self::GENERATION_LOCK_TRANSIENT);
2311 + }
2312 +
2313 + /**
2314 + * This process's PHP memory limit in bytes.
2315 + *
2316 + * @since 2.10.1
2317 + * @return int Bytes, or -1 when unlimited (or unreadable).
2318 + */
2319 + private function current_memory_limit(): int {
2320 + $limit = (string) ini_get('memory_limit');
2321 +
2322 + if ($limit === '' || $limit === '-1') {
2323 + return -1;
2324 + }
2325 +
2326 + $bytes = (int) wp_convert_hr_to_bytes($limit);
2327 +
2328 + return $bytes > 0 ? $bytes : -1;
2329 + }
2330 +
2331 + /**
2332 + * May this process retry a rebuild that last stopped at the memory limit?
2333 + *
2334 + * Raises the limit the way wp-admin does first, so a request that can get
2335 + * more room than the failed attempt had is still allowed to try.
2336 + *
2337 + * @since 2.10.1
2338 + * @param array $pending The pending marker.
2339 + * @return bool True when there is no recorded memory failure, or this
2340 + * process has more memory than the attempt that failed.
2341 + */
2342 + private function has_memory_for_retry(array $pending): bool {
2343 + if (empty($pending['memory_limit'])) {
2344 + return true;
2345 + }
2346 +
2347 + wp_raise_memory_limit('admin');
2348 +
2349 + $limit = $this->current_memory_limit();
2350 +
2351 + return $limit === -1 || $limit > (int) $pending['memory_limit'];
2352 + }
2353 +
2354 + /**
2355 + * Release what one walked chunk left behind.
2356 + *
2357 + * Hydrating a chunk through get_posts()/get_terms() also stores every
2358 + * object and its meta in the in-process object cache, which nothing
2359 + * empties until the request ends. Unsetting the chunk therefore freed
2360 + * nothing, and the walk grew with the size of the site instead of the size
2361 + * of a chunk — about 1.3 GB on a 45k-post site.
2362 + *
2363 + * A persistent object cache that supports it drops only its in-process
2364 + * copy (`flush_runtime`); the shared store keeps its data. WordPress's
2365 + * default cache has no shared store, and flushing it would empty every
2366 + * group for the rest of the request (options, the queried object, other
2367 + * plugins' data), so there only the chunk's own entries are deleted. A
2368 + * persistent cache without `flush_runtime` is left alone: deleting from it
2369 + * would evict the objects for every other request too.
2370 + *
2371 + * @since 2.10.1
2372 + * @param int $chunk_start memory_get_usage(true) before the chunk was hydrated.
2373 + * @param array $groups Cache groups keyed by the chunk's object IDs.
2374 + * @param int[] $ids The chunk's object IDs, plus any objects it
2375 + * hydrated under their own (featured images).
2376 + * @return void
2377 + * @throws \Error See assert_memory_headroom().
2378 + */
2379 + private function release_walk_memory(int $chunk_start, array $groups, array $ids): void {
2380 + // What one chunk costs before it is released: the margin the next one
2381 + // needs. Measured in the same real allocated size assert_memory_headroom()
2382 + // compares against the limit, so the two are the same unit.
2383 + $this->walk_chunk_cost = max($this->walk_chunk_cost, memory_get_usage(true) - $chunk_start);
2384 +
2385 + if (wp_using_ext_object_cache()) {
2386 + if (
2387 + function_exists('wp_cache_supports')
2388 + && wp_cache_supports('flush_runtime')
2389 + && function_exists('wp_cache_flush_runtime')
2390 + ) {
2391 + wp_cache_flush_runtime();
2392 + }
2393 + } elseif (!empty($ids)) {
2394 + foreach ($groups as $group) {
2395 + wp_cache_delete_multiple($ids, $group);
2396 + }
2397 + }
2398 +
2399 + $this->assert_memory_headroom();
2400 + }
2401 +
2402 + /**
2403 + * Cache groups get_posts() fills per post for the given post types.
2404 + *
2405 + * The post, its meta, and one relationships group per taxonomy the post
2406 + * type uses (update_object_term_cache()). Term objects themselves are
2407 + * bounded by the number of terms, not posts, so they are left cached.
2408 + *
2409 + * @since 2.10.1
2410 + * @param string[] $post_types Post types being walked.
2411 + * @return string[] Cache groups keyed by post ID.
2412 + */
2413 + private function chunk_post_cache_groups(array $post_types): array {
2414 + $groups = ['posts', 'post_meta'];
2415 +
2416 + foreach (get_object_taxonomies($post_types) as $taxonomy) {
2417 + $groups[] = $taxonomy . '_relationships';
2418 + }
2419 +
2420 + return array_values(array_unique($groups));
2421 + }
2422 +
2423 + /**
2424 + * Stop an automatic rebuild before the memory limit rather than at it.
2425 + *
2426 + * A PHP memory fatal skips every catch and finally, so the lock, the
2427 + * failure record and the backoff are all lost with it, while stopping here
2428 + * is an ordinary, fully recorded failure. Checked before each chunk is
2429 + * hydrated, against a margin of at least the largest chunk seen so far.
2430 + *
2431 + * It throws an \Error, not an \Exception, on purpose: the per-segment
2432 + * catch (\Exception) blocks in generate_multiple_sitemaps() would otherwise
2433 + * swallow it and carry on — writing an index without the aborted segments
2434 + * and then pruning their files as orphans. Only the automatic rebuild's
2435 + * catch (\Throwable) is meant to see it.
2436 + *
2437 + * @since 2.10.1
2438 + * @return void
2439 + * @throws \Error When the automatic rebuild is close to the memory limit.
2440 + */
2441 + private function assert_memory_headroom(): void {
2442 + if (!$this->memory_guard) {
2443 + return;
2444 + }
2445 +
2446 + $limit = $this->current_memory_limit();
2447 + if ($limit === -1) {
2448 + return;
2449 + }
2450 +
2451 + // A fifth of the limit (at least 32 MB) for writing the files, or one
2452 + // and a half of the costliest chunk if that is more — and never more
2453 + // than half the limit either way. Without that outer cap a single
2454 + // anomalously expensive chunk (500 posts of serialised page-builder or
2455 + // ACF meta reaches hundreds of megabytes) puts the margin above the
2456 + // limit itself, so every later check aborts at any usage at all, the
2457 + // failure records this process's limit, and has_memory_for_retry()
2458 + // then refuses every process that has the same limit. A site that
2459 + // never actually ran out of memory would stop rebuilding until WP-CLI
2460 + // or a system cron happened to run.
2461 + $headroom = (int) min(
2462 + max(
2463 + min(max($limit * 0.2, 32 * MB_IN_BYTES), $limit * 0.5),
2464 + $this->walk_chunk_cost * 1.5
2465 + ),
2466 + $limit * 0.5
2467 + );
2468 +
2469 + // The real allocated size, which is what PHP enforces memory_limit
2470 + // against; memory_get_usage(false) reports only what is handed out of
2471 + // those allocations and so understates the margin by the allocator's
2472 + // slack.
2473 + $usage = memory_get_usage(true);
2474 +
2475 + if ($usage > $limit - $headroom) {
2476 + throw new \Error(
2477 + sprintf(
2478 + /* translators: 1: memory in use, 2: PHP memory limit. */
2479 + 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'),
2480 + esc_html((string) size_format($usage)),
2481 + esc_html((string) size_format($limit))
2482 + ),
2483 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- an integer class constant, not output.
2484 + self::MEMORY_ABORT_CODE
2485 + );
2486 + }
2487 + }
2488 +
2489 + /**
2490 + * Run an automatic rebuild's generation with the fatal-safe bookkeeping.
2491 + *
2492 + * @since 2.10.1
2493 + * @param array $settings Sitemap settings.
2494 + * @return bool Whatever generate_and_save() returned.
2495 + * @throws \Throwable Whatever generation throws, after the memory guard is
2496 + * switched back off.
2497 + */
2498 + private function generate_for_regeneration(array $settings): bool {
2499 + // Same headroom wp-admin gives itself; a no-op when the limit is
2500 + // already higher or unlimited.
2501 + wp_raise_memory_limit('admin');
2502 +
2503 + $this->claim_regeneration_attempt();
2504 + $this->memory_guard = true;
2505 + $this->walk_chunk_cost = 0;
2506 +
2507 + try {
2508 + return $this->generate_and_save($settings);
2509 + } finally {
2510 + $this->memory_guard = false;
2511 + }
2512 + }
2513 +
2514 + /**
2515 + * Record a failure thrown by an automatic rebuild.
2516 + *
2517 + * @since 2.10.1
2518 + * @param \Throwable $e What was thrown.
2519 + * @param string $source Either 'content' or 'settings'.
2520 + * @return void
2521 + */
2522 + private function record_thrown_regeneration_failure(\Throwable $e, string $source): void {
2523 + $memory_limit = null;
2524 +
2525 + if ($e instanceof \Error && $e->getCode() === self::MEMORY_ABORT_CODE) {
2526 + $memory_limit = $this->current_memory_limit();
2527 + $memory_limit = $memory_limit > 0 ? $memory_limit : null;
2528 + }
2529 +
2530 + $this->record_regeneration_failure($e->getMessage(), $source, $memory_limit);
2531 + }
2532 +
2533 + /**
2534 + * Report how automatic regeneration is faring, for the admin UI.
2535 + *
2536 + * The feature used to fail invisibly: `last_generated` simply stopped
2537 + * advancing and nothing drew attention to it (#629).
2538 + *
2539 + * @since 2.2.1
2540 + * @return array Health payload.
2541 + */
2542 + public function get_regeneration_health(): array {
2543 + $settings = $this->get_settings('site');
2544 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2545 + $pending = is_array($pending) ? $pending : [];
2546 + $error = get_option(self::REGENERATION_ERROR_OPTION, []);
2547 + $error = is_array($error) ? $error : [];
2548 +
2549 + $since = !empty($pending['since']) ? (int) $pending['since'] : 0;
2550 +
2551 + $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap');
2552 + if ($next_scheduled === false) {
2553 + $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap_settings');
2554 + }
2555 +
2556 + return [
2557 + 'auto_generate' => !empty($settings['auto_generate']),
2558 + 'last_generated' => $settings['last_generated'] ?? '',
2559 + 'pending_since' => $since ? gmdate('c', $since) : null,
2560 + 'pending_seconds' => $since ? max(0, time() - $since) : 0,
2561 + 'next_scheduled' => $next_scheduled ? gmdate('c', (int) $next_scheduled) : null,
2562 + 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON,
2563 + 'stale' => $this->is_sitemap_stale($settings),
2564 + 'last_error' => !empty($error['message'])
2565 + ? [
2566 + 'message' => (string) $error['message'],
2567 + 'source' => isset($error['source']) ? (string) $error['source'] : 'content',
2568 + 'time' => !empty($error['time']) ? gmdate('c', (int) $error['time']) : null,
2569 + ]
2570 + : null,
2571 + ];
2572 + }
2573 +
2574 + /**
2575 + * Has published content changed since the served sitemap was last written?
2576 + *
2577 + * Uses core's cached last-modified lookup, which only considers published
2578 + * posts — the same content the sitemap covers.
2579 + *
2580 + * @since 2.2.1
2581 + * @param array $settings Sitemap settings.
2582 + * @return bool True when the sitemap is behind the content.
2583 + */
2584 + private function is_sitemap_stale(array $settings): bool {
2585 + if (empty($settings['enabled']) || empty($settings['last_generated'])) {
2586 + // Never generated is already reported separately by the UI.
2587 + return false;
2588 + }
2589 +
2590 + $generated = strtotime((string) $settings['last_generated']);
2591 + if (!$generated) {
2592 + return false;
2593 + }
2594 +
2595 + $modified = get_lastpostmodified('gmt');
2596 + if (!$modified) {
2597 + return false;
2598 + }
2599 +
2600 + $modified = strtotime($modified . ' UTC');
2601 + if (!$modified) {
2602 + return false;
2603 + }
2604 +
2605 + // A minute of slack keeps a rebuild that ran alongside the edit from
2606 + // reporting itself as stale.
2607 + return $modified > ($generated + MINUTE_IN_SECONDS);
2608 + }
2609 +
2610 + /**
1505 2611 * Public entry point to debounce-rebuild the sitemap after a settings change
1506 2612 * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
1507 2613 * file reflects the new settings instead of going stale until a content edit.
1508 2614 *
@@ -1511,10 +2617,10 @@
1511 2617 public function schedule_regeneration(): void {
1512 2618 // Debounce against rapid successive saves, but use the settings-specific
1513 2619 // hook so the rebuild runs regardless of the auto_generate toggle (which
1514 2620 // only governs content-change-triggered regeneration).
1515 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap_settings');
1516 - wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap_settings');
2621 + $this->mark_regeneration_pending('settings');
2622 + $this->debounce_event('thinkrank_regenerate_sitemap_settings');
1517 2623 }
1518 2624
1519 2625 /**
1520 2626 * Rebuild the served sitemap after an explicit settings change.
@@ -1523,11 +2629,22 @@
1523 2629 * auto_generate setting: the user deliberately changed inclusion rules and
1524 2630 * expects the served file to reflect them even if content-triggered
1525 2631 * auto-generation is turned off. Still respects the master `enabled` flag.
1526 2632 *
1527 - * @return void
2633 + * @since 2.10.0 Reports whether the served sitemap was actually rebuilt, so
2634 + * a caller can say so rather than assume it (#764). Existing
2635 + * callers that ignore the return are unaffected.
2636 + *
2637 + * @return bool True when the served sitemap now reflects the settings.
1528 2638 */
1529 - public function regenerate_sitemap_from_settings(): void {
2639 + public function regenerate_sitemap_from_settings(): bool {
2640 + if (!$this->acquire_generation_lock()) {
2641 + // A manual generation (or another request's takeover) is already
2642 + // writing the files; the pending marker survives so this rebuild is
2643 + // retried rather than lost.
2644 + return false;
2645 + }
2646 +
1530 2647 try {
1531 2648 $settings = $this->get_settings('site');
1532 2649 if (empty($settings['enabled'])) {
1533 2650 // The sitemap was disabled: remove the previously generated static
@@ -1532,18 +2649,81 @@
1532 2649 if (empty($settings['enabled'])) {
1533 2650 // The sitemap was disabled: remove the previously generated static
1534 2651 // files so the web server stops serving a stale sitemap that
1535 2652 // crawlers would otherwise keep fetching.
1536 - $this->delete_published_sitemaps();
1537 - return;
2653 + //
2654 + // A file that could not be removed is still being served, so
2655 + // this is not a success. Reporting one here would tell a caller
2656 + // the sitemap was gone while the web server kept answering with
2657 + // it, which is the failure this return value exists to prevent
2658 + // (#764).
2659 + $removal = $this->delete_published_sitemaps($settings);
2660 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2661 +
2662 + if (!empty($stuck)) {
2663 + $this->record_regeneration_failure(
2664 + $this->stuck_files_message($stuck, true),
2665 + 'settings'
2666 + );
2667 +
2668 + return false;
2669 + }
2670 +
2671 + $this->mark_regeneration_complete();
2672 +
2673 + return true;
1538 2674 }
1539 - $this->generate_and_save($settings);
2675 +
2676 + $revision = $this->current_regeneration_revision();
2677 +
2678 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2679 + // Returns false when a static file is stuck in the web root:
2680 + // the server keeps serving that file in preference to WordPress,
2681 + // so the switch has not taken effect (#764).
2682 + return $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2683 + }
2684 +
2685 + if ($this->generate_for_regeneration($settings)) {
2686 + $this->mark_regeneration_complete($revision);
2687 +
2688 + return true;
2689 + }
2690 +
2691 + $this->record_regeneration_failure(
2692 + $this->write_failure_message(),
2693 + 'settings'
2694 + );
2695 +
2696 + return false;
1540 2697 } catch (\Throwable $e) {
1541 - // Settings-triggered regeneration failed - details in exception.
2698 + $this->record_thrown_regeneration_failure($e, 'settings');
2699 +
2700 + return false;
2701 + } finally {
2702 + $this->release_generation_lock();
1542 2703 }
1543 2704 }
1544 2705
1545 2706 /**
2707 + * When a rebuild has been outstanding since, or 0 when none is.
2708 + *
2709 + * Lets a caller report an honest "saved, but the served file has not caught
2710 + * up yet" instead of a bare success (#764).
2711 + *
2712 + * @since 2.10.0
2713 + * @return int Unix timestamp, or 0 when nothing is pending.
2714 + */
2715 + public static function regeneration_pending_since(): int {
2716 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2717 +
2718 + if (!is_array($pending) || empty($pending['since'])) {
2719 + return 0;
2720 + }
2721 +
2722 + return (int) $pending['since'];
2723 + }
2724 +
2725 + /**
1546 2726 * Remove every static sitemap file ThinkRank publishes to the web root.
1547 2727 *
1548 2728 * Called when the sitemap feature is disabled, by the cleanup route, and by
1549 2729 * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
@@ -1583,8 +2763,44 @@
1583 2763 return thinkrank_webroot_segment_filenames($settings);
1584 2764 }
1585 2765
1586 2766 /**
2767 + * Can this web-root file be shown to be a sitemap ThinkRank wrote?
2768 + *
2769 + * The generator deletes as often as cleanup does — a segment that dropped
2770 + * out of the set, a pagination page beyond the new count, the local sitemap
2771 + * after the business identity was cleared — and until 2.1.1 it did all
2772 + * three by filename alone. That is the #515 bug on a far more frequent
2773 + * trigger: our names are the canonical ones, so an ordinary regeneration
2774 + * (post save, term change, settings save) destroyed RankMath's
2775 + * `sitemap-tags.xml` and `local-sitemap.xml` with no deactivation involved.
2776 + *
2777 + * Every name the generator derives comes from the current settings — the
2778 + * url pattern, the configured `sitemap_urls`, `local-sitemap.xml` — but the
2779 + * ownership test is still asked with `$name_derived = false`, which switches
2780 + * off the legacy fallback for the whole generator side.
2781 + *
2782 + * The fallback exists to recover a pre-2.1.1 file written with
2783 + * `enable_styling` off, which carries neither marker. That recovery belongs
2784 + * to the once-off cleanup paths. Here it can only do harm: this method runs
2785 + * on every post save, and everything this version writes carries
2786 + * THINKRANK_SITEMAP_MARKER, so after the site's first regeneration an
2787 + * unmarked file at one of our names is by definition somebody else's — and
2788 + * deleting it on an ordinary regeneration is #515 through the more common
2789 + * door. The cost is a stale unmarked segment left on disk until deactivation
2790 + * picks it up, which is the safe direction to fail in.
2791 + *
2792 + * @since 2.1.1
2793 + *
2794 + * @param string $path Absolute path to a file in the web root.
2795 + * @param array $settings Sitemap settings.
2796 + * @return bool True when the file may be deleted.
2797 + */
2798 + private function webroot_sitemap_is_ours(string $path, array $settings): bool {
2799 + return thinkrank_webroot_sitemap_is_ours($path, $settings, false);
2800 + }
2801 +
2802 + /**
1587 2803 * Auto-regenerate sitemap (called by scheduled action)
1588 2804 *
1589 2805 * @since 1.0.0
1590 2806 * @return void
@@ -1589,20 +2805,48 @@
1589 2805 * @since 1.0.0
1590 2806 * @return void
1591 2807 */
1592 2808 public function auto_regenerate_sitemap(): void {
2809 + if (!$this->acquire_generation_lock()) {
2810 + // A manual generation (or another request's takeover) is already
2811 + // writing the files; the pending marker survives so this rebuild is
2812 + // retried rather than lost.
2813 + return;
2814 + }
2815 +
1593 2816 try {
1594 2817 // Double-check that auto-generation is still enabled
1595 2818 if (!$this->should_auto_generate()) {
2819 + // Nothing outstanding can be delivered while the feature is off,
2820 + // so drop the marker rather than let the takeover retry forever.
2821 + $this->mark_regeneration_complete();
1596 2822 return;
1597 2823 }
1598 2824
1599 - $this->generate_and_save($this->get_settings('site'));
2825 + $revision = $this->current_regeneration_revision();
2826 + $settings = $this->get_settings('site');
1600 2827
1601 - // Sitemap auto-regenerated successfully
2828 + // See regenerate_sitemap_from_settings(): nothing to write.
2829 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2830 + $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2831 + return;
2832 + }
1602 2833
2834 + if ($this->generate_for_regeneration($settings)) {
2835 + $this->mark_regeneration_complete($revision);
2836 + } else {
2837 + // Previously this returned quietly and last_generated simply
2838 + // stopped advancing, leaving the site owner with no way to learn
2839 + // the sitemap had stopped updating (#629).
2840 + $this->record_regeneration_failure(
2841 + $this->write_failure_message(),
2842 + 'content'
2843 + );
2844 + }
1603 2845 } catch (\Throwable $e) {
1604 - // Sitemap auto-regeneration failed - error details available in exception
2846 + $this->record_thrown_regeneration_failure($e, 'content');
2847 + } finally {
2848 + $this->release_generation_lock();
1605 2849 }
1606 2850 }
1607 2851
1608 2852 /**
@@ -1617,8 +2861,26 @@
1617 2861 * @param array $settings Sitemap settings.
1618 2862 * @return bool True when the sitemap files were written.
1619 2863 */
1620 2864 public function generate_and_save(array $settings): bool {
2865 + // Dynamic delivery publishes no files, so writing them here would put a
2866 + // static copy back in the web root for the server to serve in place of
2867 + // the dynamic route. Guarding at each call site left gaps — the
2868 + // snapshot migrator's post-import regeneration had none — so the rule
2869 + // lives with the writing instead.
2870 + //
2871 + // `is_collecting()` is the exception that makes dynamic delivery work
2872 + // at all: render_document() and collect_documents() reach this same
2873 + // method with the writer swapped for a collector, and that is precisely
2874 + // the dynamic build. Only a real write is skipped.
2875 + if (!$this->is_collecting() && 'dynamic' === $this->resolve_delivery_mode($settings)) {
2876 + // Whatever prompted this call changed the sitemap's content, so the
2877 + // rendered copies must not outlive it.
2878 + $this->flush_dynamic_cache();
2879 +
2880 + return true;
2881 + }
2882 +
1621 2883 // Index mode is driven by the use_sitemap_index toggle (not merely by how
1622 2884 // many sitemap_urls happen to be configured). When the toggle is on but
1623 2885 // no child sitemaps are set up yet, synthesize the per-type segmented set
1624 2886 // so we emit a real <sitemapindex> with paginated children instead of a
@@ -1629,16 +2891,29 @@
1629 2891 $results = $this->generate_multiple_sitemaps($settings);
1630 2892 $written = !empty($results['success']);
1631 2893 } else {
1632 2894 $xml = $this->generate_sitemap($settings);
1633 - $written = $this->save_sitemap_to_file($xml, $this->get_primary_sitemap_filename($settings));
2895 + $primary = $this->get_primary_sitemap_filename($settings);
2896 + $written = $this->save_sitemap_to_file($xml, $primary);
1634 2897
1635 2898 // Local business sitemap is a standalone file, regenerated on the
1636 2899 // single-sitemap path too (this is the default mode).
1637 2900 $this->regenerate_local_sitemap($settings);
2901 +
2902 + // Switching out of index mode leaves sitemap_index.xml and every
2903 + // child on disk, still served and never refreshed again. The index
2904 + // path already prunes what it no longer owns; this path never did,
2905 + // so the site kept serving two sitemap trees (#563). Ownership is
2906 + // still tested per file, so another plugin's sitemap at one of our
2907 + // names is never touched (#515).
2908 + $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
1638 2909 }
1639 2910
1640 - if ($written) {
2911 + // last_generated describes what is on disk. A dynamic render publishes
2912 + // nothing, so advancing it would report a static publication that never
2913 + // happened and would let primary_sitemap_file_exists() callers believe
2914 + // there is a file to serve.
2915 + if ($written && !$this->is_collecting()) {
1641 2916 $settings['last_generated'] = gmdate('c');
1642 2917 $this->save_settings('site', null, $settings);
1643 2918 }
1644 2919
@@ -1645,8 +2920,433 @@
1645 2920 return $written;
1646 2921 }
1647 2922
1648 2923 /**
2924 + * Whether this instance is rendering documents rather than publishing them.
2925 + *
2926 + * @since 2.9.0
2927 + *
2928 + * @return bool
2929 + */
2930 + private function is_collecting(): bool {
2931 + return $this->document_sink !== null;
2932 + }
2933 +
2934 + /**
2935 + * Complete a regeneration that delivers dynamically, retiring stale files.
2936 + *
2937 + * Dynamic delivery renders nothing to disk, but that is only half the job.
2938 + * A web server hands back an existing `/sitemap.xml` without ever loading
2939 + * WordPress, so any file left over from a previous static generation goes on
2940 + * being served forever and {@see \ThinkRank\Frontend\SEO_Manager
2941 + * ::maybe_serve_sitemap()} is never reached. Switching to dynamic while
2942 + * leaving those files in place would therefore appear to do nothing at all.
2943 + *
2944 + * Both transitions matter and they differ:
2945 + *
2946 + * - An explicit switch to `dynamic` happens on a site whose root is usually
2947 + * still writable, so the files can simply be removed.
2948 + * - An `auto` site that becomes read-only cannot remove them, because
2949 + * deleting an entry needs write permission on the directory that holds
2950 + * it. There the stale sitemap really is stuck in front of us, and the
2951 + * honest outcome is a recorded failure naming it rather than a rebuild
2952 + * reported as complete (#754 review).
2953 + *
2954 + * Ownership is tested per file by the shared helper, so another plugin's
2955 + * sitemap at one of our names is never deleted (#515).
2956 + *
2957 + * @since 2.9.0
2958 + *
2959 + * @param array $settings Sitemap settings.
2960 + * @param int $revision Revision this rebuild is completing.
2961 + * @param string $source 'settings' or 'content', for the failure record.
2962 + * @return void
2963 + */
2964 + private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): bool {
2965 + $this->flush_dynamic_cache();
2966 +
2967 + $removal = $this->delete_published_sitemaps($settings);
2968 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2969 +
2970 + if (!empty($stuck)) {
2971 + $this->record_regeneration_failure($this->stuck_files_message($stuck), $source);
2972 +
2973 + return false;
2974 + }
2975 +
2976 + $this->mark_regeneration_complete($revision);
2977 +
2978 + return true;
2979 + }
2980 +
2981 + /**
2982 + * Why a stale file left in the web root means the change has not landed.
2983 + *
2984 + * Shared by every path that removes published files, so they cannot
2985 + * describe the same situation differently (#764).
2986 + *
2987 + * The two situations that reach it differ in what WordPress is doing, and
2988 + * the message has to say which. After a switch to dynamic delivery
2989 + * WordPress IS serving the sitemap and the files shadow it. After the
2990 + * sitemap is switched off WordPress serves nothing, so the one message
2991 + * used to tell a site owner who had just disabled the sitemap that it was
2992 + * "being served from WordPress", which is the opposite of what they did.
2993 + *
2994 + * @since 2.10.0
2995 + * @since 2.10.0 Public, so the REST endpoint uses it rather than a copy;
2996 + * takes $sitemap_disabled for the disabled path.
2997 + *
2998 + * @param string[] $stuck Basenames that could not be removed.
2999 + * @param bool $sitemap_disabled True when the files outlived disabling
3000 + * the sitemap rather than a switch to
3001 + * dynamic delivery.
3002 + * @return string
3003 + */
3004 + public function stuck_files_message(array $stuck, bool $sitemap_disabled = false): string {
3005 + if ($sitemap_disabled) {
3006 + return sprintf(
3007 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
3008 + __('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'),
3009 + implode(', ', $stuck),
3010 + untrailingslashit(ABSPATH)
3011 + );
3012 + }
3013 +
3014 + return sprintf(
3015 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
3016 + __('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'),
3017 + implode(', ', $stuck),
3018 + untrailingslashit(ABSPATH)
3019 + );
3020 + }
3021 +
3022 + /**
3023 + * What to tell the site owner when publishing the files failed.
3024 + *
3025 + * The old wording stated the symptom and stopped there, so the reported
3026 + * cause was a guess and this reached support as a plugin fault rather than
3027 + * a folder permission (#752, #753). When the root is demonstrably
3028 + * unwritable, say that, and say what to do about it.
3029 + *
3030 + * @since 2.9.0
3031 + *
3032 + * @return string
3033 + */
3034 + private function write_failure_message(): string {
3035 + if (!wp_is_writable(ABSPATH)) {
3036 + return sprintf(
3037 + /* translators: %s: absolute path to the WordPress root. */
3038 + __('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'),
3039 + untrailingslashit(ABSPATH)
3040 + );
3041 + }
3042 +
3043 + return __('The sitemap files could not be written to the site root.', 'thinkrank');
3044 + }
3045 +
3046 + /**
3047 + * How this site delivers its sitemap.
3048 + *
3049 + * `auto` is resolved on whether the web root can be written. That is the
3050 + * right signal here (unlike llms.txt, where the question is whether the
3051 + * server applies the .htaccess charset block): a site whose root is
3052 + * read-only cannot publish a sitemap file at all, and before this existed
3053 + * the feature simply failed with "The sitemap files could not be written to
3054 + * the site root." and served nothing (#752).
3055 + *
3056 + * @since 2.9.0
3057 + *
3058 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3059 + * @return string One of 'static' or 'dynamic'. Never 'auto'.
3060 + */
3061 + public function resolve_delivery_mode(?array $settings = null): string {
3062 + $settings = $settings ?? $this->get_settings('site');
3063 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
3064 +
3065 + if ('static' === $mode || 'dynamic' === $mode) {
3066 + return $mode;
3067 + }
3068 +
3069 + return wp_is_writable(ABSPATH) ? 'static' : 'dynamic';
3070 + }
3071 +
3072 + /**
3073 + * Render one published sitemap document without touching the filesystem.
3074 + *
3075 + * Runs the ordinary build pipeline with the writer swapped for a collector,
3076 + * so the bytes returned here are the bytes the static path would have
3077 + * written. `SitemapDeliveryParityTest` asserts that equivalence rather than
3078 + * trusting it.
3079 + *
3080 + * The whole set is built to answer for one file, because the index can only
3081 + * be assembled from the children that were actually produced. The result is
3082 + * cached per document, so that cost is paid once per change and not once
3083 + * per crawler request.
3084 + *
3085 + * @since 2.9.0
3086 + *
3087 + * @param string $filename Published file name, e.g. 'sitemap.xml'.
3088 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3089 + * @return string|null XML, or null when this site does not publish that name.
3090 + */
3091 + public function render_document(string $filename, ?array $settings = null): ?string {
3092 + $filename = basename($filename);
3093 + $settings = $settings ?? $this->get_settings('site');
3094 +
3095 + if (empty($settings['enabled'])) {
3096 + return null;
3097 + }
3098 +
3099 + $cached = get_transient($this->dynamic_cache_key($filename));
3100 + if (self::ABSENT_MARKER === $cached) {
3101 + return null;
3102 + }
3103 + if (is_string($cached) && '' !== $cached) {
3104 + return $cached;
3105 + }
3106 +
3107 + // A miss builds the whole set, because the index can only be assembled
3108 + // from the children that were actually produced. Caching only the
3109 + // requested document therefore made a crawler walking the index and its
3110 + // children rebuild the entire site's sitemap once per file — every post
3111 + // and taxonomy query repeated N times on a public endpoint (#754
3112 + // review). The set is built once and stored in full.
3113 + return $this->stream_documents($settings, $filename);
3114 + }
3115 +
3116 + /**
3117 + * Build every document, caching each as it is produced, keeping one.
3118 + *
3119 + * A miss has to build the whole set, because the index can only be
3120 + * assembled from the children that were actually produced. It does not have
3121 + * to *hold* the whole set: the static path never keeps more than one page
3122 + * in memory, writing each to disk as it goes, and buffering every
3123 + * document's XML to return one of them undid that on the request path,
3124 + * where a large site's entire sitemap corpus would sit in a single PHP
3125 + * process (#754 review).
3126 + *
3127 + * So the sink writes each document straight to its cache entry and lets it
3128 + * go, retaining only the one this request is answering. Peak retention is
3129 + * one document, whatever the site's size.
3130 + *
3131 + * Concurrency: the first request through takes a short lock and does the
3132 + * work. One that finds the lock held waits a bounded moment for the winner
3133 + * to publish, then builds anyway, because serving a correct sitemap late
3134 + * beats serving none.
3135 + *
3136 + * @since 2.9.0
3137 + *
3138 + * @param array $settings Sitemap settings.
3139 + * @param string $wanted Document this request is answering.
3140 + * @return string|null XML for $wanted, or null when the site does not publish it.
3141 + */
3142 + private function stream_documents(array $settings, string $wanted): ?string {
3143 + $lock = self::DYNAMIC_CACHE_PREFIX . 'lock';
3144 +
3145 + if (!$this->acquire_render_lock($lock)) {
3146 + for ($attempt = 0; $attempt < self::RENDER_LOCK_WAIT_ATTEMPTS; $attempt++) {
3147 + usleep(self::RENDER_LOCK_WAIT_MICROSECONDS);
3148 +
3149 + $cached = get_transient($this->dynamic_cache_key($wanted));
3150 + if (self::ABSENT_MARKER === $cached) {
3151 + return null;
3152 + }
3153 + if (is_string($cached) && '' !== $cached) {
3154 + return $cached;
3155 + }
3156 + }
3157 + }
3158 +
3159 + $kept = null;
3160 + // Names only. Keeping the bodies here would be the very retention this
3161 + // method exists to avoid.
3162 + $produced = [];
3163 +
3164 + $previous = $this->document_sink;
3165 + $this->document_sink = function (string $name, string $xml) use (&$kept, &$produced, $wanted): void {
3166 + $produced[$name] = true;
3167 + set_transient($this->dynamic_cache_key($name), $xml, self::DYNAMIC_CACHE_TTL);
3168 +
3169 + if ($name === $wanted) {
3170 + $kept = $xml;
3171 + }
3172 + };
3173 +
3174 + try {
3175 + $this->generate_and_save($settings);
3176 +
3177 + // Names the configuration lists but this build did not produce get
3178 + // a negative entry, so asking for one again is a cache hit rather
3179 + // than another full rebuild.
3180 + $absent = $this->published_document_names($settings);
3181 +
3182 + // Also the exact name this request asked for: a paginated page past
3183 + // the end of a stem is a legitimate request shape that the base
3184 + // list cannot enumerate, and without an entry it would rebuild on
3185 + // every hit.
3186 + $absent[] = $wanted;
3187 +
3188 + foreach (array_unique($absent) as $name) {
3189 + if (!isset($produced[$name])) {
3190 + set_transient($this->dynamic_cache_key($name), self::ABSENT_MARKER, self::DYNAMIC_CACHE_TTL);
3191 + }
3192 + }
3193 + } finally {
3194 + $this->document_sink = $previous;
3195 + delete_transient($lock);
3196 + }
3197 +
3198 + return $kept;
3199 + }
3200 +
3201 + /**
3202 + * Take the render lock, if it is free.
3203 + *
3204 + * Not atomic across processes, and deliberately so: the fallback for losing
3205 + * a race is duplicated work, never a wrong or missing sitemap, so a
3206 + * heavier primitive would buy nothing here.
3207 + *
3208 + * @since 2.9.0
3209 + *
3210 + * @param string $lock Lock transient name.
3211 + * @return bool True when this request holds the lock.
3212 + */
3213 + private function acquire_render_lock(string $lock): bool {
3214 + if (false !== get_transient($lock)) {
3215 + return false;
3216 + }
3217 +
3218 + set_transient($lock, time(), self::RENDER_LOCK_TTL);
3219 +
3220 + return true;
3221 + }
3222 +
3223 + /**
3224 + * Build every document this site publishes and return them all.
3225 + *
3226 + * Verification and tooling only. This retains the whole set in memory, so
3227 + * it must never be used to answer a request: {@see self::stream_documents()}
3228 + * is the serving path and keeps one document at a time regardless of site
3229 + * size (#754 review). `SitemapDeliveryParityTest` enforces that separation
3230 + * by failing if the request path routes back through here.
3231 + *
3232 + * @since 2.9.0
3233 + *
3234 + * @param array $settings Sitemap settings.
3235 + * @return array<string,string> Filename => XML.
3236 + */
3237 + public function collect_documents(array $settings): array {
3238 + $documents = [];
3239 +
3240 + $previous = $this->document_sink;
3241 + $this->document_sink = static function (string $name, string $xml) use (&$documents): void {
3242 + $documents[$name] = $xml;
3243 + };
3244 +
3245 + try {
3246 + $this->generate_and_save($settings);
3247 + } finally {
3248 + $this->document_sink = $previous;
3249 + }
3250 +
3251 + return $documents;
3252 + }
3253 +
3254 + /**
3255 + * The file names this site publishes, without building their contents.
3256 + *
3257 + * Used by the request router to decide whether a URL is ours before doing
3258 + * any work. Cheap: it reads the configured child list rather than querying
3259 + * for entries.
3260 + *
3261 + * @since 2.9.0
3262 + *
3263 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3264 + * @return string[] File names, including paginated pages that may exist.
3265 + */
3266 + public function published_document_names(?array $settings = null): array {
3267 + $settings = $settings ?? $this->get_settings('site');
3268 + $resolved = $this->maybe_promote_to_index($settings);
3269 +
3270 + $names = [$this->get_primary_sitemap_filename($settings), 'local-sitemap.xml'];
3271 +
3272 + foreach ((array) ($resolved['sitemap_urls'] ?? []) as $child) {
3273 + if (!is_array($child) || empty($child['enabled'])) {
3274 + continue;
3275 + }
3276 +
3277 + $path = (string) wp_parse_url((string) ($child['url'] ?? ''), PHP_URL_PATH);
3278 + if ('' !== $path) {
3279 + $names[] = basename($path);
3280 + }
3281 + }
3282 +
3283 + return array_values(array_unique(array_filter($names)));
3284 + }
3285 +
3286 + /**
3287 + * Does this site publish a document under that name?
3288 + *
3289 + * Not a plain membership test against {@see self::published_document_names()}:
3290 + * that lists the configured children, and a child over the per-file URL cap
3291 + * is split into `<stem>-2.xml`, `<stem>-3.xml` and so on, with every page
3292 + * listed in the index. Gating the request router on the base list alone
3293 + * therefore 404'd exactly the pages the index points at, which is worse than
3294 + * not serving them at all.
3295 + *
3296 + * Page counts are not knowable without building, so the stem is what is
3297 + * matched; a page that does not exist is answered by the build finding
3298 + * nothing for it, and is then cached as absent.
3299 + *
3300 + * @since 2.9.0
3301 + *
3302 + * @param string $name Requested file name.
3303 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3304 + * @return bool
3305 + */
3306 + public function publishes_document_name(string $name, ?array $settings = null): bool {
3307 + $names = $this->published_document_names($settings);
3308 +
3309 + if (in_array($name, $names, true)) {
3310 + return true;
3311 + }
3312 +
3313 + if (!preg_match('/^(.*)-\d+\.xml$/i', $name, $m)) {
3314 + return false;
3315 + }
3316 +
3317 + return in_array($m[1] . '.xml', $names, true);
3318 + }
3319 +
3320 + /**
3321 + * Transient key for a rendered document.
3322 + *
3323 + * @since 2.9.0
3324 + *
3325 + * @param string $filename Published file name.
3326 + * @return string
3327 + */
3328 + private function dynamic_cache_key(string $filename): string {
3329 + return self::DYNAMIC_CACHE_PREFIX . md5($filename);
3330 + }
3331 +
3332 + /**
3333 + * Drop every cached dynamic document.
3334 + *
3335 + * Called from the same places that mark the static files stale, so the two
3336 + * delivery modes invalidate on identical triggers.
3337 + *
3338 + * @since 2.9.0
3339 + *
3340 + * @return void
3341 + */
3342 + public function flush_dynamic_cache(): void {
3343 + foreach ($this->published_document_names() as $name) {
3344 + delete_transient($this->dynamic_cache_key($name));
3345 + }
3346 + }
3347 +
3348 + /**
1649 3349 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
1650 3350 *
1651 3351 * - When use_sitemap_index is on but no child sitemaps are configured, build
1652 3352 * the per-type segmented set so a real <sitemapindex> is produced (#127).
@@ -1841,8 +3541,18 @@
1841 3541 // File validation failed - error details available in exception
1842 3542 return false;
1843 3543 }
1844 3544
3545 + // Dynamic delivery: hand the document to the collector instead of the
3546 + // filesystem. Reported as published, because for this run it is — the
3547 + // caller's success/failure bookkeeping and the index assembly both key
3548 + // off this return value.
3549 + if ($this->document_sink !== null) {
3550 + ($this->document_sink)($filename, $sitemap_xml);
3551 +
3552 + return true;
3553 + }
3554 +
1845 3555 $sitemap_path = ABSPATH . $filename;
1846 3556
1847 3557 // Use WordPress filesystem API for better security
1848 3558 global $wp_filesystem;
@@ -1851,9 +3561,22 @@
1851 3561 WP_Filesystem();
1852 3562 }
1853 3563
1854 3564 if ($wp_filesystem) {
1855 - return $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3565 + $written = $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3566 +
3567 + if ($written) {
3568 + // Every sitemap this version writes carries the ownership
3569 + // marker, so once one has been written an unmarked file at one
3570 + // of our names cannot be ours. Recording that retires the
3571 + // legacy fallback for this install — see
3572 + // thinkrank_webroot_sitemap_is_ours().
3573 + if (get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1') {
3574 + update_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION, '1', false);
3575 + }
3576 + }
3577 +
3578 + return $written;
1856 3579 }
1857 3580
1858 3581 // WP_Filesystem initialization failed
1859 3582 return false;
@@ -1887,13 +3610,55 @@
1887 3610
1888 3611 // Public custom post types each get a child sitemap (parity with the
1889 3612 // "complete" preset and with Rank Math, which lists every public CPT).
1890 3613 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
1891 - if ($this->should_include_post_type($cpt)) {
1892 - $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
3614 + if (!$this->should_include_post_type($cpt)) {
3615 + continue;
1893 3616 }
3617 +
3618 + // ...and the per-content-type sitemap switch (#660).
3619 + // get_enabled_post_types() already honours it, but this list is what
3620 + // index mode builds its children from — so without the same test a
3621 + // CPT the user had switched off still got its own child sitemap,
3622 + // created and streamed in full. The flags live in $inclusions, which
3623 + // is the settings array these children are derived from.
3624 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $cpt, $inclusions)) {
3625 + continue;
3626 + }
3627 +
3628 + $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
1894 3629 }
1895 3630
3631 + // Public custom taxonomies get the same treatment (#690). Flat mode has
3632 + // always walked them through get_enabled_taxonomies(); index mode built
3633 + // its children from the list above and never consulted a taxonomy at
3634 + // all, so every custom-taxonomy archive silently vanished from the
3635 + // sitemap the moment a site switched modes — and the per-taxonomy switch
3636 + // the matrix writes had nothing to act on. Same two tests the post-type
3637 + // walk applies, in the same order.
3638 + $taken = array_column($urls, 'type');
3639 +
3640 + foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
3641 + if (!$this->should_include_taxonomy($taxonomy)) {
3642 + continue;
3643 + }
3644 +
3645 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $inclusions)) {
3646 + continue;
3647 + }
3648 +
3649 + // Post types and taxonomies are separate registries, so a site can
3650 + // hold both a `foo` post type and a `foo` taxonomy. They would
3651 + // resolve to one filename, and stream_type_entries() answers post
3652 + // types first, so the second child would list the first one's file
3653 + // twice in the index rather than adding anything.
3654 + if (in_array($taxonomy, $taken, true)) {
3655 + continue;
3656 + }
3657 +
3658 + $urls[] = $this->build_child_sitemap_entry($taxonomy, $pattern);
3659 + }
3660 +
1896 3661 return $urls;
1897 3662 }
1898 3663
1899 3664 /**
@@ -2040,8 +3805,48 @@
2040 3805 if (post_type_exists($type) && !$this->should_include_post_type($type)) {
2041 3806 continue;
2042 3807 }
2043 3808
3809 + // Same for the matrix switch: a child list saved before the user
3810 + // excluded this content type still names it, and regenerating from
3811 + // that list would rewrite the file they asked not to have. Built-in
3812 + // aggregates ('posts', 'pages', ...) are not post type names, so
3813 + // post_type_exists() keeps this to real custom post types.
3814 + if (post_type_exists($type)
3815 + && !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $type, $settings)) {
3816 + continue;
3817 + }
3818 +
3819 + // The taxonomy counterpart of the two guards above (#690). A child
3820 + // list saved while a taxonomy was still included keeps naming it, so
3821 + // without this, excluding one in the matrix would still rewrite and
3822 + // re-list the file the user asked not to have. The built-in
3823 + // aggregates are named 'categories'/'tags' rather than
3824 + // 'category'/'post_tag', so taxonomy_exists() leaves them to the
3825 + // inclusion-flag check below.
3826 + $child_taxonomy = self::CHILD_TYPE_ALIASES[$type] ?? $type;
3827 + if (taxonomy_exists($child_taxonomy)
3828 + && (!$this->should_include_taxonomy($child_taxonomy)
3829 + || !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $child_taxonomy, $settings))) {
3830 + continue;
3831 + }
3832 +
3833 + // The built-in aggregates carry their switch in the inclusion flag
3834 + // rather than under a post type name, and they are the four most
3835 + // people actually use. build_segmented_sitemap_urls() drops a child
3836 + // whose flag is empty; regenerating from a list saved while it was
3837 + // still on has to make the same decision, or turning Posts, Pages,
3838 + // Categories or Tags off in the matrix rewrites and re-lists the
3839 + // very file it was asked to remove.
3840 + // An absent flag means "not configured", which every other reader
3841 + // treats as included; only a flag that is present and off excludes.
3842 + $aggregate_flag = array_search($type, self::INCLUSION_CHILD_TYPES, true);
3843 + if ($aggregate_flag !== false
3844 + && array_key_exists($aggregate_flag, $settings)
3845 + && empty($settings[$aggregate_flag])) {
3846 + continue;
3847 + }
3848 +
2044 3849 try {
2045 3850 // The single "general"/"WordPress" sitemap is one un-paginated file
2046 3851 // (there is no index to reference extra pages); it no longer drops
2047 3852 // overflow URLs.
@@ -2077,18 +3882,36 @@
2077 3882 $buffer = [];
2078 3883 }
2079 3884 }
2080 3885
2081 - // Flush the trailing partial page, or a single empty page when the
2082 - // type had no entries at all (parity with the previous behavior of
2083 - // always writing at least one page per configured child).
2084 - if (!empty($buffer) || $page === 0) {
3886 + // Flush the trailing partial page.
3887 + //
3888 + // A type that produced nothing writes no page at all in index
3889 + // mode (#836). It used to write one empty urlset and list it in
3890 + // the index, so a crawler was asked to fetch a file that
3891 + // answers with no URLs — Search Console reports an empty
3892 + // sitemap referenced from an index as a warning, and the fetch
3893 + // is wasted on every pass. On this site three of eleven
3894 + // children were empty: a post type with nothing published and
3895 + // two taxonomies with no terms.
3896 + //
3897 + // Single-file mode still writes its one page even when empty,
3898 + // because that file IS the site's /sitemap.xml and a 404 there
3899 + // is worse than an empty urlset. Nothing lists it, so it costs
3900 + // no crawl budget.
3901 + //
3902 + // Skipping the write also keeps the filename out of
3903 + // $results['sitemaps_generated'], which is what
3904 + // prune_orphaned_segments() treats as "written this run" — so
3905 + // a type that empties after previously publishing has its
3906 + // stale file deleted rather than left serving.
3907 + if (!empty($buffer) || ($page === 0 && $index_config === null)) {
2085 3908 $page++;
2086 3909 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
2087 3910 }
2088 3911
2089 3912 // Remove pages left over from a previous, larger generation.
2090 - $this->cleanup_stale_pages($sitemap_config['url'], $page);
3913 + $this->cleanup_stale_pages($sitemap_config['url'], $page, $settings);
2091 3914
2092 3915 } catch (\Exception $e) {
2093 3916 $results['errors'][] = "Error generating {$type} sitemap: " . $e->getMessage();
2094 3917 $results['success'] = false;
@@ -2107,10 +3930,16 @@
2107 3930 $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
2108 3931 $results['success'] = false;
2109 3932 }
2110 3933
2111 - // Build the index last, from the child files actually generated.
3934 + // Build the index last, from the child files actually generated, plus the
3935 + // sitemaps other plugins own: those serve their own URLs and write no
3936 + // file here, so they are appended to the index only (#104).
2112 3937 if ($index_config !== null) {
3938 + foreach (self::additional_sitemaps() as $extra) {
3939 + $index_children[] = ['url' => $extra];
3940 + }
3941 +
2113 3942 try {
2114 3943 $index_xml = $this->generate_sitemap_index($index_children, $settings);
2115 3944 $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
2116 3945
@@ -2155,8 +3984,15 @@
2155 3984 * @param array $generated Entries from $results['sitemaps_generated'].
2156 3985 * @return string[] Basenames removed.
2157 3986 */
2158 3987 private function prune_orphaned_segments(array $settings, array $generated): array {
3988 + // Rendering for a request, not publishing: there is nothing on disk
3989 + // this run owns, and a dynamic render must never delete the files a
3990 + // site's previous static mode left behind.
3991 + if ($this->is_collecting()) {
3992 + return [];
3993 + }
3994 +
2159 3995 $kept = [];
2160 3996 foreach ($generated as $entry) {
2161 3997 if (!empty($entry['filename'])) {
2162 3998 $kept[strtolower((string) $entry['filename'])] = true;
@@ -2162,15 +3998,22 @@
2162 3998 $kept[strtolower((string) $entry['filename'])] = true;
2163 3999 }
2164 4000 }
2165 4001
2166 - // The index and the local business sitemap are written by their own
2167 - // paths and are not segments, so they are never orphans here.
2168 - $kept[strtolower(basename($this->get_primary_sitemap_filename($settings)))] = true;
4002 + // The current mode's primary and the local business sitemap are written
4003 + // by their own paths and are never orphans here.
4004 + $primary = strtolower(basename($this->get_primary_sitemap_filename($settings)));
4005 + $kept[$primary] = true;
2169 4006 $kept['local-sitemap.xml'] = true;
2170 - $kept['sitemap.xml'] = true;
2171 - $kept['sitemap_index.xml'] = true;
2172 4007
4008 + // The OTHER mode's primary is an orphan the moment the mode changes:
4009 + // index mode leaves sitemap.xml behind, flat mode leaves
4010 + // sitemap_index.xml and its children. Both used to be kept
4011 + // unconditionally, so the site served two sitemap trees and only ever
4012 + // refreshed one (#563). The children are already covered by the segment
4013 + // sweep below, which now sees them because the index is no longer kept.
4014 + $stale_primaries = array_diff(['sitemap.xml', 'sitemap_index.xml'], [$primary]);
4015 +
2173 4016 $removed = [];
2174 4017
2175 4018 global $wp_filesystem;
2176 4019 if (!$wp_filesystem) {
@@ -2180,9 +4023,11 @@
2180 4023 if (!$wp_filesystem) {
2181 4024 return $removed;
2182 4025 }
2183 4026
2184 - foreach ($this->publishable_segment_filenames($settings) as $candidate) {
4027 + $candidates = array_merge($this->publishable_segment_filenames($settings), $stale_primaries);
4028 +
4029 + foreach ($candidates as $candidate) {
2185 4030 if (isset($kept[strtolower($candidate)])) {
2186 4031 continue;
2187 4032 }
2188 4033
@@ -2201,8 +4046,13 @@
2201 4046 foreach ($paths as $path) {
2202 4047 if (!file_exists($path)) {
2203 4048 continue;
2204 4049 }
4050 + // A name we could have published is not proof we published
4051 + // this file: RankMath and core write at the same paths (#515).
4052 + if (!$this->webroot_sitemap_is_ours($path, $settings)) {
4053 + continue;
4054 + }
2205 4055 if ($wp_filesystem->delete($path)) {
2206 4056 $removed[] = basename($path);
2207 4057 }
2208 4058 }
@@ -2238,11 +4088,15 @@
2238 4088 $settings = $settings ?? $this->get_settings('site');
2239 4089 $entries = $this->collect_local_entries();
2240 4090
2241 4091 if (empty($entries)) {
2242 - // Business identity was cleared — drop any file left from before.
4092 + // Business identity was cleared — drop the file we left from
4093 + // before, but only ours. `local-sitemap.xml` is the name Rank Math
4094 + // publishes under too (this method mirrors it deliberately), so on
4095 + // a migrated site the file at that path may never have been ours
4096 + // to delete (#515).
2243 4097 $path = ABSPATH . 'local-sitemap.xml';
2244 - if (file_exists($path)) {
4098 + if (!$this->is_collecting() && file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
2245 4099 wp_delete_file($path);
2246 4100 }
2247 4101 return false;
2248 4102 }
@@ -2264,8 +4118,25 @@
2264 4118 *
2265 4119 * @since 1.15.x
2266 4120 * @return array Zero or one URL entry
2267 4121 */
4122 + /**
4123 + * Does this site publish a local business sitemap right now?
4124 + *
4125 + * The same gate {@see self::regenerate_local_sitemap()} applies, asked
4126 + * without writing anything. Callers that need to know whether the document
4127 + * exists must not test the filesystem: under dynamic delivery it is served
4128 + * from PHP and there is no file, which is how `local-sitemap.xml` came to be
4129 + * dropped from robots.txt on exactly those sites (#752).
4130 + *
4131 + * @since 2.9.0
4132 + *
4133 + * @return bool True when the local sitemap has content to publish.
4134 + */
4135 + public function publishes_local_sitemap(): bool {
4136 + return !empty($this->collect_local_entries());
4137 + }
4138 +
2268 4139 private function collect_local_entries(): array {
2269 4140 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2270 4141 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2271 4142 }
@@ -2337,17 +4208,43 @@
2337 4208 case 'products':
2338 4209 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
2339 4210 return ['entries' => $entries, 'image_ns' => true];
2340 4211
2341 - case 'product_categories':
2342 - $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
2343 - return ['entries' => $entries, 'image_ns' => false];
4212 + default:
4213 + // Resolve a preset's display name to the object it streams, so
4214 + // 'product_categories' is an ordinary taxonomy child rather than
4215 + // a case of its own (#690).
4216 + $alias = self::CHILD_TYPE_ALIASES[$type] ?? null;
4217 + $object = $alias ?? $type;
2344 4218
2345 - default:
2346 4219 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2347 - if (in_array($type, $custom_post_types, true)) {
2348 - return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
4220 + if (in_array($object, $custom_post_types, true)) {
4221 + return ['entries' => $this->collect_post_entries_iter([$object], $settings), 'image_ns' => true];
2349 4222 }
4223 +
4224 + // Custom taxonomies reach index mode here, streamed through the
4225 + // same iterator flat mode uses so the two modes emit identical
4226 + // URLs for the same settings.
4227 + if (taxonomy_exists($object) && $this->should_include_taxonomy($object)) {
4228 + return ['entries' => $this->collect_taxonomy_entries_iter($object, $settings), 'image_ns' => false];
4229 + }
4230 +
4231 + // An aliased child whose object is gone (WooCommerce deactivated)
4232 + // keeps writing the empty file it always wrote. Returning null
4233 + // here would hand it the whole-site fallback instead, dumping
4234 + // every URL on the site into a file named for products.
4235 + //
4236 + // A registered taxonomy this generator will not emit
4237 + // (`post_format`, `nav_menu`, a non-public one) needs the same
4238 + // answer for the same reason. generate_multiple_sitemaps()
4239 + // skips those before they reach here, so nothing takes this
4240 + // path today — but it is the one branch where falling through
4241 + // to null is silently catastrophic rather than merely wrong,
4242 + // and the guard keeping it unreachable lives in another method.
4243 + if ($alias !== null || taxonomy_exists($object)) {
4244 + return ['entries' => [], 'image_ns' => false];
4245 + }
4246 +
2350 4247 return null;
2351 4248 }
2352 4249 }
2353 4250
@@ -2390,13 +4287,23 @@
2390 4287 * which has no -N suffix) is never touched.
2391 4288 *
2392 4289 * @since 1.14.0
2393 4290 *
4291 + * @since 2.1.1 Each candidate must pass the content ownership test — a
4292 + * `-N.xml` page of another plugin's sitemap paginates our
4293 + * stem exactly as ours does (#515).
4294 + *
2394 4295 * @param string $base_url Base (page 1) sitemap URL
2395 4296 * @param int $current_pages Number of pages generated this run
4297 + * @param array $settings Sitemap settings, for the ownership test.
2396 4298 * @return void
2397 4299 */
2398 - private function cleanup_stale_pages(string $base_url, int $current_pages): void {
4300 + private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
4301 + // See prune_orphaned_segments(): a dynamic render deletes nothing.
4302 + if ($this->is_collecting()) {
4303 + return;
4304 + }
4305 +
2399 4306 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2400 4307 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2401 4308 return;
2402 4309 }
@@ -2413,9 +4320,11 @@
2413 4320
2414 4321 $candidates = glob(ABSPATH . $stem . '-*.xml') ?: [];
2415 4322 foreach ($candidates as $path) {
2416 4323 // Only delete numeric-suffixed pages beyond the current count.
2417 - if (preg_match('/-(\d+)\.xml$/', basename($path), $mm) && (int) $mm[1] > $current_pages) {
4324 + if (preg_match('/-(\d+)\.xml$/', basename($path), $mm)
4325 + && (int) $mm[1] > $current_pages
4326 + && $this->webroot_sitemap_is_ours($path, $settings)) {
2418 4327 $wp_filesystem->delete($path);
2419 4328 }
2420 4329 }
2421 4330 }
@@ -2420,8 +4329,97 @@
2420 4329 }
2421 4330 }
2422 4331
2423 4332 /**
4333 + * Sitemap URLs contributed by other plugins.
4334 + *
4335 + * ThinkRank owns the sitemap index and the robots.txt `Sitemap:` lines, so a
4336 + * companion plugin that serves its own sitemap — Pro's news and video
4337 + * sitemaps, for instance — had no way to be discovered: it appeared in
4338 + * neither, leaving manual Search Console submission as the only route in
4339 + * (#104). Registering here puts a sitemap in the index when one exists, and
4340 + * in robots.txt when it does not.
4341 + *
4342 + * Callers get root-relative paths. Entries are normalised to a leading
4343 + * slash, de-duplicated, and anything that is not a non-empty string is
4344 + * dropped, so one badly-behaved callback cannot produce a malformed index.
4345 + *
4346 + * @since 2.3.1
4347 + *
4348 + * @return string[] Root-relative sitemap paths, e.g. ['/news-sitemap.xml'].
4349 + */
4350 + public static function additional_sitemaps(): array {
4351 + /**
4352 + * Filters the sitemaps contributed by other plugins.
4353 + *
4354 + * @since 2.3.1
4355 + *
4356 + * @param string[] $sitemaps Root-relative sitemap paths.
4357 + */
4358 + $sitemaps = apply_filters('thinkrank_additional_sitemaps', []);
4359 +
4360 + if (!is_array($sitemaps)) {
4361 + return [];
4362 + }
4363 +
4364 + // Both consumers resolve an entry with home_url(), which prefixes the
4365 + // install's own directory. Everything below is measured against that so
4366 + // an absolute URL is reduced to what home_url() will put back.
4367 + $home = wp_parse_url(home_url('/'));
4368 + $home_host = strtolower((string) ($home['host'] ?? ''));
4369 + $home_path = '/' . trim((string) ($home['path'] ?? ''), '/');
4370 +
4371 + $clean = [];
4372 + foreach ($sitemaps as $sitemap) {
4373 + if (!is_string($sitemap)) {
4374 + continue;
4375 + }
4376 +
4377 + $sitemap = trim($sitemap);
4378 + if ('' === $sitemap) {
4379 + continue;
4380 + }
4381 +
4382 + // A full URL on this site is accepted and reduced to the part
4383 + // home_url() does not already supply, so a caller that reached for
4384 + // home_url() still lands in the right place — including on a
4385 + // subdirectory install, where keeping the whole path would repeat
4386 + // the directory. A URL on another host is dropped rather than
4387 + // rewritten: the sitemaps protocol will not accept a cross-host
4388 + // child anyway, and reusing its path would advertise a URL on this
4389 + // site that does not exist.
4390 + if (preg_match('#^(https?:)?//#i', $sitemap)) {
4391 + $parts = wp_parse_url('//' === substr($sitemap, 0, 2) ? 'https:' . $sitemap : $sitemap);
4392 + if (!is_array($parts)) {
4393 + continue;
4394 + }
4395 +
4396 + if (strtolower((string) ($parts['host'] ?? '')) !== $home_host) {
4397 + continue;
4398 + }
4399 +
4400 + $path = (string) ($parts['path'] ?? '');
4401 + if ('' === $path) {
4402 + continue;
4403 + }
4404 +
4405 + if ('/' !== $home_path && ($path === $home_path || 0 === strpos($path, $home_path . '/'))) {
4406 + $path = substr($path, strlen($home_path));
4407 + }
4408 +
4409 + // A sitemap served from a query string keeps it; dropping the
4410 + // query would point at a different document.
4411 + $query = (string) ($parts['query'] ?? '');
4412 + $sitemap = $path . ('' !== $query ? '?' . $query : '');
4413 + }
4414 +
4415 + $clean[] = '/' . ltrim($sitemap, '/');
4416 + }
4417 +
4418 + return array_values(array_unique($clean));
4419 + }
4420 +
4421 + /**
2424 4422 * Generate sitemap index XML from the list of child sitemap files produced
2425 4423 * during generation (each already resolved to its final, possibly paginated,
2426 4424 * URL).
2427 4425 *
@@ -2430,14 +4428,9 @@
2430 4428 * @param array $settings Sitemap settings
2431 4429 * @return string Sitemap index XML
2432 4430 */
2433 4431 private function generate_sitemap_index(array $children, array $settings): string {
2434 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
2435 -
2436 - // Add XSL stylesheet only if styling is enabled
2437 - if (!empty($settings['enable_styling'])) {
2438 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap-index.xsl') . '"?>' . "\n";
2439 - }
4432 + $xml = $this->xml_prolog($settings, 'index');
2440 4433 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
2441 4434
2442 4435 $site_url = home_url();
2443 4436
@@ -2450,9 +4443,9 @@
2450 4443 $sitemap_url = $site_url . $sitemap_url;
2451 4444 }
2452 4445
2453 4446 $xml .= " <sitemap>\n";
2454 - $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
4447 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($sitemap_url)) . "</loc>\n";
2455 4448 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
2456 4449 $xml .= " </sitemap>\n";
2457 4450 }
2458 4451