PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 All 57 releases
← All changes | includes/seo/class-sitemap-generator.php +2423 -300 2.0.0 → 2.14.2 View file →
@@ -21,8 +21,13 @@
21 21 if ( ! defined( 'ABSPATH' ) ) {
22 22 exit;
23 23 }
24 24
25 +// Filename derivation and web-root removal are shared with the deactivator and
26 +// with uninstall.php, which runs without an autoloader — so they live in a
27 +// plain function file both can require. See includes/cleanup-webroot.php.
28 +require_once __DIR__ . '/../cleanup-webroot.php';
29 +
25 30 /**
26 31 * Sitemap Generator Class
27 32 *
28 33 * Generates and manages XML sitemaps for search engine optimization.
@@ -33,13 +38,246 @@
33 38 */
34 39 class Sitemap_Generator extends Abstract_SEO_Manager {
35 40
36 41 /**
42 + * Memoised WooCommerce page IDs kept out of the sitemap. Null until resolved.
43 + *
44 + * @since 2.0.1
45 + * @var int[]|null
46 + */
47 + private ?array $woocommerce_excluded_page_ids = null;
48 +
49 + /**
50 + * Inclusion flag -> the child sitemap it controls.
51 + *
52 + * Shared by the child-list builder and by the "did the caller change what
53 + * the sitemap includes?" check in maybe_promote_to_index().
54 + *
55 + * @since 1.31.0
56 + * @var array<string, string>
57 + */
58 + private const INCLUSION_CHILD_TYPES = [
59 + 'include_posts' => 'posts',
60 + 'include_pages' => 'pages',
61 + 'include_categories' => 'categories',
62 + 'include_tags' => 'tags',
63 + ];
64 +
65 + /**
66 + * Child sitemap type -> the object that actually supplies its entries.
67 + *
68 + * A child normally carries its object's own slug, but the sitemap presets UI
69 + * writes a display name for the WooCommerce taxonomy child
70 + * ('product_categories', not 'product_cat'), so a saved child list can name a
71 + * type no post type or taxonomy answers to. Resolving through here lets those
72 + * children take the same generic path as every other custom taxonomy instead
73 + * of needing a case of their own (#690).
74 + *
75 + * @since 2.7.0
76 + * @var array<string, string>
77 + */
78 + private const CHILD_TYPE_ALIASES = [
79 + 'product_categories' => 'product_cat',
80 + ];
81 +
82 + /**
37 83 * Supported sitemap types
38 84 *
39 85 * @since 1.0.0
40 86 * @var array
41 87 */
88 + /**
89 + * How many post IDs to hydrate at a time while walking the sitemap set.
90 + *
91 + * @since 2.0.1
92 + * @var int
93 + */
94 + private const ID_WALK_CHUNK = 500;
95 +
96 + /**
97 + * How long a content or settings change is debounced before the sitemap is
98 + * rebuilt, in seconds. Coalesces bulk edits into a single regeneration.
99 + *
100 + * @since 2.2.1
101 + * @var int
102 + */
103 + private const REGENERATION_DEBOUNCE = 30;
104 +
105 + /**
106 + * How far past its due time the scheduled rebuild may sit before a request
107 + * takes it over, in seconds.
108 + *
109 + * WP-Cron is request-driven, so on a site running DISABLE_WP_CRON, blocking
110 + * loopback requests, or seeing very little traffic the event never fires and
111 + * the sitemap silently stops updating (#629). The grace keeps the fast path
112 + * (cron) in charge under normal conditions.
113 + *
114 + * @since 2.2.1
115 + * @var int
116 + */
117 + private const REGENERATION_TAKEOVER_GRACE = 120;
118 +
119 + /**
120 + * Longest backoff between takeover attempts after a failed rebuild, so a
121 + * persistently failing generation cannot run on every admin request.
122 + *
123 + * @since 2.2.1
124 + * @var int
125 + */
126 + private const REGENERATION_MAX_BACKOFF = 3600;
127 +
128 + /**
129 + * Option holding the rebuild that content/settings changes are still waiting
130 + * on: `since`, `source` ('content'|'settings'), `attempts`, `next_attempt`.
131 + * Absent means the served sitemap is up to date with what triggered it.
132 + *
133 + * @since 2.2.1
134 + * @var string
135 + */
136 + public const REGENERATION_PENDING_OPTION = 'thinkrank_sitemap_regeneration_pending';
137 +
138 + /**
139 + * Option holding the last automatic-regeneration failure (`message`,
140 + * `source`, `time`), so the failure is visible instead of swallowed.
141 + *
142 + * @since 2.2.1
143 + * @var string
144 + */
145 + public const REGENERATION_ERROR_OPTION = 'thinkrank_sitemap_regeneration_error';
146 +
147 + /**
148 + * Delivery modes accepted by the `delivery_mode` setting.
149 + *
150 + * Mirrors LLMs_Txt_Manager::DELIVERY_MODES, which solved the same problem
151 + * for llms.txt. `auto` is the only value a site should normally need.
152 + *
153 + * @since 2.9.0
154 + * @var string[]
155 + */
156 + public const DELIVERY_MODES = ['auto', 'static', 'dynamic'];
157 +
158 + /**
159 + * Cache group for documents rendered on the dynamic path.
160 + *
161 + * @since 2.9.0
162 + * @var string
163 + */
164 + private const DYNAMIC_CACHE_PREFIX = 'thinkrank_sitemap_doc_';
165 +
166 + /**
167 + * How long a dynamically rendered document is cached.
168 + *
169 + * Invalidated by content and settings changes through
170 + * {@see self::flush_dynamic_cache()}, so this is only the backstop for a
171 + * change nothing hooked.
172 + *
173 + * @since 2.9.0
174 + * @var int
175 + */
176 + private const DYNAMIC_CACHE_TTL = 12 * HOUR_IN_SECONDS;
177 +
178 + /**
179 + * How long the render lock is held before it is assumed abandoned.
180 + *
181 + * Long enough for a large site's full build, short enough that a request
182 + * killed mid-build does not lock the endpoint out for meaningfully long.
183 + *
184 + * @since 2.9.0
185 + * @var int
186 + */
187 + private const RENDER_LOCK_TTL = 60;
188 +
189 + /**
190 + * Cached stand-in for "this site does not publish that name".
191 + *
192 + * published_document_names() lists what the configuration *could* produce,
193 + * but a child whose type is excluded produces nothing. Without a negative
194 + * entry those names miss the cache forever, so every request for one
195 + * rebuilt the entire sitemap — the same cost the positive cache exists to
196 + * avoid, on a public endpoint (#754 review).
197 + *
198 + * @since 2.9.0
199 + * @var string
200 + */
201 + private const ABSENT_MARKER = "\0thinkrank-absent";
202 +
203 + /**
204 + * How many times, and how long, a losing request waits for the winner.
205 + *
206 + * Bounded at roughly a second in total: past that, building a second copy
207 + * costs less than making a crawler wait.
208 + *
209 + * @since 2.9.0
210 + * @var int
211 + */
212 + private const RENDER_LOCK_WAIT_ATTEMPTS = 4;
213 +
214 + /**
215 + * @since 2.9.0
216 + * @var int
217 + */
218 + private const RENDER_LOCK_WAIT_MICROSECONDS = 250000;
219 +
220 + /**
221 + * Where generated documents go instead of disk, when set.
222 + *
223 + * Every sitemap document this class produces — segments, the index, the
224 + * single flat file and local-sitemap.xml — is published through the one
225 + * writer, {@see self::save_sitemap_to_file()}. Swapping that writer for a
226 + * collector is therefore all it takes to render the same bytes without a
227 + * filesystem, which is what dynamic delivery needs (#752). Doing it here
228 + * rather than duplicating the build pipeline is deliberate: a second
229 + * pipeline would drift from this one, and the index in particular is
230 + * assembled from whatever the children actually produced.
231 + *
232 + * @since 2.9.0
233 + * @var callable|null
234 + */
235 + private $document_sink = null;
236 +
237 + /**
238 + * Transient guarding against two generations running at once. Shared with
239 + * Sitemap_Endpoint's manual generate route so an automatic rebuild and a
240 + * manual one cannot write the same files concurrently.
241 + *
242 + * @since 2.2.1
243 + * @var string
244 + */
245 + public const GENERATION_LOCK_TRANSIENT = 'thinkrank_sitemap_generation_lock';
246 +
247 + /**
248 + * How many term IDs to hydrate at a time while walking a taxonomy.
249 + *
250 + * @since 2.0.1
251 + * @var int
252 + */
253 + private const TERM_WALK_CHUNK = 1000;
254 +
255 + /**
256 + * Exception code for an automatic rebuild stopped short of the memory limit.
257 + *
258 + * @since 2.10.1
259 + * @var int
260 + */
261 + private const MEMORY_ABORT_CODE = 4290;
262 +
263 + /**
264 + * Whether the chunked walks should stop before the memory limit. Only on
265 + * for automatic rebuilds, whose failure is recorded and retried.
266 + *
267 + * @since 2.10.1
268 + * @var bool
269 + */
270 + private bool $memory_guard = false;
271 +
272 + /**
273 + * Largest memory cost of one walked chunk in this rebuild, in bytes.
274 + *
275 + * @since 2.10.1
276 + * @var int
277 + */
278 + private int $walk_chunk_cost = 0;
279 +
42 280 private array $sitemap_types = [
43 281 'posts' => [
44 282 'name' => 'Posts',
45 283 'post_types' => ['post'],
@@ -66,25 +304,29 @@
66 304 ]
67 305 ];
68 306
69 307 /**
308 + * The generator the content-change listeners share, built on the first
309 + * change of a request.
310 + *
311 + * @since 2.10.1
312 + * @var self|null
313 + */
314 + private static ?self $listener = null;
315 +
316 + /**
70 317 * Constructor
71 318 *
72 319 * @since 1.0.0
320 + * @since 2.10.1 Registers no hooks, whatever `$register_hooks` says. The
321 + * content-change listeners are registered once, at bootstrap,
322 + * by register_content_listeners().
73 323 *
74 - * @param bool $register_hooks Optional. Whether to register the auto-generation
75 - * hooks. Pass false for a read-only instance built
76 - * solely to query settings — the hooks are bound to
77 - * `$this`, so a second hook-registering instance
78 - * 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.
79 326 */
80 327 public function __construct(bool $register_hooks = true) {
81 328 parent::__construct('sitemap');
82 -
83 - // Initialize auto-generation hooks
84 - if ($register_hooks) {
85 - $this->init_auto_generation_hooks();
86 - }
87 329 }
88 330
89 331 /**
90 332 * Filter the args of a sitemap post query.
@@ -128,34 +370,154 @@
128 370 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
129 371 }
130 372
131 373 /**
132 - * Initialize WordPress hooks for auto-generation
374 + * Register the content-change listeners that queue an automatic rebuild.
133 375 *
134 - * @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
135 391 * @return void
136 392 */
137 - private function init_auto_generation_hooks(): void {
138 - // Content change hooks - use priority 20 to run after other plugins
139 - add_action('save_post', [$this, 'handle_content_change'], 20, 2);
140 - add_action('delete_post', [$this, 'handle_content_deletion'], 20);
141 - add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
142 - 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);
143 407
144 - // Taxonomy change hooks
145 - add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
146 - add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
147 - 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 + }
148 413
149 - // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
150 - // and thinkrank_regenerate_sitemap_settings) are registered at plugin
151 - // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
152 - // rather than here. A cron run never builds this class via the REST
153 - // endpoint (no rest_api_init), so registering them in the constructor
154 - // 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);
155 431 }
156 432
157 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 + /**
158 520 * Generate XML sitemap
159 521 *
160 522 * @since 1.0.0
161 523 *
@@ -164,15 +526,10 @@
164 526 */
165 527 public function generate_sitemap(array $options = []): string {
166 528 $settings = $this->get_settings('site');
167 529
168 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
530 + $xml = $this->xml_prolog($settings, 'sitemap');
169 531
170 - // Add XSL stylesheet only if styling is enabled
171 - if (!empty($settings['enable_styling'])) {
172 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
173 - }
174 -
175 532 // Add image namespace if images are enabled
176 533 if (!empty($settings['include_images'])) {
177 534 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
178 535 } else {
@@ -284,8 +641,34 @@
284 641 ];
285 642 }
286 643
287 644 /**
645 + * Sitemap keys outside the defaults.
646 + *
647 + * @since 2.0.1
648 + *
649 + * @return string[]
650 + */
651 + protected function additional_setting_keys(): array {
652 + return ['selected_preset'];
653 + }
654 +
655 + /**
656 + * Inclusion flags are per post type and per taxonomy.
657 + *
658 + * A site registering a `product` post type stores `include_product`; an
659 + * enumerated list would go stale on the next registration, so the family
660 + * is matched instead.
661 + *
662 + * @since 2.0.1
663 + *
664 + * @return string[]
665 + */
666 + protected function dynamic_setting_key_patterns(): array {
667 + return ['/^include_[a-z0-9_]+$/', '/^exclude_[a-z0-9_]+$/'];
668 + }
669 +
670 + /**
288 671 * Get default settings for a context type (implements interface)
289 672 *
290 673 * @since 1.0.0
291 674 *
@@ -308,8 +691,15 @@
308 691 ]
309 692 ],
310 693 'use_sitemap_index' => false,
311 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 +
312 702 // General Settings
313 703 'links_per_sitemap' => 1000,
314 704 'include_images' => true,
315 705 'include_featured_images' => false,
@@ -331,8 +721,17 @@
331 721 // Advanced Options
332 722 'enable_styling' => true,
333 723 'custom_url_pattern' => 'sitemap-{type}.xml',
334 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 +
335 734 // Generation tracking
336 735 'last_generated' => ''
337 736 ];
338 737 }
@@ -337,8 +736,49 @@
337 736 ];
338 737 }
339 738
340 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 + /**
341 781 * Get settings schema definition (implements interface)
342 782 *
343 783 * @since 1.0.0
344 784 *
@@ -352,8 +792,15 @@
352 792 'title' => 'Enable Sitemap',
353 793 'description' => 'Generate XML sitemap for search engines',
354 794 'default' => true
355 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 + ],
356 803 'include_posts' => [
357 804 'type' => 'boolean',
358 805 'title' => 'Include Posts',
359 806 'description' => 'Include blog posts in sitemap',
@@ -394,8 +841,32 @@
394 841 'type' => 'string',
395 842 'title' => 'Last Generated',
396 843 'description' => 'Timestamp of last sitemap generation',
397 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' => ''
398 869 ]
399 870 ];
400 871 }
401 872
@@ -412,9 +883,14 @@
412 883 * @return string XML URL entry
413 884 */
414 885 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
415 886 $xml = " <url>\n";
416 - $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";
417 893 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
418 894 // than no timestamp, and an absent lastmod is valid per the spec.
419 895 if (!empty($lastmod)) {
420 896 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
@@ -424,9 +900,12 @@
424 900
425 901 // Add image entries if provided
426 902 foreach ($images as $image) {
427 903 $xml .= " <image:image>\n";
428 - $xml .= " <image:loc>" . esc_url($image['url']) . "</image:loc>\n";
904 + // The sitemap protocol wants an escaped URL, and WordPress hands back
905 + // attachment URLs with non-ASCII filenames unencoded (esc_url() does
906 + // not encode them either), so write the percent-encoded form (#924).
907 + $xml .= " <image:loc>" . esc_url(\ThinkRank\Core\Url_Validator::to_ascii(Url_Scheme::apply((string) $image['url']))) . "</image:loc>\n";
429 908
430 909 if (!empty($image['title'])) {
431 910 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
432 911 }
@@ -539,9 +1018,21 @@
539 1018 if (empty($all_ids)) {
540 1019 return;
541 1020 }
542 1021
543 - foreach (array_chunk($all_ids, 500) as $chunk) {
1022 + // Walk the ID list with a moving window rather than array_chunk().
1023 + // array_chunk() builds a second array holding every element again, so
1024 + // peak memory was twice the ID list — on a 100k-post site that is ~16MB
1025 + // where ~8MB is needed, and this walk is the one part of an otherwise
1026 + // well-bounded routine with no ceiling (#402).
1027 + $total = count($all_ids);
1028 +
1029 + for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
1030 + $this->assert_memory_headroom();
1031 + $chunk_start = memory_get_usage(true);
1032 +
1033 + $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
1034 +
544 1035 $posts = get_posts($this->filter_query_args([
545 1036 'post_type' => $post_types,
546 1037 'post_status' => 'publish',
547 1038 'numberposts' => count($chunk),
@@ -548,22 +1039,51 @@
548 1039 'post__in' => $chunk,
549 1040 'orderby' => 'post__in', // preserve the resolved order
550 1041 ]));
551 1042
1043 + // get_featured_image() hydrates each featured image under the
1044 + // attachment's own ID, which the chunk's post IDs do not reach.
1045 + $attachment_ids = [];
1046 +
552 1047 foreach ($posts as $post) {
553 1048 if ($this->should_include_in_sitemap($post, $settings)) {
554 - $url = get_permalink($post);
1049 + /**
1050 + * Filter a sitemap entry's permalink.
1051 + *
1052 + * The multilingual manager uses this to generate each
1053 + * translation's URL in its OWN language: the sitemap query
1054 + * deliberately runs with suppress_filters, and the cron
1055 + * rebuild runs with no language context at all, so a bare
1056 + * get_permalink() resolved every translation to the
1057 + * default-language URL — N entries sharing one <loc> (#409).
1058 + *
1059 + * @since 2.0.1
1060 + * @param string $url Permalink as WordPress resolved it.
1061 + * @param \WP_Post $post Post the entry describes.
1062 + */
1063 + $url = apply_filters('thinkrank_sitemap_post_permalink', get_permalink($post), $post);
555 1064 $lastmod = gmdate('c', strtotime($post->post_modified_gmt));
556 1065 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
557 1066 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
558 1067 $images = $this->extract_post_images($post, $settings);
559 1068
1069 + $thumbnail_id = (int) get_post_thumbnail_id($post);
1070 + if ($thumbnail_id > 0) {
1071 + $attachment_ids[] = $thumbnail_id;
1072 + }
1073 +
560 1074 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
561 1075 }
562 1076 }
563 1077
564 - // Free the hydrated chunk before loading the next one.
1078 + // Free the hydrated chunk before loading the next one — including
1079 + // the copies get_posts() left in the runtime object cache.
565 1080 unset($posts);
1081 + $this->release_walk_memory(
1082 + $chunk_start,
1083 + $this->chunk_post_cache_groups($post_types),
1084 + array_merge($chunk, $attachment_ids)
1085 + );
566 1086 }
567 1087 }
568 1088
569 1089 /**
@@ -619,9 +1139,17 @@
619 1139 if (is_wp_error($all_ids) || empty($all_ids)) {
620 1140 return;
621 1141 }
622 1142
623 - foreach (array_chunk($all_ids, 1000) as $chunk) {
1143 + // Same moving window as the post walk above, for the same reason.
1144 + $total = count($all_ids);
1145 +
1146 + for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
1147 + $this->assert_memory_headroom();
1148 + $chunk_start = memory_get_usage(true);
1149 +
1150 + $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
1151 +
624 1152 $terms = get_terms($this->filter_term_query_args([
625 1153 'taxonomy' => $taxonomy,
626 1154 'include' => $chunk,
627 1155 'orderby' => 'include', // preserve the resolved order
@@ -632,12 +1160,12 @@
632 1160 continue;
633 1161 }
634 1162
635 1163 foreach ($terms as $term) {
636 - // A term the user marked noindex must not be advertised in the
637 - // sitemap: the robots tag now honours term meta, so listing it
638 - // here would have the sitemap contradict the page's own tag.
639 - if ($this->term_is_noindexed((int) $term->term_id)) {
1164 + // A term whose archive says noindex (its own override or its
1165 + // taxonomy's), or that redirects, must not be advertised: the
1166 + // sitemap would contradict the page's own signal (#911).
1167 + if (!Indexability::is_indexable_term($term)) {
640 1168 continue;
641 1169 }
642 1170
643 1171 $url = get_term_link($term);
@@ -648,12 +1176,48 @@
648 1176 }
649 1177 }
650 1178
651 1179 unset($terms);
1180 + $this->release_walk_memory($chunk_start, ['terms', 'term_meta'], $chunk);
652 1181 }
653 1182 }
654 1183
655 1184 /**
1185 + * The XML declaration, ownership marker and optional stylesheet every
1186 + * sitemap document opens with.
1187 + *
1188 + * The marker is written unconditionally, and that is the point: removal on
1189 + * deactivate and uninstall deletes a web-root sitemap only when the file
1190 + * says it is ours, and our filenames are the canonical ones another SEO
1191 + * plugin writes too (#515). Tying the proof to `enable_styling` — the one
1192 + * marker older versions left — would mean a site with styling off either
1193 + * kept a shadowing file behind (#510) or had a competitor's deleted.
1194 + *
1195 + * @since 2.1.1
1196 + *
1197 + * The stylesheet URL is served by {@see Sitemap_Stylesheet}, not read off
1198 + * disk by the web server, because a static file cannot carry the site's own
1199 + * logo and colours (#639). It is a fixed URL: the palette is applied per
1200 + * request, so changing a brand colour needs no regeneration and shows up on
1201 + * sitemaps published long before.
1202 + *
1203 + * @param array $settings Sitemap settings (read for `enable_styling`).
1204 + * @param string $variant Stylesheet variant, `sitemap` or `index`.
1205 + * @return string Prolog lines, newline-terminated.
1206 + */
1207 + private function xml_prolog(array $settings, string $variant): string {
1208 + $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1209 + $xml .= THINKRANK_SITEMAP_MARKER . "\n";
1210 +
1211 + // The stylesheet is presentation only, so it stays opt-in.
1212 + if (!empty($settings['enable_styling'])) {
1213 + $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url(Sitemap_Stylesheet::url($variant)) . '"?>' . "\n";
1214 + }
1215 +
1216 + return $xml;
1217 + }
1218 +
1219 + /**
656 1220 * Wrap a set of <url> entry strings in a complete <urlset> document.
657 1221 *
658 1222 * @since 1.14.0
659 1223 *
@@ -662,14 +1226,10 @@
662 1226 * @param bool $with_image_ns Include the image sitemap namespace
663 1227 * @return string Full sitemap XML
664 1228 */
665 1229 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
666 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1230 + $xml = $this->xml_prolog($settings, 'sitemap');
667 1231
668 - if (!empty($settings['enable_styling'])) {
669 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
670 - }
671 -
672 1232 if ($with_image_ns && !empty($settings['include_images'])) {
673 1233 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
674 1234 } else {
675 1235 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
@@ -793,9 +1353,9 @@
793 1353 if (preg_match('/src=["\']([^"\']+)["\']/', $img_tag, $src_match)) {
794 1354 $image_url = $src_match[1];
795 1355
796 1356 // Skip if not a valid URL or external image
797 - if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
1357 + if (!\ThinkRank\Core\Url_Validator::is_valid($image_url)) {
798 1358 continue;
799 1359 }
800 1360
801 1361 // Extract title and alt attributes
@@ -846,9 +1406,16 @@
846 1406 '_builtin' => false
847 1407 ], 'names');
848 1408
849 1409 foreach ($custom_taxonomies as $taxonomy) {
850 - if ($this->should_include_taxonomy($taxonomy)) {
1410 + if (!$this->should_include_taxonomy($taxonomy)) {
1411 + continue;
1412 + }
1413 +
1414 + // Same as the post-type walk above: an explicit per-taxonomy flag
1415 + // now decides, and an unset flag keeps the previous "included"
1416 + // behaviour (#660).
1417 + if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $settings)) {
851 1418 $taxonomies[] = $taxonomy;
852 1419 }
853 1420 }
854 1421
@@ -903,14 +1470,15 @@
903 1470 if (is_wp_error($all_terms)) {
904 1471 return [];
905 1472 }
906 1473
907 - // Drop terms the user marked noindex. This path feeds the single general
908 - // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
909 - // so both need the filter or the two disagree about the same term.
1474 + // Drop terms that are not indexable destinations. This path feeds the
1475 + // single general sitemap while collect_taxonomy_entries_iter() feeds
1476 + // the segmented ones, so both need the filter or the two disagree about
1477 + // the same term.
910 1478 $all_terms = array_values(array_filter(
911 1479 $all_terms,
912 - fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1480 + static fn($term) => $term instanceof \WP_Term && Indexability::is_indexable_term($term)
913 1481 ));
914 1482
915 1483 // Group terms by taxonomy
916 1484 return $this->group_terms_by_taxonomy($all_terms);
@@ -1061,8 +1629,17 @@
1061 1629 * @param array $settings Sitemap settings
1062 1630 * @return bool Whether to include in sitemap
1063 1631 */
1064 1632 private function should_include_in_sitemap(\WP_Post $post, array $settings): bool {
1633 + // The WooCommerce cart, checkout and account pages are transactional,
1634 + // never indexable, and generate_default_robots_rules() already emits a
1635 + // Disallow for each of them. Listing them here submitted URLs our own
1636 + // robots.txt blocks, which Search Console reports as "Submitted URL
1637 + // blocked by robots.txt". Yoast and Rank Math exclude the same three.
1638 + if (in_array($post->ID, $this->woocommerce_excluded_page_ids(), true)) {
1639 + return false;
1640 + }
1641 +
1065 1642 // Respect user setting for password protected content
1066 1643 if (!empty($post->post_password) && !empty($settings['exclude_password_protected'])) {
1067 1644 return false;
1068 1645 }
@@ -1083,17 +1660,16 @@
1083 1660 if (!in_array($post->post_status, ['publish', 'private'], true)) {
1084 1661 return false;
1085 1662 }
1086 1663
1087 - // Check if post overrides robots and sets noindex.
1088 - if ((bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true)) {
1089 - $raw = get_post_meta($post->ID, '_thinkrank_robots_meta', true);
1090 - if (is_string($raw) && $raw !== '') {
1091 - $robots = json_decode($raw, true);
1092 - if (is_array($robots) && !empty($robots['noindex'])) {
1093 - return false;
1094 - }
1095 - }
1664 + // A URL whose page says noindex, or that ThinkRank redirects, is not a
1665 + // destination. Only the per-post noindex used to be read here, so a
1666 + // post type set to No-index still had every item listed, and redirected
1667 + // posts were submitted as "Page with redirect" (#911). The password
1668 + // check stays with the setting above: listing protected posts is a
1669 + // choice this sitemap has always offered.
1670 + if (Indexability::is_post_noindexed($post) || Indexability::is_post_redirected($post)) {
1671 + return false;
1096 1672 }
1097 1673
1098 1674 return true;
1099 1675 }
@@ -1098,32 +1674,39 @@
1098 1674 return true;
1099 1675 }
1100 1676
1101 1677 /**
1102 - * Whether a term carries an explicit noindex override.
1678 + * WooCommerce pages that must never reach the sitemap.
1103 1679 *
1104 - * Mirrors the post-side check in should_include_post(); terms store the same
1105 - * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1106 - * by the update-term-seo ability and by the SEO importer.
1680 + * Resolved through wc_get_page_id() so a store that moved or renamed its
1681 + * cart/checkout/account pages is still matched. Returns an empty list when
1682 + * WooCommerce is not active. Memoised — should_include_in_sitemap() runs
1683 + * once per post.
1107 1684 *
1108 - * @since 1.31.0
1685 + * @since 2.0.1
1109 1686 *
1110 - * @param int $term_id Term to test.
1111 - * @return bool True when the term is marked noindex.
1687 + * @return int[] Page IDs to exclude.
1112 1688 */
1113 - private function term_is_noindexed(int $term_id): bool {
1114 - if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1115 - return false;
1689 + private function woocommerce_excluded_page_ids(): array {
1690 + if ($this->woocommerce_excluded_page_ids !== null) {
1691 + return $this->woocommerce_excluded_page_ids;
1116 1692 }
1117 1693
1118 - $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1119 - if (!is_string($raw) || $raw === '') {
1120 - return false;
1694 + $ids = [];
1695 +
1696 + if (function_exists('wc_get_page_id')) {
1697 + foreach (['cart', 'checkout', 'myaccount'] as $page) {
1698 + $id = (int) wc_get_page_id($page);
1699 + // wc_get_page_id() returns -1 when the page is not configured.
1700 + if ($id > 0) {
1701 + $ids[] = $id;
1702 + }
1703 + }
1121 1704 }
1122 1705
1123 - $robots = json_decode($raw, true);
1706 + $this->woocommerce_excluded_page_ids = $ids;
1124 1707
1125 - return is_array($robots) && !empty($robots['noindex']);
1708 + return $ids;
1126 1709 }
1127 1710
1128 1711 /**
1129 1712 * Count total URLs in sitemap
@@ -1289,9 +1872,15 @@
1289 1872 if (!empty($settings['include_pages'])) {
1290 1873 $post_types[] = 'page';
1291 1874 }
1292 1875
1293 - // Auto-detect public custom post types that should be included
1876 + // Auto-detect public custom post types that should be included.
1877 + //
1878 + // A custom type's `include_<slug>` / `exclude_<slug>` flag is honoured
1879 + // here (#660). It was previously stored — additional_setting_keys()
1880 + // has always let those keys through — but never read, so a CPT was in
1881 + // the sitemap whatever the setting said. Unset still means included, so
1882 + // a site that never touched the flag is unaffected.
1294 1883 $custom_post_types = get_post_types([
1295 1884 'public' => true,
1296 1885 '_builtin' => false
1297 1886 ], 'names');
@@ -1296,9 +1885,13 @@
1296 1885 '_builtin' => false
1297 1886 ], 'names');
1298 1887
1299 1888 foreach ($custom_post_types as $post_type) {
1300 - if ($this->should_include_post_type($post_type)) {
1889 + if (!$this->should_include_post_type($post_type)) {
1890 + continue;
1891 + }
1892 +
1893 + if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $post_type, $settings)) {
1301 1894 $post_types[] = $post_type;
1302 1895 }
1303 1896 }
1304 1897
@@ -1319,18 +1912,11 @@
1319 1912 // Templately's internal `templately_library` store — which are template
1320 1913 // records, not standalone indexable URLs. BetterDocs `docs` and
1321 1914 // WooCommerce `product` register exclude_from_search => false, so they
1322 1915 // remain included.
1323 - if (!is_post_type_viewable($post_type)) {
1324 - return false;
1325 - }
1326 -
1327 - $post_type_obj = get_post_type_object($post_type);
1328 - if (!$post_type_obj || !empty($post_type_obj->exclude_from_search)) {
1329 - return false;
1330 - }
1331 -
1332 - return true;
1916 + // The predicate lives in Content_Type_Settings so the matrix can ask
1917 + // the same question before offering a switch for this post type.
1918 + return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_post_type($post_type);
1333 1919 }
1334 1920
1335 1921 /**
1336 1922 * Check if taxonomy should trigger regeneration
@@ -1339,11 +1925,13 @@
1339 1925 * @param string $taxonomy Taxonomy slug
1340 1926 * @return bool True if taxonomy should trigger regeneration
1341 1927 */
1342 1928 private function should_include_taxonomy(string $taxonomy): bool {
1343 - // Include all public taxonomies (presets control which sitemaps are created)
1344 - $taxonomy_obj = get_taxonomy($taxonomy);
1345 - return $taxonomy_obj && $taxonomy_obj->public;
1929 + // Public taxonomies only, and only the ones this generator can actually
1930 + // emit — the same predicate the content-type matrix asks before it
1931 + // offers a sitemap switch for one (presets still control which
1932 + // sitemaps are created).
1933 + return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_taxonomy($taxonomy);
1346 1934 }
1347 1935
1348 1936 /**
1349 1937 * Schedule debounced sitemap regeneration
@@ -1351,16 +1939,679 @@
1351 1939 * @since 1.0.0
1352 1940 * @return void
1353 1941 */
1354 1942 private function schedule_debounced_regeneration(): void {
1355 - // Clear any existing scheduled regeneration
1356 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap');
1943 + $this->mark_regeneration_pending('content');
1944 + $this->debounce_event('thinkrank_regenerate_sitemap');
1945 + }
1357 1946
1358 - // Schedule regeneration in 30 seconds to debounce rapid changes
1359 - wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap');
1947 + /**
1948 + * Schedule (or keep) the debounced single event behind a regeneration hook.
1949 + *
1950 + * An event that is already due is left alone. WP-Cron only runs when a
1951 + * request arrives, so on a site with DISABLE_WP_CRON, a blocked loopback or
1952 + * little traffic an overdue event can sit in the queue for a long time —
1953 + * clearing and re-scheduling it on every save pushed the rebuild
1954 + * permanently 30 seconds into the future and the sitemap never updated
1955 + * (#629). Debouncing only against an event that has not come due yet keeps
1956 + * the bulk-edit coalescing without starving the rebuild.
1957 + *
1958 + * @since 2.2.1
1959 + * @param string $hook Regeneration hook to debounce.
1960 + * @return void
1961 + */
1962 + private function debounce_event(string $hook): void {
1963 + $next = wp_next_scheduled($hook);
1964 +
1965 + if ($next !== false) {
1966 + if ($next <= time()) {
1967 + return;
1968 + }
1969 +
1970 + wp_clear_scheduled_hook($hook);
1971 + }
1972 +
1973 + wp_schedule_single_event(time() + self::REGENERATION_DEBOUNCE, $hook);
1360 1974 }
1361 1975
1362 1976 /**
1977 + * Record that a rebuild is outstanding, so an overdue one can be taken over
1978 + * by a later request and its staleness surfaced in the UI.
1979 + *
1980 + * `since` is the *oldest* outstanding change: it is what the takeover grace
1981 + * and the admin staleness warning are measured from, so successive edits
1982 + * must not push it forward. A settings change outranks a content change —
1983 + * it rebuilds regardless of the auto_generate toggle and handles a sitemap
1984 + * that has just been disabled — so once one is outstanding it stays the
1985 + * recorded source until the rebuild lands.
1986 + *
1987 + * @since 2.2.1
1988 + * @param string $source Either 'content' or 'settings'.
1989 + * @return void
1990 + */
1991 + private function mark_regeneration_pending(string $source): void {
1992 + // Whatever made the static files stale made the rendered ones stale
1993 + // too. Invalidating here rather than only on the rebuild keeps the two
1994 + // delivery modes reacting to exactly the same triggers, which is the
1995 + // only way a dynamic site stays as fresh as a static one (#752).
1996 + $this->flush_dynamic_cache();
1997 +
1998 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1999 + $pending = is_array($pending) ? $pending : [];
2000 +
2001 + $since = !empty($pending['since']) ? (int) $pending['since'] : time();
2002 + $current = isset($pending['source']) ? (string) $pending['source'] : '';
2003 + $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
2004 +
2005 + // Merged so the bookkeeping a rebuild keeps on the marker (`started`,
2006 + // `memory_limit`) survives an edit made while it is outstanding.
2007 + update_option(
2008 + self::REGENERATION_PENDING_OPTION,
2009 + array_merge($pending, [
2010 + 'since' => $since,
2011 + 'source' => $source,
2012 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
2013 + 'next_attempt' => !empty($pending['next_attempt'])
2014 + ? (int) $pending['next_attempt']
2015 + : time() + self::REGENERATION_TAKEOVER_GRACE,
2016 + // Bumped on every change so a rebuild can tell whether the edit
2017 + // it started for is still the newest one outstanding.
2018 + 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
2019 + ]),
2020 + true
2021 + );
2022 + }
2023 +
2024 + /**
2025 + * The revision of the outstanding rebuild, for
2026 + * {@see mark_regeneration_complete()} to compare against once it is done.
2027 + *
2028 + * @since 2.2.1
2029 + * @return int Current revision, 0 when nothing is outstanding.
2030 + */
2031 + private function current_regeneration_revision(): int {
2032 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2033 +
2034 + return (is_array($pending) && !empty($pending['revision'])) ? (int) $pending['revision'] : 0;
2035 + }
2036 +
2037 + /**
2038 + * Clear the outstanding-rebuild marker and any recorded failure.
2039 + *
2040 + * Public because a manual generation satisfies whatever the automatic path
2041 + * was still waiting to write.
2042 + *
2043 + * @since 2.2.1
2044 + * @return void
2045 + */
2046 + public function mark_regeneration_complete(?int $revision = null): void {
2047 + // The write succeeded, so whatever failure was on record is history.
2048 + if (get_option(self::REGENERATION_ERROR_OPTION, null) !== null) {
2049 + delete_option(self::REGENERATION_ERROR_OPTION);
2050 + }
2051 +
2052 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
2053 +
2054 + if ($pending === null) {
2055 + return;
2056 + }
2057 +
2058 + // A change that landed while this rebuild was running is not covered by
2059 + // the files it just wrote, so it has to stay outstanding — otherwise, on
2060 + // a site where WP-Cron never fires, clearing the marker would strand it
2061 + // exactly the way #629 stranded everything.
2062 + if (
2063 + $revision !== null
2064 + && is_array($pending)
2065 + && (int) ($pending['revision'] ?? 0) !== $revision
2066 + ) {
2067 + // This attempt succeeded, so drop what it claimed: the newer change
2068 + // waits the grace a fresh edit gets, not a failure backoff it never
2069 + // earned. Not zero: that edit queued its own debounced event, and
2070 + // a marker due at once had the next admin request rebuild in its
2071 + // shutdown and the event rebuild again seconds later.
2072 + unset($pending['started'], $pending['memory_limit']);
2073 + $pending['attempts'] = 0;
2074 + $pending['next_attempt'] = time() + self::REGENERATION_TAKEOVER_GRACE;
2075 +
2076 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2077 + return;
2078 + }
2079 +
2080 + delete_option(self::REGENERATION_PENDING_OPTION);
2081 + }
2082 +
2083 + /**
2084 + * Record the attempt that is about to run before it runs.
2085 + *
2086 + * A PHP fatal — the memory limit or max_execution_time — is not a
2087 + * Throwable, so no catch or finally around the generation runs when the
2088 + * process dies, and a failure recorded afterwards was never recorded at
2089 + * all: `attempts` stayed 0, the backoff never applied, and the next request
2090 + * started the same doomed rebuild again (a fatal every few minutes for as
2091 + * long as an admin was logged in). Claiming the attempt up front makes the
2092 + * backoff hold even when nothing after this line gets to run, and leaves a
2093 + * `started` stamp the next attempt can recognise as an interrupted one.
2094 + *
2095 + * @since 2.10.1
2096 + * @return void
2097 + */
2098 + private function claim_regeneration_attempt(): void {
2099 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
2100 +
2101 + // Nothing outstanding (e.g. a manual generation already satisfied it):
2102 + // there is no marker to retry from, so nothing to claim.
2103 + if (!is_array($pending) || empty($pending['since'])) {
2104 + return;
2105 + }
2106 +
2107 + if (!empty($pending['started'])) {
2108 + // The previous attempt claimed itself and never reported back.
2109 + update_option(
2110 + self::REGENERATION_ERROR_OPTION,
2111 + [
2112 + 'message' => __('The previous automatic sitemap rebuild stopped before it finished, most likely because PHP ran out of memory or time. It is retried with a growing delay. If this keeps happening, raise the PHP memory_limit or max_execution_time, or run WP-Cron from a system cron.', 'thinkrank'),
2113 + 'source' => isset($pending['source']) ? (string) $pending['source'] : 'content',
2114 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 1,
2115 + 'time' => (int) $pending['started'],
2116 + ],
2117 + false
2118 + );
2119 + }
2120 +
2121 + $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
2122 +
2123 + $pending['attempts'] = $attempts;
2124 + $pending['next_attempt'] = time() + $this->regeneration_backoff($attempts);
2125 + $pending['started'] = time();
2126 +
2127 + update_option(self::REGENERATION_PENDING_OPTION, $pending, true);
2128 + }
2129 +
2130 + /**
2131 + * Delay before the next takeover after the given number of attempts.
2132 + *
2133 + * @since 2.10.1
2134 + * @param int $attempts Attempts made so far (1 or more).
2135 + * @return int Seconds.
2136 + */
2137 + private function regeneration_backoff(int $attempts): int {
2138 + return (int) min(
2139 + self::REGENERATION_TAKEOVER_GRACE * (2 ** min(max($attempts, 1), 10)),
2140 + self::REGENERATION_MAX_BACKOFF
2141 + );
2142 + }
2143 +
2144 + /**
2145 + * Record a failed regeneration instead of discarding it.
2146 + *
2147 + * Keeps the pending marker in place so the rebuild is retried, but backs the
2148 + * next attempt off exponentially (capped) so a persistently failing
2149 + * generation cannot run on every admin request.
2150 + *
2151 + * @since 2.2.1
2152 + * @since 2.10.1 Accepts the memory limit a rebuild had to stop short of, and
2153 + * does not count an attempt claim_regeneration_attempt()
2154 + * already counted.
2155 + * @param string $message Failure detail.
2156 + * @param string $source Either 'content' or 'settings'.
2157 + * @param int|null $memory_limit Memory limit (bytes) the rebuild stopped
2158 + * short of, when that was the failure.
2159 + * @return void
2160 + */
2161 + private function record_regeneration_failure(string $message, string $source, ?int $memory_limit = null): void {
2162 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2163 + $pending = is_array($pending) ? $pending : [];
2164 + $attempts = !empty($pending['attempts']) ? (int) $pending['attempts'] : 0;
2165 +
2166 + // An attempt that claimed itself up front has already been counted.
2167 + if (empty($pending['started'])) {
2168 + $attempts++;
2169 + }
2170 + $attempts = max($attempts, 1);
2171 +
2172 + $backoff = $this->regeneration_backoff($attempts);
2173 +
2174 + // Same precedence mark_regeneration_pending() enforces: a settings
2175 + // rebuild outranks a content one and must not be downgraded by a failed
2176 + // attempt. Overwriting it routed the retry back through the content
2177 + // path, where should_auto_generate() can be false and the completion
2178 + // marker then discards the settings rebuild entirely. Only the
2179 + // outstanding rebuild is upgraded — the recorded error keeps reporting
2180 + // whichever attempt actually failed.
2181 + $current = isset($pending['source']) ? (string) $pending['source'] : '';
2182 + $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
2183 +
2184 + $marker = [
2185 + 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
2186 + 'source' => $pending_source,
2187 + 'attempts' => $attempts,
2188 + 'next_attempt' => time() + $backoff,
2189 + 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
2190 + ];
2191 +
2192 + // Remembered so has_memory_for_retry() can keep requests with no more
2193 + // memory than this from repeating the same attempt.
2194 + if ($memory_limit !== null && $memory_limit > 0) {
2195 + $marker['memory_limit'] = $memory_limit;
2196 + }
2197 +
2198 + update_option(self::REGENERATION_PENDING_OPTION, $marker, true);
2199 +
2200 + update_option(
2201 + self::REGENERATION_ERROR_OPTION,
2202 + [
2203 + 'message' => $message,
2204 + 'source' => $source,
2205 + 'attempts' => $attempts,
2206 + 'time' => time(),
2207 + ],
2208 + false
2209 + );
2210 +
2211 + if (defined('WP_DEBUG') && WP_DEBUG) {
2212 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
2213 + error_log(sprintf('ThinkRank: sitemap %s regeneration failed — %s', $source, $message));
2214 + }
2215 + }
2216 +
2217 + /**
2218 + * Is a rebuild outstanding and past the point where WP-Cron should have run
2219 + * it?
2220 + *
2221 + * Deliberately cheap — one autoloaded option read — because it is consulted
2222 + * on every admin request to decide whether the takeover is needed.
2223 + *
2224 + * @since 2.2.1
2225 + * @return bool True when a request should rebuild the sitemap itself.
2226 + */
2227 + public static function has_overdue_regeneration(): bool {
2228 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2229 +
2230 + if (!is_array($pending) || empty($pending['since'])) {
2231 + return false;
2232 + }
2233 +
2234 + $due = !empty($pending['next_attempt'])
2235 + ? (int) $pending['next_attempt']
2236 + : (int) $pending['since'] + self::REGENERATION_TAKEOVER_GRACE;
2237 +
2238 + return time() >= $due;
2239 + }
2240 +
2241 + /**
2242 + * Rebuild the sitemap in-request when WP-Cron has not delivered.
2243 + *
2244 + * Hooked on `shutdown` for admin, REST and CLI requests only (see
2245 + * Plugin::register_sitemap_cron_listeners()), so the work happens after the
2246 + * response has been sent and never adds latency to a visitor page view.
2247 + *
2248 + * @since 2.2.1
2249 + * @return void
2250 + */
2251 + public function run_overdue_regeneration(): void {
2252 + if (!self::has_overdue_regeneration()) {
2253 + return;
2254 + }
2255 +
2256 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2257 + $pending = is_array($pending) ? $pending : [];
2258 + $source = isset($pending['source']) ? (string) $pending['source'] : 'content';
2259 +
2260 + // Cron is running and its own event for this rebuild is still queued
2261 + // and due: it is about to do exactly this work. Only then — an event
2262 + // that has already been consumed (e.g. it fired while another process
2263 + // held the generation lock) is never re-queued, and returning here
2264 + // unconditionally left the rebuild to requests that could not finish it.
2265 + if (wp_doing_cron()) {
2266 + $hook = $source === 'settings' ? 'thinkrank_regenerate_sitemap_settings' : 'thinkrank_regenerate_sitemap';
2267 + $next = wp_next_scheduled($hook);
2268 +
2269 + if ($next !== false && $next <= time()) {
2270 + return;
2271 + }
2272 + }
2273 +
2274 + // The last attempt had to stop short of this process's memory limit.
2275 + // Retrying at the same (or a lower) limit only repeats that, so leave
2276 + // the rebuild to a process with more room — WP-CLI, a system cron, or a
2277 + // host with a higher limit — instead of burning it on every request.
2278 + if (!$this->has_memory_for_retry($pending)) {
2279 + return;
2280 + }
2281 +
2282 + if ($source === 'settings') {
2283 + $this->regenerate_sitemap_from_settings();
2284 + return;
2285 + }
2286 +
2287 + $this->auto_regenerate_sitemap();
2288 + }
2289 +
2290 + /**
2291 + * Acquire the shared generation lock.
2292 + *
2293 + * @since 2.2.1
2294 + * @return bool True when this process may generate.
2295 + */
2296 + private function acquire_generation_lock(): bool {
2297 + if (get_transient(self::GENERATION_LOCK_TRANSIENT)) {
2298 + return false;
2299 + }
2300 +
2301 + set_transient(self::GENERATION_LOCK_TRANSIENT, time(), 5 * MINUTE_IN_SECONDS);
2302 +
2303 + return true;
2304 + }
2305 +
2306 + /**
2307 + * Release the shared generation lock.
2308 + *
2309 + * @since 2.2.1
2310 + * @return void
2311 + */
2312 + private function release_generation_lock(): void {
2313 + delete_transient(self::GENERATION_LOCK_TRANSIENT);
2314 + }
2315 +
2316 + /**
2317 + * This process's PHP memory limit in bytes.
2318 + *
2319 + * @since 2.10.1
2320 + * @return int Bytes, or -1 when unlimited (or unreadable).
2321 + */
2322 + private function current_memory_limit(): int {
2323 + $limit = (string) ini_get('memory_limit');
2324 +
2325 + if ($limit === '' || $limit === '-1') {
2326 + return -1;
2327 + }
2328 +
2329 + $bytes = (int) wp_convert_hr_to_bytes($limit);
2330 +
2331 + return $bytes > 0 ? $bytes : -1;
2332 + }
2333 +
2334 + /**
2335 + * May this process retry a rebuild that last stopped at the memory limit?
2336 + *
2337 + * Raises the limit the way wp-admin does first, so a request that can get
2338 + * more room than the failed attempt had is still allowed to try.
2339 + *
2340 + * @since 2.10.1
2341 + * @param array $pending The pending marker.
2342 + * @return bool True when there is no recorded memory failure, or this
2343 + * process has more memory than the attempt that failed.
2344 + */
2345 + private function has_memory_for_retry(array $pending): bool {
2346 + if (empty($pending['memory_limit'])) {
2347 + return true;
2348 + }
2349 +
2350 + wp_raise_memory_limit('admin');
2351 +
2352 + $limit = $this->current_memory_limit();
2353 +
2354 + return $limit === -1 || $limit > (int) $pending['memory_limit'];
2355 + }
2356 +
2357 + /**
2358 + * Release what one walked chunk left behind.
2359 + *
2360 + * Hydrating a chunk through get_posts()/get_terms() also stores every
2361 + * object and its meta in the in-process object cache, which nothing
2362 + * empties until the request ends. Unsetting the chunk therefore freed
2363 + * nothing, and the walk grew with the size of the site instead of the size
2364 + * of a chunk — about 1.3 GB on a 45k-post site.
2365 + *
2366 + * A persistent object cache that supports it drops only its in-process
2367 + * copy (`flush_runtime`); the shared store keeps its data. WordPress's
2368 + * default cache has no shared store, and flushing it would empty every
2369 + * group for the rest of the request (options, the queried object, other
2370 + * plugins' data), so there only the chunk's own entries are deleted. A
2371 + * persistent cache without `flush_runtime` is left alone: deleting from it
2372 + * would evict the objects for every other request too.
2373 + *
2374 + * @since 2.10.1
2375 + * @param int $chunk_start memory_get_usage(true) before the chunk was hydrated.
2376 + * @param array $groups Cache groups keyed by the chunk's object IDs.
2377 + * @param int[] $ids The chunk's object IDs, plus any objects it
2378 + * hydrated under their own (featured images).
2379 + * @return void
2380 + * @throws \Error See assert_memory_headroom().
2381 + */
2382 + private function release_walk_memory(int $chunk_start, array $groups, array $ids): void {
2383 + // What one chunk costs before it is released: the margin the next one
2384 + // needs. Measured in the same real allocated size assert_memory_headroom()
2385 + // compares against the limit, so the two are the same unit.
2386 + $this->walk_chunk_cost = max($this->walk_chunk_cost, memory_get_usage(true) - $chunk_start);
2387 +
2388 + if (wp_using_ext_object_cache()) {
2389 + if (
2390 + function_exists('wp_cache_supports')
2391 + && wp_cache_supports('flush_runtime')
2392 + && function_exists('wp_cache_flush_runtime')
2393 + ) {
2394 + wp_cache_flush_runtime();
2395 + }
2396 + } elseif (!empty($ids)) {
2397 + foreach ($groups as $group) {
2398 + wp_cache_delete_multiple($ids, $group);
2399 + }
2400 + }
2401 +
2402 + $this->assert_memory_headroom();
2403 + }
2404 +
2405 + /**
2406 + * Cache groups get_posts() fills per post for the given post types.
2407 + *
2408 + * The post, its meta, and one relationships group per taxonomy the post
2409 + * type uses (update_object_term_cache()). Term objects themselves are
2410 + * bounded by the number of terms, not posts, so they are left cached.
2411 + *
2412 + * @since 2.10.1
2413 + * @param string[] $post_types Post types being walked.
2414 + * @return string[] Cache groups keyed by post ID.
2415 + */
2416 + private function chunk_post_cache_groups(array $post_types): array {
2417 + $groups = ['posts', 'post_meta'];
2418 +
2419 + foreach (get_object_taxonomies($post_types) as $taxonomy) {
2420 + $groups[] = $taxonomy . '_relationships';
2421 + }
2422 +
2423 + return array_values(array_unique($groups));
2424 + }
2425 +
2426 + /**
2427 + * Stop an automatic rebuild before the memory limit rather than at it.
2428 + *
2429 + * A PHP memory fatal skips every catch and finally, so the lock, the
2430 + * failure record and the backoff are all lost with it, while stopping here
2431 + * is an ordinary, fully recorded failure. Checked before each chunk is
2432 + * hydrated, against a margin of at least the largest chunk seen so far.
2433 + *
2434 + * It throws an \Error, not an \Exception, on purpose: the per-segment
2435 + * catch (\Exception) blocks in generate_multiple_sitemaps() would otherwise
2436 + * swallow it and carry on — writing an index without the aborted segments
2437 + * and then pruning their files as orphans. Only the automatic rebuild's
2438 + * catch (\Throwable) is meant to see it.
2439 + *
2440 + * @since 2.10.1
2441 + * @return void
2442 + * @throws \Error When the automatic rebuild is close to the memory limit.
2443 + */
2444 + private function assert_memory_headroom(): void {
2445 + if (!$this->memory_guard) {
2446 + return;
2447 + }
2448 +
2449 + $limit = $this->current_memory_limit();
2450 + if ($limit === -1) {
2451 + return;
2452 + }
2453 +
2454 + // A fifth of the limit (at least 32 MB) for writing the files, or one
2455 + // and a half of the costliest chunk if that is more — and never more
2456 + // than half the limit either way. Without that outer cap a single
2457 + // anomalously expensive chunk (500 posts of serialised page-builder or
2458 + // ACF meta reaches hundreds of megabytes) puts the margin above the
2459 + // limit itself, so every later check aborts at any usage at all, the
2460 + // failure records this process's limit, and has_memory_for_retry()
2461 + // then refuses every process that has the same limit. A site that
2462 + // never actually ran out of memory would stop rebuilding until WP-CLI
2463 + // or a system cron happened to run.
2464 + $headroom = (int) min(
2465 + max(
2466 + min(max($limit * 0.2, 32 * MB_IN_BYTES), $limit * 0.5),
2467 + $this->walk_chunk_cost * 1.5
2468 + ),
2469 + $limit * 0.5
2470 + );
2471 +
2472 + // The real allocated size, which is what PHP enforces memory_limit
2473 + // against; memory_get_usage(false) reports only what is handed out of
2474 + // those allocations and so understates the margin by the allocator's
2475 + // slack.
2476 + $usage = memory_get_usage(true);
2477 +
2478 + if ($usage > $limit - $headroom) {
2479 + throw new \Error(
2480 + sprintf(
2481 + /* translators: 1: memory in use, 2: PHP memory limit. */
2482 + esc_html__('The sitemap rebuild was stopped at %1$s of the %2$s PHP memory limit, before PHP would have run out of memory. It will be retried by a process with more memory (WP-CLI or a system cron). To let it finish in the admin, raise the PHP memory_limit.', 'thinkrank'),
2483 + esc_html((string) size_format($usage)),
2484 + esc_html((string) size_format($limit))
2485 + ),
2486 + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- an integer class constant, not output.
2487 + self::MEMORY_ABORT_CODE
2488 + );
2489 + }
2490 + }
2491 +
2492 + /**
2493 + * Run an automatic rebuild's generation with the fatal-safe bookkeeping.
2494 + *
2495 + * @since 2.10.1
2496 + * @param array $settings Sitemap settings.
2497 + * @return bool Whatever generate_and_save() returned.
2498 + * @throws \Throwable Whatever generation throws, after the memory guard is
2499 + * switched back off.
2500 + */
2501 + private function generate_for_regeneration(array $settings): bool {
2502 + // Same headroom wp-admin gives itself; a no-op when the limit is
2503 + // already higher or unlimited.
2504 + wp_raise_memory_limit('admin');
2505 +
2506 + $this->claim_regeneration_attempt();
2507 + $this->memory_guard = true;
2508 + $this->walk_chunk_cost = 0;
2509 +
2510 + try {
2511 + return $this->generate_and_save($settings);
2512 + } finally {
2513 + $this->memory_guard = false;
2514 + }
2515 + }
2516 +
2517 + /**
2518 + * Record a failure thrown by an automatic rebuild.
2519 + *
2520 + * @since 2.10.1
2521 + * @param \Throwable $e What was thrown.
2522 + * @param string $source Either 'content' or 'settings'.
2523 + * @return void
2524 + */
2525 + private function record_thrown_regeneration_failure(\Throwable $e, string $source): void {
2526 + $memory_limit = null;
2527 +
2528 + if ($e instanceof \Error && $e->getCode() === self::MEMORY_ABORT_CODE) {
2529 + $memory_limit = $this->current_memory_limit();
2530 + $memory_limit = $memory_limit > 0 ? $memory_limit : null;
2531 + }
2532 +
2533 + $this->record_regeneration_failure($e->getMessage(), $source, $memory_limit);
2534 + }
2535 +
2536 + /**
2537 + * Report how automatic regeneration is faring, for the admin UI.
2538 + *
2539 + * The feature used to fail invisibly: `last_generated` simply stopped
2540 + * advancing and nothing drew attention to it (#629).
2541 + *
2542 + * @since 2.2.1
2543 + * @return array Health payload.
2544 + */
2545 + public function get_regeneration_health(): array {
2546 + $settings = $this->get_settings('site');
2547 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2548 + $pending = is_array($pending) ? $pending : [];
2549 + $error = get_option(self::REGENERATION_ERROR_OPTION, []);
2550 + $error = is_array($error) ? $error : [];
2551 +
2552 + $since = !empty($pending['since']) ? (int) $pending['since'] : 0;
2553 +
2554 + $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap');
2555 + if ($next_scheduled === false) {
2556 + $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap_settings');
2557 + }
2558 +
2559 + return [
2560 + 'auto_generate' => !empty($settings['auto_generate']),
2561 + 'last_generated' => $settings['last_generated'] ?? '',
2562 + 'pending_since' => $since ? gmdate('c', $since) : null,
2563 + 'pending_seconds' => $since ? max(0, time() - $since) : 0,
2564 + 'next_scheduled' => $next_scheduled ? gmdate('c', (int) $next_scheduled) : null,
2565 + 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON,
2566 + 'stale' => $this->is_sitemap_stale($settings),
2567 + 'last_error' => !empty($error['message'])
2568 + ? [
2569 + 'message' => (string) $error['message'],
2570 + 'source' => isset($error['source']) ? (string) $error['source'] : 'content',
2571 + 'time' => !empty($error['time']) ? gmdate('c', (int) $error['time']) : null,
2572 + ]
2573 + : null,
2574 + ];
2575 + }
2576 +
2577 + /**
2578 + * Has published content changed since the served sitemap was last written?
2579 + *
2580 + * Uses core's cached last-modified lookup, which only considers published
2581 + * posts — the same content the sitemap covers.
2582 + *
2583 + * @since 2.2.1
2584 + * @param array $settings Sitemap settings.
2585 + * @return bool True when the sitemap is behind the content.
2586 + */
2587 + private function is_sitemap_stale(array $settings): bool {
2588 + if (empty($settings['enabled']) || empty($settings['last_generated'])) {
2589 + // Never generated is already reported separately by the UI.
2590 + return false;
2591 + }
2592 +
2593 + $generated = strtotime((string) $settings['last_generated']);
2594 + if (!$generated) {
2595 + return false;
2596 + }
2597 +
2598 + $modified = get_lastpostmodified('gmt');
2599 + if (!$modified) {
2600 + return false;
2601 + }
2602 +
2603 + $modified = strtotime($modified . ' UTC');
2604 + if (!$modified) {
2605 + return false;
2606 + }
2607 +
2608 + // A minute of slack keeps a rebuild that ran alongside the edit from
2609 + // reporting itself as stale.
2610 + return $modified > ($generated + MINUTE_IN_SECONDS);
2611 + }
2612 +
2613 + /**
1363 2614 * Public entry point to debounce-rebuild the sitemap after a settings change
1364 2615 * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
1365 2616 * file reflects the new settings instead of going stale until a content edit.
1366 2617 *
@@ -1369,10 +2620,10 @@
1369 2620 public function schedule_regeneration(): void {
1370 2621 // Debounce against rapid successive saves, but use the settings-specific
1371 2622 // hook so the rebuild runs regardless of the auto_generate toggle (which
1372 2623 // only governs content-change-triggered regeneration).
1373 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap_settings');
1374 - wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap_settings');
2624 + $this->mark_regeneration_pending('settings');
2625 + $this->debounce_event('thinkrank_regenerate_sitemap_settings');
1375 2626 }
1376 2627
1377 2628 /**
1378 2629 * Rebuild the served sitemap after an explicit settings change.
@@ -1381,11 +2632,22 @@
1381 2632 * auto_generate setting: the user deliberately changed inclusion rules and
1382 2633 * expects the served file to reflect them even if content-triggered
1383 2634 * auto-generation is turned off. Still respects the master `enabled` flag.
1384 2635 *
1385 - * @return void
2636 + * @since 2.10.0 Reports whether the served sitemap was actually rebuilt, so
2637 + * a caller can say so rather than assume it (#764). Existing
2638 + * callers that ignore the return are unaffected.
2639 + *
2640 + * @return bool True when the served sitemap now reflects the settings.
1386 2641 */
1387 - public function regenerate_sitemap_from_settings(): void {
2642 + public function regenerate_sitemap_from_settings(): bool {
2643 + if (!$this->acquire_generation_lock()) {
2644 + // A manual generation (or another request's takeover) is already
2645 + // writing the files; the pending marker survives so this rebuild is
2646 + // retried rather than lost.
2647 + return false;
2648 + }
2649 +
1388 2650 try {
1389 2651 $settings = $this->get_settings('site');
1390 2652 if (empty($settings['enabled'])) {
1391 2653 // The sitemap was disabled: remove the previously generated static
@@ -1390,30 +2652,98 @@
1390 2652 if (empty($settings['enabled'])) {
1391 2653 // The sitemap was disabled: remove the previously generated static
1392 2654 // files so the web server stops serving a stale sitemap that
1393 2655 // crawlers would otherwise keep fetching.
1394 - $this->delete_published_sitemaps();
1395 - return;
2656 + //
2657 + // A file that could not be removed is still being served, so
2658 + // this is not a success. Reporting one here would tell a caller
2659 + // the sitemap was gone while the web server kept answering with
2660 + // it, which is the failure this return value exists to prevent
2661 + // (#764).
2662 + $removal = $this->delete_published_sitemaps($settings);
2663 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2664 +
2665 + if (!empty($stuck)) {
2666 + $this->record_regeneration_failure(
2667 + $this->stuck_files_message($stuck, true),
2668 + 'settings'
2669 + );
2670 +
2671 + return false;
2672 + }
2673 +
2674 + $this->mark_regeneration_complete();
2675 +
2676 + return true;
1396 2677 }
1397 - $this->generate_and_save($settings);
2678 +
2679 + $revision = $this->current_regeneration_revision();
2680 +
2681 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2682 + // Returns false when a static file is stuck in the web root:
2683 + // the server keeps serving that file in preference to WordPress,
2684 + // so the switch has not taken effect (#764).
2685 + return $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2686 + }
2687 +
2688 + if ($this->generate_for_regeneration($settings)) {
2689 + $this->mark_regeneration_complete($revision);
2690 +
2691 + return true;
2692 + }
2693 +
2694 + $this->record_regeneration_failure(
2695 + $this->write_failure_message(),
2696 + 'settings'
2697 + );
2698 +
2699 + return false;
1398 2700 } catch (\Throwable $e) {
1399 - // Settings-triggered regeneration failed - details in exception.
2701 + $this->record_thrown_regeneration_failure($e, 'settings');
2702 +
2703 + return false;
2704 + } finally {
2705 + $this->release_generation_lock();
1400 2706 }
1401 2707 }
1402 2708
1403 2709 /**
2710 + * When a rebuild has been outstanding since, or 0 when none is.
2711 + *
2712 + * Lets a caller report an honest "saved, but the served file has not caught
2713 + * up yet" instead of a bare success (#764).
2714 + *
2715 + * @since 2.10.0
2716 + * @return int Unix timestamp, or 0 when nothing is pending.
2717 + */
2718 + public static function regeneration_pending_since(): int {
2719 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2720 +
2721 + if (!is_array($pending) || empty($pending['since'])) {
2722 + return 0;
2723 + }
2724 +
2725 + return (int) $pending['since'];
2726 + }
2727 +
2728 + /**
1404 2729 * Remove every static sitemap file ThinkRank publishes to the web root.
1405 2730 *
1406 - * Called when the sitemap feature is disabled, and by the cleanup route, so
1407 - * /sitemap.xml, /sitemap_index.xml, the segmented children (incl. paginated
1408 - * -N pages), and /local-sitemap.xml stop being served. Only ThinkRank's own
1409 - * filenames are targeted; WordPress core's wp-sitemap.xml and any other
1410 - * plugin's sitemap in the web root are left untouched.
2731 + * Called when the sitemap feature is disabled, by the cleanup route, and by
2732 + * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
2733 + * children (incl. paginated -N pages), and /local-sitemap.xml stop being
2734 + * served. Only ThinkRank's own filenames are targeted; WordPress core's
2735 + * wp-sitemap.xml and any other plugin's sitemap in the web root are left
2736 + * untouched.
1411 2737 *
1412 2738 * @since 1.31.0 Returns the filenames removed, and accepts the settings to
1413 2739 * derive them from, so a caller that already read them (and
1414 2740 * needs to report what went) does not have to re-read or
1415 2741 * re-derive the name list.
2742 + * @since 2.1.0 Delegates to thinkrank_webroot_delete_sitemaps(). Uninstall
2743 + * needs the same removal but has no autoloader to reach this
2744 + * class, so the logic moved to includes/cleanup-webroot.php
2745 + * and this stays as the in-plugin entry point.
1416 2746 *
1417 2747 * @param array|null $settings Optional. Sitemap settings; defaults to the
1418 2748 * saved site settings.
1419 2749 * @return array{deleted: string[], failed: string[]} Basenames removed, and
@@ -1419,123 +2749,58 @@
1419 2749 * @return array{deleted: string[], failed: string[]} Basenames removed, and
1420 2750 * those that existed but could not be removed.
1421 2751 */
1422 2752 public function delete_published_sitemaps(?array $settings = null): array {
1423 - $settings = $settings ?? $this->get_settings('site');
1424 - $deleted = [];
1425 - $failed = [];
1426 -
1427 - // Base filenames to remove. Derive the child/index names from the stored
1428 - // sitemap_urls so a custom custom_url_pattern (e.g. seo-{type}.xml) is
1429 - // honored, not just the default sitemap-*.xml naming. Include the current
1430 - // primary + the default names as a safety net.
1431 - $names = [
1432 - 'sitemap.xml',
1433 - 'sitemap_index.xml',
1434 - 'local-sitemap.xml',
1435 - $this->get_primary_sitemap_filename($settings),
1436 - ];
1437 -
1438 - foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
1439 - if (empty($config['url'])) {
1440 - continue;
1441 - }
1442 - $name = basename((string) wp_parse_url($config['url'], PHP_URL_PATH));
1443 - if ($name !== '') {
1444 - $names[] = $name;
1445 - }
1446 - }
1447 -
1448 - // Segment names for every type we could have published under the current
1449 - // url pattern, not just the ones stored in sitemap_urls. Two states leave
1450 - // a published child unlisted there: settings that drifted from what is on
1451 - // disk (a single-file entry while an index and its children are live), and
1452 - // a content type that has since been switched off. Deriving the names
1453 - // from the pattern reaches those without falling back to a 'sitemap-*.xml'
1454 - // glob, which would also match another plugin's segments.
1455 - $names = array_merge($names, $this->publishable_segment_filenames($settings));
1456 -
1457 - foreach (array_unique(array_filter($names)) as $name) {
1458 - // Remove the file itself and any paginated -N variants of its stem
1459 - // (e.g. seo-posts.xml plus seo-posts-2.xml, seo-posts-3.xml…).
1460 - $path = ABSPATH . $name;
1461 - if (file_exists($path)) {
1462 - wp_delete_file($path);
1463 - // wp_delete_file() returns nothing, so confirm by re-checking.
1464 - if (file_exists($path)) {
1465 - $failed[] = $name;
1466 - } else {
1467 - $deleted[] = $name;
1468 - }
1469 - }
1470 - if (preg_match('/^(.*)\.xml$/i', $name, $m)) {
1471 - // Pagination pages only — a numeric suffix on this exact stem.
1472 - // Globbing '<stem>-*.xml' matched any name that merely started
1473 - // with the stem, so the default 'sitemap.xml' entry pulled in
1474 - // every sitemap-*.xml in the root, including another plugin's.
1475 - $paged_pattern = '/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i';
1476 -
1477 - foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
1478 - $paged_name = basename($paged);
1479 - if (!preg_match($paged_pattern, $paged_name)) {
1480 - continue;
1481 - }
1482 -
1483 - wp_delete_file($paged);
1484 - if (file_exists($paged)) {
1485 - $failed[] = $paged_name;
1486 - } else {
1487 - $deleted[] = $paged_name;
1488 - }
1489 - }
1490 - }
1491 - }
1492 -
1493 - return [
1494 - 'deleted' => array_values(array_unique($deleted)),
1495 - 'failed' => array_values(array_unique($failed)),
1496 - ];
2753 + return thinkrank_webroot_delete_sitemaps($settings ?? $this->get_settings('site'));
1497 2754 }
1498 2755
1499 2756 /**
1500 2757 * Every child-sitemap filename this site could have published.
1501 2758 *
1502 - * Formats the configured url pattern against each type ThinkRank segments by
1503 - * — the four built-ins plus every public custom post type and public custom
1504 - * taxonomy — ignoring whether that type is currently included. The point is
1505 - * to recognise our own filenames, and a type that was published and later
1506 - * disabled still left a file behind.
1507 - *
1508 2759 * @since 1.31.0
2760 + * @since 2.1.0 Delegates to thinkrank_webroot_segment_filenames().
1509 2761 *
1510 2762 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
1511 2763 * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
1512 2764 */
1513 2765 private function publishable_segment_filenames(array $settings): array {
1514 - $pattern = (string) ($settings['custom_url_pattern'] ?? 'sitemap-{type}.xml');
1515 - if (strpos($pattern, '{type}') === false) {
1516 - return [];
1517 - }
2766 + return thinkrank_webroot_segment_filenames($settings);
2767 + }
1518 2768
1519 - $types = ['posts', 'pages', 'categories', 'tags'];
1520 -
1521 - foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
1522 - $types[] = (string) $cpt;
1523 - }
1524 -
1525 - foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
1526 - $types[] = (string) $taxonomy;
1527 - }
1528 -
1529 - $names = [];
1530 - foreach (array_unique($types) as $type) {
1531 - $name = basename(str_replace('{type}', $type, $pattern));
1532 - if ($name !== '') {
1533 - $names[] = $name;
1534 - }
1535 - }
1536 -
1537 - return $names;
2769 + /**
2770 + * Can this web-root file be shown to be a sitemap ThinkRank wrote?
2771 + *
2772 + * The generator deletes as often as cleanup does — a segment that dropped
2773 + * out of the set, a pagination page beyond the new count, the local sitemap
2774 + * after the business identity was cleared — and until 2.1.1 it did all
2775 + * three by filename alone. That is the #515 bug on a far more frequent
2776 + * trigger: our names are the canonical ones, so an ordinary regeneration
2777 + * (post save, term change, settings save) destroyed RankMath's
2778 + * `sitemap-tags.xml` and `local-sitemap.xml` with no deactivation involved.
2779 + *
2780 + * Every name the generator derives comes from the current settings — the
2781 + * url pattern, the configured `sitemap_urls`, `local-sitemap.xml` — but the
2782 + * ownership test is still asked with `$name_derived = false`, which switches
2783 + * off the legacy fallback for the whole generator side.
2784 + *
2785 + * The fallback exists to recover a pre-2.1.1 file written with
2786 + * `enable_styling` off, which carries neither marker. That recovery belongs
2787 + * to the once-off cleanup paths. Here it can only do harm: this method runs
2788 + * on every post save, and everything this version writes carries
2789 + * THINKRANK_SITEMAP_MARKER, so after the site's first regeneration an
2790 + * unmarked file at one of our names is by definition somebody else's — and
2791 + * deleting it on an ordinary regeneration is #515 through the more common
2792 + * door. The cost is a stale unmarked segment left on disk until deactivation
2793 + * picks it up, which is the safe direction to fail in.
2794 + *
2795 + * @since 2.1.1
2796 + *
2797 + * @param string $path Absolute path to a file in the web root.
2798 + * @param array $settings Sitemap settings.
2799 + * @return bool True when the file may be deleted.
2800 + */
2801 + private function webroot_sitemap_is_ours(string $path, array $settings): bool {
2802 + return thinkrank_webroot_sitemap_is_ours($path, $settings, false);
1538 2803 }
1539 2804
1540 2805 /**
1541 2806 * Auto-regenerate sitemap (called by scheduled action)
@@ -1543,20 +2808,48 @@
1543 2808 * @since 1.0.0
1544 2809 * @return void
1545 2810 */
1546 2811 public function auto_regenerate_sitemap(): void {
2812 + if (!$this->acquire_generation_lock()) {
2813 + // A manual generation (or another request's takeover) is already
2814 + // writing the files; the pending marker survives so this rebuild is
2815 + // retried rather than lost.
2816 + return;
2817 + }
2818 +
1547 2819 try {
1548 2820 // Double-check that auto-generation is still enabled
1549 2821 if (!$this->should_auto_generate()) {
2822 + // Nothing outstanding can be delivered while the feature is off,
2823 + // so drop the marker rather than let the takeover retry forever.
2824 + $this->mark_regeneration_complete();
1550 2825 return;
1551 2826 }
1552 2827
1553 - $this->generate_and_save($this->get_settings('site'));
2828 + $revision = $this->current_regeneration_revision();
2829 + $settings = $this->get_settings('site');
1554 2830
1555 - // Sitemap auto-regenerated successfully
2831 + // See regenerate_sitemap_from_settings(): nothing to write.
2832 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2833 + $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2834 + return;
2835 + }
1556 2836
2837 + if ($this->generate_for_regeneration($settings)) {
2838 + $this->mark_regeneration_complete($revision);
2839 + } else {
2840 + // Previously this returned quietly and last_generated simply
2841 + // stopped advancing, leaving the site owner with no way to learn
2842 + // the sitemap had stopped updating (#629).
2843 + $this->record_regeneration_failure(
2844 + $this->write_failure_message(),
2845 + 'content'
2846 + );
2847 + }
1557 2848 } catch (\Throwable $e) {
1558 - // Sitemap auto-regeneration failed - error details available in exception
2849 + $this->record_thrown_regeneration_failure($e, 'content');
2850 + } finally {
2851 + $this->release_generation_lock();
1559 2852 }
1560 2853 }
1561 2854
1562 2855 /**
@@ -1571,8 +2864,26 @@
1571 2864 * @param array $settings Sitemap settings.
1572 2865 * @return bool True when the sitemap files were written.
1573 2866 */
1574 2867 public function generate_and_save(array $settings): bool {
2868 + // Dynamic delivery publishes no files, so writing them here would put a
2869 + // static copy back in the web root for the server to serve in place of
2870 + // the dynamic route. Guarding at each call site left gaps — the
2871 + // snapshot migrator's post-import regeneration had none — so the rule
2872 + // lives with the writing instead.
2873 + //
2874 + // `is_collecting()` is the exception that makes dynamic delivery work
2875 + // at all: render_document() and collect_documents() reach this same
2876 + // method with the writer swapped for a collector, and that is precisely
2877 + // the dynamic build. Only a real write is skipped.
2878 + if (!$this->is_collecting() && 'dynamic' === $this->resolve_delivery_mode($settings)) {
2879 + // Whatever prompted this call changed the sitemap's content, so the
2880 + // rendered copies must not outlive it.
2881 + $this->flush_dynamic_cache();
2882 +
2883 + return true;
2884 + }
2885 +
1575 2886 // Index mode is driven by the use_sitemap_index toggle (not merely by how
1576 2887 // many sitemap_urls happen to be configured). When the toggle is on but
1577 2888 // no child sitemaps are set up yet, synthesize the per-type segmented set
1578 2889 // so we emit a real <sitemapindex> with paginated children instead of a
@@ -1583,16 +2894,29 @@
1583 2894 $results = $this->generate_multiple_sitemaps($settings);
1584 2895 $written = !empty($results['success']);
1585 2896 } else {
1586 2897 $xml = $this->generate_sitemap($settings);
1587 - $written = $this->save_sitemap_to_file($xml, $this->get_primary_sitemap_filename($settings));
2898 + $primary = $this->get_primary_sitemap_filename($settings);
2899 + $written = $this->save_sitemap_to_file($xml, $primary);
1588 2900
1589 2901 // Local business sitemap is a standalone file, regenerated on the
1590 2902 // single-sitemap path too (this is the default mode).
1591 2903 $this->regenerate_local_sitemap($settings);
2904 +
2905 + // Switching out of index mode leaves sitemap_index.xml and every
2906 + // child on disk, still served and never refreshed again. The index
2907 + // path already prunes what it no longer owns; this path never did,
2908 + // so the site kept serving two sitemap trees (#563). Ownership is
2909 + // still tested per file, so another plugin's sitemap at one of our
2910 + // names is never touched (#515).
2911 + $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
1592 2912 }
1593 2913
1594 - if ($written) {
2914 + // last_generated describes what is on disk. A dynamic render publishes
2915 + // nothing, so advancing it would report a static publication that never
2916 + // happened and would let primary_sitemap_file_exists() callers believe
2917 + // there is a file to serve.
2918 + if ($written && !$this->is_collecting()) {
1595 2919 $settings['last_generated'] = gmdate('c');
1596 2920 $this->save_settings('site', null, $settings);
1597 2921 }
1598 2922
@@ -1599,8 +2923,433 @@
1599 2923 return $written;
1600 2924 }
1601 2925
1602 2926 /**
2927 + * Whether this instance is rendering documents rather than publishing them.
2928 + *
2929 + * @since 2.9.0
2930 + *
2931 + * @return bool
2932 + */
2933 + private function is_collecting(): bool {
2934 + return $this->document_sink !== null;
2935 + }
2936 +
2937 + /**
2938 + * Complete a regeneration that delivers dynamically, retiring stale files.
2939 + *
2940 + * Dynamic delivery renders nothing to disk, but that is only half the job.
2941 + * A web server hands back an existing `/sitemap.xml` without ever loading
2942 + * WordPress, so any file left over from a previous static generation goes on
2943 + * being served forever and {@see \ThinkRank\Frontend\SEO_Manager
2944 + * ::maybe_serve_sitemap()} is never reached. Switching to dynamic while
2945 + * leaving those files in place would therefore appear to do nothing at all.
2946 + *
2947 + * Both transitions matter and they differ:
2948 + *
2949 + * - An explicit switch to `dynamic` happens on a site whose root is usually
2950 + * still writable, so the files can simply be removed.
2951 + * - An `auto` site that becomes read-only cannot remove them, because
2952 + * deleting an entry needs write permission on the directory that holds
2953 + * it. There the stale sitemap really is stuck in front of us, and the
2954 + * honest outcome is a recorded failure naming it rather than a rebuild
2955 + * reported as complete (#754 review).
2956 + *
2957 + * Ownership is tested per file by the shared helper, so another plugin's
2958 + * sitemap at one of our names is never deleted (#515).
2959 + *
2960 + * @since 2.9.0
2961 + *
2962 + * @param array $settings Sitemap settings.
2963 + * @param int $revision Revision this rebuild is completing.
2964 + * @param string $source 'settings' or 'content', for the failure record.
2965 + * @return void
2966 + */
2967 + private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): bool {
2968 + $this->flush_dynamic_cache();
2969 +
2970 + $removal = $this->delete_published_sitemaps($settings);
2971 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2972 +
2973 + if (!empty($stuck)) {
2974 + $this->record_regeneration_failure($this->stuck_files_message($stuck), $source);
2975 +
2976 + return false;
2977 + }
2978 +
2979 + $this->mark_regeneration_complete($revision);
2980 +
2981 + return true;
2982 + }
2983 +
2984 + /**
2985 + * Why a stale file left in the web root means the change has not landed.
2986 + *
2987 + * Shared by every path that removes published files, so they cannot
2988 + * describe the same situation differently (#764).
2989 + *
2990 + * The two situations that reach it differ in what WordPress is doing, and
2991 + * the message has to say which. After a switch to dynamic delivery
2992 + * WordPress IS serving the sitemap and the files shadow it. After the
2993 + * sitemap is switched off WordPress serves nothing, so the one message
2994 + * used to tell a site owner who had just disabled the sitemap that it was
2995 + * "being served from WordPress", which is the opposite of what they did.
2996 + *
2997 + * @since 2.10.0
2998 + * @since 2.10.0 Public, so the REST endpoint uses it rather than a copy;
2999 + * takes $sitemap_disabled for the disabled path.
3000 + *
3001 + * @param string[] $stuck Basenames that could not be removed.
3002 + * @param bool $sitemap_disabled True when the files outlived disabling
3003 + * the sitemap rather than a switch to
3004 + * dynamic delivery.
3005 + * @return string
3006 + */
3007 + public function stuck_files_message(array $stuck, bool $sitemap_disabled = false): string {
3008 + if ($sitemap_disabled) {
3009 + return sprintf(
3010 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
3011 + __('The sitemap is disabled, but these files are still in the site root and your web server is still serving them: %1$s. They could not be removed because %2$s is not writable. Delete them, or ask your host to make the WordPress root writable.', 'thinkrank'),
3012 + implode(', ', $stuck),
3013 + untrailingslashit(ABSPATH)
3014 + );
3015 + }
3016 +
3017 + return sprintf(
3018 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
3019 + __('The sitemap is being served from WordPress, but these files are still in the site root and your web server will keep serving them instead: %1$s. They could not be removed because %2$s is not writable. Delete them, or ask your host to make the WordPress root writable.', 'thinkrank'),
3020 + implode(', ', $stuck),
3021 + untrailingslashit(ABSPATH)
3022 + );
3023 + }
3024 +
3025 + /**
3026 + * What to tell the site owner when publishing the files failed.
3027 + *
3028 + * The old wording stated the symptom and stopped there, so the reported
3029 + * cause was a guess and this reached support as a plugin fault rather than
3030 + * a folder permission (#752, #753). When the root is demonstrably
3031 + * unwritable, say that, and say what to do about it.
3032 + *
3033 + * @since 2.9.0
3034 + *
3035 + * @return string
3036 + */
3037 + private function write_failure_message(): string {
3038 + if (!wp_is_writable(ABSPATH)) {
3039 + return sprintf(
3040 + /* translators: %s: absolute path to the WordPress root. */
3041 + __('The sitemap could not be written because the folder %s is not writable by PHP. Ask your host to make the WordPress root writable, or set Sitemap Delivery to Dynamic to serve the sitemap without writing files.', 'thinkrank'),
3042 + untrailingslashit(ABSPATH)
3043 + );
3044 + }
3045 +
3046 + return __('The sitemap files could not be written to the site root.', 'thinkrank');
3047 + }
3048 +
3049 + /**
3050 + * How this site delivers its sitemap.
3051 + *
3052 + * `auto` is resolved on whether the web root can be written. That is the
3053 + * right signal here (unlike llms.txt, where the question is whether the
3054 + * server applies the .htaccess charset block): a site whose root is
3055 + * read-only cannot publish a sitemap file at all, and before this existed
3056 + * the feature simply failed with "The sitemap files could not be written to
3057 + * the site root." and served nothing (#752).
3058 + *
3059 + * @since 2.9.0
3060 + *
3061 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3062 + * @return string One of 'static' or 'dynamic'. Never 'auto'.
3063 + */
3064 + public function resolve_delivery_mode(?array $settings = null): string {
3065 + $settings = $settings ?? $this->get_settings('site');
3066 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
3067 +
3068 + if ('static' === $mode || 'dynamic' === $mode) {
3069 + return $mode;
3070 + }
3071 +
3072 + return wp_is_writable(ABSPATH) ? 'static' : 'dynamic';
3073 + }
3074 +
3075 + /**
3076 + * Render one published sitemap document without touching the filesystem.
3077 + *
3078 + * Runs the ordinary build pipeline with the writer swapped for a collector,
3079 + * so the bytes returned here are the bytes the static path would have
3080 + * written. `SitemapDeliveryParityTest` asserts that equivalence rather than
3081 + * trusting it.
3082 + *
3083 + * The whole set is built to answer for one file, because the index can only
3084 + * be assembled from the children that were actually produced. The result is
3085 + * cached per document, so that cost is paid once per change and not once
3086 + * per crawler request.
3087 + *
3088 + * @since 2.9.0
3089 + *
3090 + * @param string $filename Published file name, e.g. 'sitemap.xml'.
3091 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3092 + * @return string|null XML, or null when this site does not publish that name.
3093 + */
3094 + public function render_document(string $filename, ?array $settings = null): ?string {
3095 + $filename = basename($filename);
3096 + $settings = $settings ?? $this->get_settings('site');
3097 +
3098 + if (empty($settings['enabled'])) {
3099 + return null;
3100 + }
3101 +
3102 + $cached = get_transient($this->dynamic_cache_key($filename));
3103 + if (self::ABSENT_MARKER === $cached) {
3104 + return null;
3105 + }
3106 + if (is_string($cached) && '' !== $cached) {
3107 + return $cached;
3108 + }
3109 +
3110 + // A miss builds the whole set, because the index can only be assembled
3111 + // from the children that were actually produced. Caching only the
3112 + // requested document therefore made a crawler walking the index and its
3113 + // children rebuild the entire site's sitemap once per file — every post
3114 + // and taxonomy query repeated N times on a public endpoint (#754
3115 + // review). The set is built once and stored in full.
3116 + return $this->stream_documents($settings, $filename);
3117 + }
3118 +
3119 + /**
3120 + * Build every document, caching each as it is produced, keeping one.
3121 + *
3122 + * A miss has to build the whole set, because the index can only be
3123 + * assembled from the children that were actually produced. It does not have
3124 + * to *hold* the whole set: the static path never keeps more than one page
3125 + * in memory, writing each to disk as it goes, and buffering every
3126 + * document's XML to return one of them undid that on the request path,
3127 + * where a large site's entire sitemap corpus would sit in a single PHP
3128 + * process (#754 review).
3129 + *
3130 + * So the sink writes each document straight to its cache entry and lets it
3131 + * go, retaining only the one this request is answering. Peak retention is
3132 + * one document, whatever the site's size.
3133 + *
3134 + * Concurrency: the first request through takes a short lock and does the
3135 + * work. One that finds the lock held waits a bounded moment for the winner
3136 + * to publish, then builds anyway, because serving a correct sitemap late
3137 + * beats serving none.
3138 + *
3139 + * @since 2.9.0
3140 + *
3141 + * @param array $settings Sitemap settings.
3142 + * @param string $wanted Document this request is answering.
3143 + * @return string|null XML for $wanted, or null when the site does not publish it.
3144 + */
3145 + private function stream_documents(array $settings, string $wanted): ?string {
3146 + $lock = self::DYNAMIC_CACHE_PREFIX . 'lock';
3147 +
3148 + if (!$this->acquire_render_lock($lock)) {
3149 + for ($attempt = 0; $attempt < self::RENDER_LOCK_WAIT_ATTEMPTS; $attempt++) {
3150 + usleep(self::RENDER_LOCK_WAIT_MICROSECONDS);
3151 +
3152 + $cached = get_transient($this->dynamic_cache_key($wanted));
3153 + if (self::ABSENT_MARKER === $cached) {
3154 + return null;
3155 + }
3156 + if (is_string($cached) && '' !== $cached) {
3157 + return $cached;
3158 + }
3159 + }
3160 + }
3161 +
3162 + $kept = null;
3163 + // Names only. Keeping the bodies here would be the very retention this
3164 + // method exists to avoid.
3165 + $produced = [];
3166 +
3167 + $previous = $this->document_sink;
3168 + $this->document_sink = function (string $name, string $xml) use (&$kept, &$produced, $wanted): void {
3169 + $produced[$name] = true;
3170 + set_transient($this->dynamic_cache_key($name), $xml, self::DYNAMIC_CACHE_TTL);
3171 +
3172 + if ($name === $wanted) {
3173 + $kept = $xml;
3174 + }
3175 + };
3176 +
3177 + try {
3178 + $this->generate_and_save($settings);
3179 +
3180 + // Names the configuration lists but this build did not produce get
3181 + // a negative entry, so asking for one again is a cache hit rather
3182 + // than another full rebuild.
3183 + $absent = $this->published_document_names($settings);
3184 +
3185 + // Also the exact name this request asked for: a paginated page past
3186 + // the end of a stem is a legitimate request shape that the base
3187 + // list cannot enumerate, and without an entry it would rebuild on
3188 + // every hit.
3189 + $absent[] = $wanted;
3190 +
3191 + foreach (array_unique($absent) as $name) {
3192 + if (!isset($produced[$name])) {
3193 + set_transient($this->dynamic_cache_key($name), self::ABSENT_MARKER, self::DYNAMIC_CACHE_TTL);
3194 + }
3195 + }
3196 + } finally {
3197 + $this->document_sink = $previous;
3198 + delete_transient($lock);
3199 + }
3200 +
3201 + return $kept;
3202 + }
3203 +
3204 + /**
3205 + * Take the render lock, if it is free.
3206 + *
3207 + * Not atomic across processes, and deliberately so: the fallback for losing
3208 + * a race is duplicated work, never a wrong or missing sitemap, so a
3209 + * heavier primitive would buy nothing here.
3210 + *
3211 + * @since 2.9.0
3212 + *
3213 + * @param string $lock Lock transient name.
3214 + * @return bool True when this request holds the lock.
3215 + */
3216 + private function acquire_render_lock(string $lock): bool {
3217 + if (false !== get_transient($lock)) {
3218 + return false;
3219 + }
3220 +
3221 + set_transient($lock, time(), self::RENDER_LOCK_TTL);
3222 +
3223 + return true;
3224 + }
3225 +
3226 + /**
3227 + * Build every document this site publishes and return them all.
3228 + *
3229 + * Verification and tooling only. This retains the whole set in memory, so
3230 + * it must never be used to answer a request: {@see self::stream_documents()}
3231 + * is the serving path and keeps one document at a time regardless of site
3232 + * size (#754 review). `SitemapDeliveryParityTest` enforces that separation
3233 + * by failing if the request path routes back through here.
3234 + *
3235 + * @since 2.9.0
3236 + *
3237 + * @param array $settings Sitemap settings.
3238 + * @return array<string,string> Filename => XML.
3239 + */
3240 + public function collect_documents(array $settings): array {
3241 + $documents = [];
3242 +
3243 + $previous = $this->document_sink;
3244 + $this->document_sink = static function (string $name, string $xml) use (&$documents): void {
3245 + $documents[$name] = $xml;
3246 + };
3247 +
3248 + try {
3249 + $this->generate_and_save($settings);
3250 + } finally {
3251 + $this->document_sink = $previous;
3252 + }
3253 +
3254 + return $documents;
3255 + }
3256 +
3257 + /**
3258 + * The file names this site publishes, without building their contents.
3259 + *
3260 + * Used by the request router to decide whether a URL is ours before doing
3261 + * any work. Cheap: it reads the configured child list rather than querying
3262 + * for entries.
3263 + *
3264 + * @since 2.9.0
3265 + *
3266 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3267 + * @return string[] File names, including paginated pages that may exist.
3268 + */
3269 + public function published_document_names(?array $settings = null): array {
3270 + $settings = $settings ?? $this->get_settings('site');
3271 + $resolved = $this->maybe_promote_to_index($settings);
3272 +
3273 + $names = [$this->get_primary_sitemap_filename($settings), 'local-sitemap.xml'];
3274 +
3275 + foreach ((array) ($resolved['sitemap_urls'] ?? []) as $child) {
3276 + if (!is_array($child) || empty($child['enabled'])) {
3277 + continue;
3278 + }
3279 +
3280 + $path = (string) wp_parse_url((string) ($child['url'] ?? ''), PHP_URL_PATH);
3281 + if ('' !== $path) {
3282 + $names[] = basename($path);
3283 + }
3284 + }
3285 +
3286 + return array_values(array_unique(array_filter($names)));
3287 + }
3288 +
3289 + /**
3290 + * Does this site publish a document under that name?
3291 + *
3292 + * Not a plain membership test against {@see self::published_document_names()}:
3293 + * that lists the configured children, and a child over the per-file URL cap
3294 + * is split into `<stem>-2.xml`, `<stem>-3.xml` and so on, with every page
3295 + * listed in the index. Gating the request router on the base list alone
3296 + * therefore 404'd exactly the pages the index points at, which is worse than
3297 + * not serving them at all.
3298 + *
3299 + * Page counts are not knowable without building, so the stem is what is
3300 + * matched; a page that does not exist is answered by the build finding
3301 + * nothing for it, and is then cached as absent.
3302 + *
3303 + * @since 2.9.0
3304 + *
3305 + * @param string $name Requested file name.
3306 + * @param array|null $settings Sitemap settings (falls back to saved ones).
3307 + * @return bool
3308 + */
3309 + public function publishes_document_name(string $name, ?array $settings = null): bool {
3310 + $names = $this->published_document_names($settings);
3311 +
3312 + if (in_array($name, $names, true)) {
3313 + return true;
3314 + }
3315 +
3316 + if (!preg_match('/^(.*)-\d+\.xml$/i', $name, $m)) {
3317 + return false;
3318 + }
3319 +
3320 + return in_array($m[1] . '.xml', $names, true);
3321 + }
3322 +
3323 + /**
3324 + * Transient key for a rendered document.
3325 + *
3326 + * @since 2.9.0
3327 + *
3328 + * @param string $filename Published file name.
3329 + * @return string
3330 + */
3331 + private function dynamic_cache_key(string $filename): string {
3332 + return self::DYNAMIC_CACHE_PREFIX . md5($filename);
3333 + }
3334 +
3335 + /**
3336 + * Drop every cached dynamic document.
3337 + *
3338 + * Called from the same places that mark the static files stale, so the two
3339 + * delivery modes invalidate on identical triggers.
3340 + *
3341 + * @since 2.9.0
3342 + *
3343 + * @return void
3344 + */
3345 + public function flush_dynamic_cache(): void {
3346 + foreach ($this->published_document_names() as $name) {
3347 + delete_transient($this->dynamic_cache_key($name));
3348 + }
3349 + }
3350 +
3351 + /**
1603 3352 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
1604 3353 *
1605 3354 * - When use_sitemap_index is on but no child sitemaps are configured, build
1606 3355 * the per-type segmented set so a real <sitemapindex> is produced (#127).
@@ -1620,8 +3369,22 @@
1620 3369 $mode_supplied = array_key_exists('use_sitemap_index', $settings);
1621 3370 $urls_supplied = is_array($settings['sitemap_urls'] ?? null);
1622 3371 $has_children = count($urls_supplied ? $settings['sitemap_urls'] : []) > 1;
1623 3372
3373 + // Which inclusion flags did the caller actually name? The child list is
3374 + // the only thing that reads them, and inheriting a saved one skipped
3375 + // that — so on an index-mode site the include_* flags were enforced
3376 + // nowhere but in the browser, where SitemapGeneration.js recomputes
3377 + // sitemap_urls itself. Every non-UI client, the shipped
3378 + // `update-sitemap-settings` ability included, saved the flag and changed
3379 + // nothing (#398). Read from the payload for the same reason as above:
3380 + // after the merge every saved flag would look like one the caller named.
3381 + $named_inclusions = array_intersect(
3382 + array_keys(self::INCLUSION_CHILD_TYPES),
3383 + array_keys($settings)
3384 + );
3385 + $inclusions_supplied = (bool) $named_inclusions;
3386 +
1624 3387 // Inclusion flags may be absent from a partial payload (e.g. the manual
1625 3388 // generate endpoint) — fall back to saved settings so synthesized child
1626 3389 // sitemaps reflect the real include_posts/pages/categories choices.
1627 3390 $inclusions = array_merge($saved, $settings);
@@ -1657,12 +3420,26 @@
1657 3420 $settings['use_sitemap_index'] = $saved['use_sitemap_index'] ?? '';
1658 3421
1659 3422 // Inheriting the mode means inheriting its children too, unless the
1660 3423 // caller named its own set.
3424 + $saved_children = (is_array($saved['sitemap_urls'] ?? null) ? $saved['sitemap_urls'] : []);
1661 3425 if (!empty($settings['use_sitemap_index'])
1662 3426 && !$urls_supplied
1663 - && count((is_array($saved['sitemap_urls'] ?? null) ? $saved['sitemap_urls'] : [])) > 1) {
1664 - $settings['sitemap_urls'] = $saved['sitemap_urls'];
3427 + && count($saved_children) > 1) {
3428 + // A named inclusion flag is applied *to* the inherited list, not
3429 + // used to regenerate it. build_segmented_sitemap_urls() also adds
3430 + // a child for every public custom post type, so rebuilding here
3431 + // would make `{include_pages: false}` — one thing off — silently
3432 + // switch on children the saved list never had (an Elementor
3433 + // internal CPT, a WooCommerce product feed). Only the flags the
3434 + // caller actually named change anything.
3435 + $settings['sitemap_urls'] = $inclusions_supplied
3436 + ? $this->apply_inclusion_flags_to_children($saved_children, $named_inclusions, $inclusions)
3437 + : $saved_children;
3438 +
3439 + // The children are resolved either way — including when the
3440 + // caller switched the last one off, which leaves a bare index and
3441 + // is what they asked for.
1665 3442 $has_children = true;
1666 3443 }
1667 3444 }
1668 3445
@@ -1723,26 +3500,9 @@
1723 3500 * @param array $settings Sitemap settings.
1724 3501 * @return string Sitemap filename.
1725 3502 */
1726 3503 public function get_primary_sitemap_filename(array $settings): string {
1727 - $use_index = !empty($settings['use_sitemap_index']);
1728 -
1729 - foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
1730 - if (empty($config['enabled']) || empty($config['url'])) {
1731 - continue;
1732 - }
1733 -
1734 - if ($use_index !== (($config['type'] ?? '') === 'index')) {
1735 - continue;
1736 - }
1737 -
1738 - $path = wp_parse_url($config['url'], PHP_URL_PATH);
1739 - if (!empty($path)) {
1740 - return basename($path);
1741 - }
1742 - }
1743 -
1744 - return $use_index ? 'sitemap_index.xml' : 'sitemap.xml';
3504 + return thinkrank_webroot_primary_sitemap_filename($settings);
1745 3505 }
1746 3506
1747 3507 /**
1748 3508 * Public URL of the sitemap the site serves.
@@ -1784,8 +3544,18 @@
1784 3544 // File validation failed - error details available in exception
1785 3545 return false;
1786 3546 }
1787 3547
3548 + // Dynamic delivery: hand the document to the collector instead of the
3549 + // filesystem. Reported as published, because for this run it is — the
3550 + // caller's success/failure bookkeeping and the index assembly both key
3551 + // off this return value.
3552 + if ($this->document_sink !== null) {
3553 + ($this->document_sink)($filename, $sitemap_xml);
3554 +
3555 + return true;
3556 + }
3557 +
1788 3558 $sitemap_path = ABSPATH . $filename;
1789 3559
1790 3560 // Use WordPress filesystem API for better security
1791 3561 global $wp_filesystem;
@@ -1794,9 +3564,22 @@
1794 3564 WP_Filesystem();
1795 3565 }
1796 3566
1797 3567 if ($wp_filesystem) {
1798 - return $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3568 + $written = $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3569 +
3570 + if ($written) {
3571 + // Every sitemap this version writes carries the ownership
3572 + // marker, so once one has been written an unmarked file at one
3573 + // of our names cannot be ours. Recording that retires the
3574 + // legacy fallback for this install — see
3575 + // thinkrank_webroot_sitemap_is_ours().
3576 + if (get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1') {
3577 + update_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION, '1', false);
3578 + }
3579 + }
3580 +
3581 + return $written;
1799 3582 }
1800 3583
1801 3584 // WP_Filesystem initialization failed
1802 3585 return false;
@@ -1819,52 +3602,164 @@
1819 3602 */
1820 3603 public function build_segmented_sitemap_urls(array $inclusions): array {
1821 3604 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
1822 3605
1823 - $entry = static function (string $url, string $type): array {
1824 - return [
1825 - 'url' => $url,
1826 - 'type' => $type,
1827 - 'enabled' => true,
1828 - 'last_checked' => null,
1829 - 'status' => 'unknown',
1830 - ];
1831 - };
1832 - $child = static function (string $type) use ($pattern, $entry): array {
1833 - $file = str_replace('{type}', $type, $pattern);
1834 - if (strpos($file, '/') !== 0) {
1835 - $file = '/' . $file;
3606 + $urls = [$this->sitemap_child_entry('/sitemap_index.xml', 'index')];
3607 +
3608 + foreach (self::INCLUSION_CHILD_TYPES as $flag => $type) {
3609 + if (!empty($inclusions[$flag])) {
3610 + $urls[] = $this->build_child_sitemap_entry($type, $pattern);
1836 3611 }
1837 - return $entry($file, $type);
1838 - };
1839 -
1840 - $urls = [$entry('/sitemap_index.xml', 'index')];
1841 -
1842 - if (!empty($inclusions['include_posts'])) {
1843 - $urls[] = $child('posts');
1844 3612 }
1845 - if (!empty($inclusions['include_pages'])) {
1846 - $urls[] = $child('pages');
1847 - }
1848 - if (!empty($inclusions['include_categories'])) {
1849 - $urls[] = $child('categories');
1850 - }
1851 - if (!empty($inclusions['include_tags'])) {
1852 - $urls[] = $child('tags');
1853 - }
1854 3613
1855 3614 // Public custom post types each get a child sitemap (parity with the
1856 3615 // "complete" preset and with Rank Math, which lists every public CPT).
1857 3616 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
1858 - if ($this->should_include_post_type($cpt)) {
1859 - $urls[] = $child($cpt);
3617 + if (!$this->should_include_post_type($cpt)) {
3618 + continue;
1860 3619 }
3620 +
3621 + // ...and the per-content-type sitemap switch (#660).
3622 + // get_enabled_post_types() already honours it, but this list is what
3623 + // index mode builds its children from — so without the same test a
3624 + // CPT the user had switched off still got its own child sitemap,
3625 + // created and streamed in full. The flags live in $inclusions, which
3626 + // is the settings array these children are derived from.
3627 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $cpt, $inclusions)) {
3628 + continue;
3629 + }
3630 +
3631 + $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
1861 3632 }
1862 3633
3634 + // Public custom taxonomies get the same treatment (#690). Flat mode has
3635 + // always walked them through get_enabled_taxonomies(); index mode built
3636 + // its children from the list above and never consulted a taxonomy at
3637 + // all, so every custom-taxonomy archive silently vanished from the
3638 + // sitemap the moment a site switched modes — and the per-taxonomy switch
3639 + // the matrix writes had nothing to act on. Same two tests the post-type
3640 + // walk applies, in the same order.
3641 + $taken = array_column($urls, 'type');
3642 +
3643 + foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
3644 + if (!$this->should_include_taxonomy($taxonomy)) {
3645 + continue;
3646 + }
3647 +
3648 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $inclusions)) {
3649 + continue;
3650 + }
3651 +
3652 + // Post types and taxonomies are separate registries, so a site can
3653 + // hold both a `foo` post type and a `foo` taxonomy. They would
3654 + // resolve to one filename, and stream_type_entries() answers post
3655 + // types first, so the second child would list the first one's file
3656 + // twice in the index rather than adding anything.
3657 + if (in_array($taxonomy, $taken, true)) {
3658 + continue;
3659 + }
3660 +
3661 + $urls[] = $this->build_child_sitemap_entry($taxonomy, $pattern);
3662 + }
3663 +
1863 3664 return $urls;
1864 3665 }
1865 3666
1866 3667 /**
3668 + * Apply only the inclusion flags the caller named to an existing child list.
3669 + *
3670 + * The narrow counterpart to build_segmented_sitemap_urls(): that one
3671 + * regenerates the whole set from scratch, which is right when there is no set
3672 + * yet and wrong when there is. Rebuilding an existing list would add a child
3673 + * for every public custom post type it had never contained, so a payload that
3674 + * switches one thing off would switch others on. Here a flag adds or removes
3675 + * exactly its own child and leaves every other entry — custom post types,
3676 + * hand-added URLs, per-child enabled/status state — untouched (#398).
3677 + *
3678 + * @since 1.31.0
3679 + *
3680 + * @param array $children Existing child sitemap entries.
3681 + * @param string[] $named_inclusions Inclusion flag keys present in the payload.
3682 + * @param array $inclusions Merged settings, for the resolved flag
3683 + * values and custom_url_pattern.
3684 + * @return array Updated child sitemap entries.
3685 + */
3686 + private function apply_inclusion_flags_to_children(
3687 + array $children,
3688 + array $named_inclusions,
3689 + array $inclusions
3690 + ): array {
3691 + $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
3692 +
3693 + foreach ($named_inclusions as $flag) {
3694 + $type = self::INCLUSION_CHILD_TYPES[$flag];
3695 +
3696 + $present = false;
3697 + foreach ($children as $entry) {
3698 + if (($entry['type'] ?? '') === $type) {
3699 + $present = true;
3700 + break;
3701 + }
3702 + }
3703 +
3704 + if (empty($inclusions[$flag])) {
3705 + if ($present) {
3706 + $children = array_values(array_filter(
3707 + $children,
3708 + static function ($entry) use ($type): bool {
3709 + return (is_array($entry) ? ($entry['type'] ?? '') : '') !== $type;
3710 + }
3711 + ));
3712 + }
3713 + continue;
3714 + }
3715 +
3716 + if (!$present) {
3717 + $children[] = $this->build_child_sitemap_entry($type, $pattern);
3718 + }
3719 + }
3720 +
3721 + return $children;
3722 + }
3723 +
3724 + /**
3725 + * Build one child sitemap entry, resolving its filename from the url pattern.
3726 + *
3727 + * @since 1.31.0
3728 + *
3729 + * @param string $type Child sitemap type (posts, pages, a post type name).
3730 + * @param string $pattern Filename pattern containing {type}.
3731 + * @return array Sitemap URL config.
3732 + */
3733 + private function build_child_sitemap_entry(string $type, string $pattern): array {
3734 + $file = str_replace('{type}', $type, $pattern);
3735 + if (strpos($file, '/') !== 0) {
3736 + $file = '/' . $file;
3737 + }
3738 +
3739 + return $this->sitemap_child_entry($file, $type);
3740 + }
3741 +
3742 + /**
3743 + * The shape generate_multiple_sitemaps() expects of a sitemap_urls entry.
3744 + *
3745 + * @since 1.31.0
3746 + *
3747 + * @param string $url Sitemap path.
3748 + * @param string $type Entry type.
3749 + * @return array Sitemap URL config.
3750 + */
3751 + private function sitemap_child_entry(string $url, string $type): array {
3752 + return [
3753 + 'url' => $url,
3754 + 'type' => $type,
3755 + 'enabled' => true,
3756 + 'last_checked' => null,
3757 + 'status' => 'unknown',
3758 + ];
3759 + }
3760 +
3761 + /**
1867 3762 * Generate multiple sitemaps based on settings
1868 3763 *
1869 3764 * @since 1.0.0
1870 3765 * @param array $settings Sitemap settings
@@ -1913,8 +3808,48 @@
1913 3808 if (post_type_exists($type) && !$this->should_include_post_type($type)) {
1914 3809 continue;
1915 3810 }
1916 3811
3812 + // Same for the matrix switch: a child list saved before the user
3813 + // excluded this content type still names it, and regenerating from
3814 + // that list would rewrite the file they asked not to have. Built-in
3815 + // aggregates ('posts', 'pages', ...) are not post type names, so
3816 + // post_type_exists() keeps this to real custom post types.
3817 + if (post_type_exists($type)
3818 + && !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $type, $settings)) {
3819 + continue;
3820 + }
3821 +
3822 + // The taxonomy counterpart of the two guards above (#690). A child
3823 + // list saved while a taxonomy was still included keeps naming it, so
3824 + // without this, excluding one in the matrix would still rewrite and
3825 + // re-list the file the user asked not to have. The built-in
3826 + // aggregates are named 'categories'/'tags' rather than
3827 + // 'category'/'post_tag', so taxonomy_exists() leaves them to the
3828 + // inclusion-flag check below.
3829 + $child_taxonomy = self::CHILD_TYPE_ALIASES[$type] ?? $type;
3830 + if (taxonomy_exists($child_taxonomy)
3831 + && (!$this->should_include_taxonomy($child_taxonomy)
3832 + || !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $child_taxonomy, $settings))) {
3833 + continue;
3834 + }
3835 +
3836 + // The built-in aggregates carry their switch in the inclusion flag
3837 + // rather than under a post type name, and they are the four most
3838 + // people actually use. build_segmented_sitemap_urls() drops a child
3839 + // whose flag is empty; regenerating from a list saved while it was
3840 + // still on has to make the same decision, or turning Posts, Pages,
3841 + // Categories or Tags off in the matrix rewrites and re-lists the
3842 + // very file it was asked to remove.
3843 + // An absent flag means "not configured", which every other reader
3844 + // treats as included; only a flag that is present and off excludes.
3845 + $aggregate_flag = array_search($type, self::INCLUSION_CHILD_TYPES, true);
3846 + if ($aggregate_flag !== false
3847 + && array_key_exists($aggregate_flag, $settings)
3848 + && empty($settings[$aggregate_flag])) {
3849 + continue;
3850 + }
3851 +
1917 3852 try {
1918 3853 // The single "general"/"WordPress" sitemap is one un-paginated file
1919 3854 // (there is no index to reference extra pages); it no longer drops
1920 3855 // overflow URLs.
@@ -1950,18 +3885,36 @@
1950 3885 $buffer = [];
1951 3886 }
1952 3887 }
1953 3888
1954 - // Flush the trailing partial page, or a single empty page when the
1955 - // type had no entries at all (parity with the previous behavior of
1956 - // always writing at least one page per configured child).
1957 - if (!empty($buffer) || $page === 0) {
3889 + // Flush the trailing partial page.
3890 + //
3891 + // A type that produced nothing writes no page at all in index
3892 + // mode (#836). It used to write one empty urlset and list it in
3893 + // the index, so a crawler was asked to fetch a file that
3894 + // answers with no URLs — Search Console reports an empty
3895 + // sitemap referenced from an index as a warning, and the fetch
3896 + // is wasted on every pass. On this site three of eleven
3897 + // children were empty: a post type with nothing published and
3898 + // two taxonomies with no terms.
3899 + //
3900 + // Single-file mode still writes its one page even when empty,
3901 + // because that file IS the site's /sitemap.xml and a 404 there
3902 + // is worse than an empty urlset. Nothing lists it, so it costs
3903 + // no crawl budget.
3904 + //
3905 + // Skipping the write also keeps the filename out of
3906 + // $results['sitemaps_generated'], which is what
3907 + // prune_orphaned_segments() treats as "written this run" — so
3908 + // a type that empties after previously publishing has its
3909 + // stale file deleted rather than left serving.
3910 + if (!empty($buffer) || ($page === 0 && $index_config === null)) {
1958 3911 $page++;
1959 3912 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
1960 3913 }
1961 3914
1962 3915 // Remove pages left over from a previous, larger generation.
1963 - $this->cleanup_stale_pages($sitemap_config['url'], $page);
3916 + $this->cleanup_stale_pages($sitemap_config['url'], $page, $settings);
1964 3917
1965 3918 } catch (\Exception $e) {
1966 3919 $results['errors'][] = "Error generating {$type} sitemap: " . $e->getMessage();
1967 3920 $results['success'] = false;
@@ -1980,10 +3933,16 @@
1980 3933 $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
1981 3934 $results['success'] = false;
1982 3935 }
1983 3936
1984 - // Build the index last, from the child files actually generated.
3937 + // Build the index last, from the child files actually generated, plus the
3938 + // sitemaps other plugins own: those serve their own URLs and write no
3939 + // file here, so they are appended to the index only (#104).
1985 3940 if ($index_config !== null) {
3941 + foreach (self::additional_sitemaps() as $extra) {
3942 + $index_children[] = ['url' => $extra];
3943 + }
3944 +
1986 3945 try {
1987 3946 $index_xml = $this->generate_sitemap_index($index_children, $settings);
1988 3947 $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
1989 3948
@@ -2028,8 +3987,15 @@
2028 3987 * @param array $generated Entries from $results['sitemaps_generated'].
2029 3988 * @return string[] Basenames removed.
2030 3989 */
2031 3990 private function prune_orphaned_segments(array $settings, array $generated): array {
3991 + // Rendering for a request, not publishing: there is nothing on disk
3992 + // this run owns, and a dynamic render must never delete the files a
3993 + // site's previous static mode left behind.
3994 + if ($this->is_collecting()) {
3995 + return [];
3996 + }
3997 +
2032 3998 $kept = [];
2033 3999 foreach ($generated as $entry) {
2034 4000 if (!empty($entry['filename'])) {
2035 4001 $kept[strtolower((string) $entry['filename'])] = true;
@@ -2035,15 +4001,22 @@
2035 4001 $kept[strtolower((string) $entry['filename'])] = true;
2036 4002 }
2037 4003 }
2038 4004
2039 - // The index and the local business sitemap are written by their own
2040 - // paths and are not segments, so they are never orphans here.
2041 - $kept[strtolower(basename($this->get_primary_sitemap_filename($settings)))] = true;
4005 + // The current mode's primary and the local business sitemap are written
4006 + // by their own paths and are never orphans here.
4007 + $primary = strtolower(basename($this->get_primary_sitemap_filename($settings)));
4008 + $kept[$primary] = true;
2042 4009 $kept['local-sitemap.xml'] = true;
2043 - $kept['sitemap.xml'] = true;
2044 - $kept['sitemap_index.xml'] = true;
2045 4010
4011 + // The OTHER mode's primary is an orphan the moment the mode changes:
4012 + // index mode leaves sitemap.xml behind, flat mode leaves
4013 + // sitemap_index.xml and its children. Both used to be kept
4014 + // unconditionally, so the site served two sitemap trees and only ever
4015 + // refreshed one (#563). The children are already covered by the segment
4016 + // sweep below, which now sees them because the index is no longer kept.
4017 + $stale_primaries = array_diff(['sitemap.xml', 'sitemap_index.xml'], [$primary]);
4018 +
2046 4019 $removed = [];
2047 4020
2048 4021 global $wp_filesystem;
2049 4022 if (!$wp_filesystem) {
@@ -2053,9 +4026,11 @@
2053 4026 if (!$wp_filesystem) {
2054 4027 return $removed;
2055 4028 }
2056 4029
2057 - foreach ($this->publishable_segment_filenames($settings) as $candidate) {
4030 + $candidates = array_merge($this->publishable_segment_filenames($settings), $stale_primaries);
4031 +
4032 + foreach ($candidates as $candidate) {
2058 4033 if (isset($kept[strtolower($candidate)])) {
2059 4034 continue;
2060 4035 }
2061 4036
@@ -2074,8 +4049,13 @@
2074 4049 foreach ($paths as $path) {
2075 4050 if (!file_exists($path)) {
2076 4051 continue;
2077 4052 }
4053 + // A name we could have published is not proof we published
4054 + // this file: RankMath and core write at the same paths (#515).
4055 + if (!$this->webroot_sitemap_is_ours($path, $settings)) {
4056 + continue;
4057 + }
2078 4058 if ($wp_filesystem->delete($path)) {
2079 4059 $removed[] = basename($path);
2080 4060 }
2081 4061 }
@@ -2111,11 +4091,15 @@
2111 4091 $settings = $settings ?? $this->get_settings('site');
2112 4092 $entries = $this->collect_local_entries();
2113 4093
2114 4094 if (empty($entries)) {
2115 - // Business identity was cleared — drop any file left from before.
4095 + // Business identity was cleared — drop the file we left from
4096 + // before, but only ours. `local-sitemap.xml` is the name Rank Math
4097 + // publishes under too (this method mirrors it deliberately), so on
4098 + // a migrated site the file at that path may never have been ours
4099 + // to delete (#515).
2116 4100 $path = ABSPATH . 'local-sitemap.xml';
2117 - if (file_exists($path)) {
4101 + if (!$this->is_collecting() && file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
2118 4102 wp_delete_file($path);
2119 4103 }
2120 4104 return false;
2121 4105 }
@@ -2137,8 +4121,25 @@
2137 4121 *
2138 4122 * @since 1.15.x
2139 4123 * @return array Zero or one URL entry
2140 4124 */
4125 + /**
4126 + * Does this site publish a local business sitemap right now?
4127 + *
4128 + * The same gate {@see self::regenerate_local_sitemap()} applies, asked
4129 + * without writing anything. Callers that need to know whether the document
4130 + * exists must not test the filesystem: under dynamic delivery it is served
4131 + * from PHP and there is no file, which is how `local-sitemap.xml` came to be
4132 + * dropped from robots.txt on exactly those sites (#752).
4133 + *
4134 + * @since 2.9.0
4135 + *
4136 + * @return bool True when the local sitemap has content to publish.
4137 + */
4138 + public function publishes_local_sitemap(): bool {
4139 + return !empty($this->collect_local_entries());
4140 + }
4141 +
2141 4142 private function collect_local_entries(): array {
2142 4143 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2143 4144 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2144 4145 }
@@ -2210,17 +4211,43 @@
2210 4211 case 'products':
2211 4212 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
2212 4213 return ['entries' => $entries, 'image_ns' => true];
2213 4214
2214 - case 'product_categories':
2215 - $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
2216 - return ['entries' => $entries, 'image_ns' => false];
4215 + default:
4216 + // Resolve a preset's display name to the object it streams, so
4217 + // 'product_categories' is an ordinary taxonomy child rather than
4218 + // a case of its own (#690).
4219 + $alias = self::CHILD_TYPE_ALIASES[$type] ?? null;
4220 + $object = $alias ?? $type;
2217 4221
2218 - default:
2219 4222 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2220 - if (in_array($type, $custom_post_types, true)) {
2221 - return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
4223 + if (in_array($object, $custom_post_types, true)) {
4224 + return ['entries' => $this->collect_post_entries_iter([$object], $settings), 'image_ns' => true];
2222 4225 }
4226 +
4227 + // Custom taxonomies reach index mode here, streamed through the
4228 + // same iterator flat mode uses so the two modes emit identical
4229 + // URLs for the same settings.
4230 + if (taxonomy_exists($object) && $this->should_include_taxonomy($object)) {
4231 + return ['entries' => $this->collect_taxonomy_entries_iter($object, $settings), 'image_ns' => false];
4232 + }
4233 +
4234 + // An aliased child whose object is gone (WooCommerce deactivated)
4235 + // keeps writing the empty file it always wrote. Returning null
4236 + // here would hand it the whole-site fallback instead, dumping
4237 + // every URL on the site into a file named for products.
4238 + //
4239 + // A registered taxonomy this generator will not emit
4240 + // (`post_format`, `nav_menu`, a non-public one) needs the same
4241 + // answer for the same reason. generate_multiple_sitemaps()
4242 + // skips those before they reach here, so nothing takes this
4243 + // path today — but it is the one branch where falling through
4244 + // to null is silently catastrophic rather than merely wrong,
4245 + // and the guard keeping it unreachable lives in another method.
4246 + if ($alias !== null || taxonomy_exists($object)) {
4247 + return ['entries' => [], 'image_ns' => false];
4248 + }
4249 +
2223 4250 return null;
2224 4251 }
2225 4252 }
2226 4253
@@ -2263,13 +4290,23 @@
2263 4290 * which has no -N suffix) is never touched.
2264 4291 *
2265 4292 * @since 1.14.0
2266 4293 *
4294 + * @since 2.1.1 Each candidate must pass the content ownership test — a
4295 + * `-N.xml` page of another plugin's sitemap paginates our
4296 + * stem exactly as ours does (#515).
4297 + *
2267 4298 * @param string $base_url Base (page 1) sitemap URL
2268 4299 * @param int $current_pages Number of pages generated this run
4300 + * @param array $settings Sitemap settings, for the ownership test.
2269 4301 * @return void
2270 4302 */
2271 - private function cleanup_stale_pages(string $base_url, int $current_pages): void {
4303 + private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
4304 + // See prune_orphaned_segments(): a dynamic render deletes nothing.
4305 + if ($this->is_collecting()) {
4306 + return;
4307 + }
4308 +
2272 4309 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2273 4310 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2274 4311 return;
2275 4312 }
@@ -2286,9 +4323,11 @@
2286 4323
2287 4324 $candidates = glob(ABSPATH . $stem . '-*.xml') ?: [];
2288 4325 foreach ($candidates as $path) {
2289 4326 // Only delete numeric-suffixed pages beyond the current count.
2290 - if (preg_match('/-(\d+)\.xml$/', basename($path), $mm) && (int) $mm[1] > $current_pages) {
4327 + if (preg_match('/-(\d+)\.xml$/', basename($path), $mm)
4328 + && (int) $mm[1] > $current_pages
4329 + && $this->webroot_sitemap_is_ours($path, $settings)) {
2291 4330 $wp_filesystem->delete($path);
2292 4331 }
2293 4332 }
2294 4333 }
@@ -2293,8 +4332,97 @@
2293 4332 }
2294 4333 }
2295 4334
2296 4335 /**
4336 + * Sitemap URLs contributed by other plugins.
4337 + *
4338 + * ThinkRank owns the sitemap index and the robots.txt `Sitemap:` lines, so a
4339 + * companion plugin that serves its own sitemap — Pro's news and video
4340 + * sitemaps, for instance — had no way to be discovered: it appeared in
4341 + * neither, leaving manual Search Console submission as the only route in
4342 + * (#104). Registering here puts a sitemap in the index when one exists, and
4343 + * in robots.txt when it does not.
4344 + *
4345 + * Callers get root-relative paths. Entries are normalised to a leading
4346 + * slash, de-duplicated, and anything that is not a non-empty string is
4347 + * dropped, so one badly-behaved callback cannot produce a malformed index.
4348 + *
4349 + * @since 2.3.1
4350 + *
4351 + * @return string[] Root-relative sitemap paths, e.g. ['/news-sitemap.xml'].
4352 + */
4353 + public static function additional_sitemaps(): array {
4354 + /**
4355 + * Filters the sitemaps contributed by other plugins.
4356 + *
4357 + * @since 2.3.1
4358 + *
4359 + * @param string[] $sitemaps Root-relative sitemap paths.
4360 + */
4361 + $sitemaps = apply_filters('thinkrank_additional_sitemaps', []);
4362 +
4363 + if (!is_array($sitemaps)) {
4364 + return [];
4365 + }
4366 +
4367 + // Both consumers resolve an entry with home_url(), which prefixes the
4368 + // install's own directory. Everything below is measured against that so
4369 + // an absolute URL is reduced to what home_url() will put back.
4370 + $home = wp_parse_url(home_url('/'));
4371 + $home_host = strtolower((string) ($home['host'] ?? ''));
4372 + $home_path = '/' . trim((string) ($home['path'] ?? ''), '/');
4373 +
4374 + $clean = [];
4375 + foreach ($sitemaps as $sitemap) {
4376 + if (!is_string($sitemap)) {
4377 + continue;
4378 + }
4379 +
4380 + $sitemap = trim($sitemap);
4381 + if ('' === $sitemap) {
4382 + continue;
4383 + }
4384 +
4385 + // A full URL on this site is accepted and reduced to the part
4386 + // home_url() does not already supply, so a caller that reached for
4387 + // home_url() still lands in the right place — including on a
4388 + // subdirectory install, where keeping the whole path would repeat
4389 + // the directory. A URL on another host is dropped rather than
4390 + // rewritten: the sitemaps protocol will not accept a cross-host
4391 + // child anyway, and reusing its path would advertise a URL on this
4392 + // site that does not exist.
4393 + if (preg_match('#^(https?:)?//#i', $sitemap)) {
4394 + $parts = wp_parse_url('//' === substr($sitemap, 0, 2) ? 'https:' . $sitemap : $sitemap);
4395 + if (!is_array($parts)) {
4396 + continue;
4397 + }
4398 +
4399 + if (strtolower((string) ($parts['host'] ?? '')) !== $home_host) {
4400 + continue;
4401 + }
4402 +
4403 + $path = (string) ($parts['path'] ?? '');
4404 + if ('' === $path) {
4405 + continue;
4406 + }
4407 +
4408 + if ('/' !== $home_path && ($path === $home_path || 0 === strpos($path, $home_path . '/'))) {
4409 + $path = substr($path, strlen($home_path));
4410 + }
4411 +
4412 + // A sitemap served from a query string keeps it; dropping the
4413 + // query would point at a different document.
4414 + $query = (string) ($parts['query'] ?? '');
4415 + $sitemap = $path . ('' !== $query ? '?' . $query : '');
4416 + }
4417 +
4418 + $clean[] = '/' . ltrim($sitemap, '/');
4419 + }
4420 +
4421 + return array_values(array_unique($clean));
4422 + }
4423 +
4424 + /**
2297 4425 * Generate sitemap index XML from the list of child sitemap files produced
2298 4426 * during generation (each already resolved to its final, possibly paginated,
2299 4427 * URL).
2300 4428 *
@@ -2303,14 +4431,9 @@
2303 4431 * @param array $settings Sitemap settings
2304 4432 * @return string Sitemap index XML
2305 4433 */
2306 4434 private function generate_sitemap_index(array $children, array $settings): string {
2307 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
2308 -
2309 - // Add XSL stylesheet only if styling is enabled
2310 - if (!empty($settings['enable_styling'])) {
2311 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap-index.xsl') . '"?>' . "\n";
2312 - }
4435 + $xml = $this->xml_prolog($settings, 'index');
2313 4436 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
2314 4437
2315 4438 $site_url = home_url();
2316 4439
@@ -2323,9 +4446,9 @@
2323 4446 $sitemap_url = $site_url . $sitemap_url;
2324 4447 }
2325 4448
2326 4449 $xml .= " <sitemap>\n";
2327 - $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
4450 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($sitemap_url)) . "</loc>\n";
2328 4451 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
2329 4452 $xml .= " </sitemap>\n";
2330 4453 }
2331 4454