PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/seo/class-sitemap-generator.php +1972 -158 1.30.0 → 2.9.0 View file →
@@ -21,8 +21,13 @@
21 21 if ( ! defined( 'ABSPATH' ) ) {
22 22 exit;
23 23 }
24 24
25 +// Filename derivation and web-root removal are shared with the deactivator and
26 +// with uninstall.php, which runs without an autoloader — so they live in a
27 +// plain function file both can require. See includes/cleanup-webroot.php.
28 +require_once __DIR__ . '/../cleanup-webroot.php';
29 +
25 30 /**
26 31 * Sitemap Generator Class
27 32 *
28 33 * Generates and manages XML sitemaps for search engine optimization.
@@ -33,13 +38,221 @@
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 +
42 255 private array $sitemap_types = [
43 256 'posts' => [
44 257 'name' => 'Posts',
45 258 'post_types' => ['post'],
@@ -69,14 +282,22 @@
69 282 /**
70 283 * Constructor
71 284 *
72 285 * @since 1.0.0
286 + *
287 + * @param bool $register_hooks Optional. Whether to register the auto-generation
288 + * hooks. Pass false for a read-only instance built
289 + * solely to query settings — the hooks are bound to
290 + * `$this`, so a second hook-registering instance
291 + * would run `handle_content_change()` twice per save.
73 292 */
74 - public function __construct() {
293 + public function __construct(bool $register_hooks = true) {
75 294 parent::__construct('sitemap');
76 295
77 296 // Initialize auto-generation hooks
78 - $this->init_auto_generation_hooks();
297 + if ($register_hooks) {
298 + $this->init_auto_generation_hooks();
299 + }
79 300 }
80 301
81 302 /**
82 303 * Filter the args of a sitemap post query.
@@ -156,15 +377,10 @@
156 377 */
157 378 public function generate_sitemap(array $options = []): string {
158 379 $settings = $this->get_settings('site');
159 380
160 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
381 + $xml = $this->xml_prolog($settings, 'sitemap');
161 382
162 - // Add XSL stylesheet only if styling is enabled
163 - if (!empty($settings['enable_styling'])) {
164 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
165 - }
166 -
167 383 // Add image namespace if images are enabled
168 384 if (!empty($settings['include_images'])) {
169 385 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
170 386 } else {
@@ -276,8 +492,34 @@
276 492 ];
277 493 }
278 494
279 495 /**
496 + * Sitemap keys outside the defaults.
497 + *
498 + * @since 2.0.1
499 + *
500 + * @return string[]
501 + */
502 + protected function additional_setting_keys(): array {
503 + return ['selected_preset'];
504 + }
505 +
506 + /**
507 + * Inclusion flags are per post type and per taxonomy.
508 + *
509 + * A site registering a `product` post type stores `include_product`; an
510 + * enumerated list would go stale on the next registration, so the family
511 + * is matched instead.
512 + *
513 + * @since 2.0.1
514 + *
515 + * @return string[]
516 + */
517 + protected function dynamic_setting_key_patterns(): array {
518 + return ['/^include_[a-z0-9_]+$/', '/^exclude_[a-z0-9_]+$/'];
519 + }
520 +
521 + /**
280 522 * Get default settings for a context type (implements interface)
281 523 *
282 524 * @since 1.0.0
283 525 *
@@ -300,8 +542,15 @@
300 542 ]
301 543 ],
302 544 'use_sitemap_index' => false,
303 545
546 + // How the sitemap reaches crawlers. 'auto' keeps the historical
547 + // behaviour wherever the web root is writable, and only falls back
548 + // to serving the sitemap from PHP where writing a file is
549 + // impossible — previously a hard failure with nothing served
550 + // (#752).
551 + 'delivery_mode' => 'auto',
552 +
304 553 // General Settings
305 554 'links_per_sitemap' => 1000,
306 555 'include_images' => true,
307 556 'include_featured_images' => false,
@@ -323,8 +572,17 @@
323 572 // Advanced Options
324 573 'enable_styling' => true,
325 574 'custom_url_pattern' => 'sitemap-{type}.xml',
326 575
576 + // Stylesheet branding (#639). Both colours default to empty, not
577 + // to the stock hexes: empty means the stylesheet's own value
578 + // stands, so a site that never opens this screen renders exactly
579 + // as it did before the setting existed.
580 + 'styling_logo' => false,
581 + 'styling_logo_url' => '',
582 + 'styling_color_main' => '',
583 + 'styling_color_accent' => '',
584 +
327 585 // Generation tracking
328 586 'last_generated' => ''
329 587 ];
330 588 }
@@ -329,8 +587,49 @@
329 587 ];
330 588 }
331 589
332 590 /**
591 + * Normalize the stylesheet branding values on the way into the store.
592 + *
593 + * The generic sanitizer only runs `sanitize_text_field()` over a string,
594 + * which happily keeps "red" or "rebeccapurple" as a colour. Nothing
595 + * downstream can use those — {@see Sitemap_Stylesheet::render()} skips any
596 + * value it cannot read as a hex colour — so storing them would report a
597 + * successful save of a setting that changes nothing, and `get-sitemap-
598 + * settings` would hand an agent back a colour the sitemap does not use.
599 + * Reducing here instead keeps the store and the rendering in agreement.
600 + *
601 + * @since 2.7.0
602 + *
603 + * @param array $settings Settings to sanitize.
604 + * @param string $context_type Context the save is for.
605 + * @return array
606 + */
607 + protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
608 + $sanitized = parent::sanitize_settings($settings, $context_type);
609 +
610 + foreach (['styling_color_main', 'styling_color_accent'] as $key) {
611 + if (array_key_exists($key, $sanitized)) {
612 + $sanitized[$key] = Sitemap_Stylesheet::hex($sanitized[$key]);
613 + }
614 + }
615 +
616 + if (array_key_exists('styling_logo_url', $sanitized)) {
617 + $sanitized['styling_logo_url'] = esc_url_raw((string) $sanitized['styling_logo_url']);
618 + }
619 +
620 + // A mode this build cannot act on has to be stored as the fallback
621 + // rather than kept verbatim, or get-sitemap-settings reports a delivery
622 + // mode the site does not actually apply.
623 + if (array_key_exists('delivery_mode', $sanitized)) {
624 + $mode = sanitize_key((string) $sanitized['delivery_mode']);
625 + $sanitized['delivery_mode'] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
626 + }
627 +
628 + return $sanitized;
629 + }
630 +
631 + /**
333 632 * Get settings schema definition (implements interface)
334 633 *
335 634 * @since 1.0.0
336 635 *
@@ -344,8 +643,15 @@
344 643 'title' => 'Enable Sitemap',
345 644 'description' => 'Generate XML sitemap for search engines',
346 645 'default' => true
347 646 ],
647 + 'delivery_mode' => [
648 + 'type' => 'string',
649 + 'title' => 'Sitemap Delivery',
650 + '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',
651 + 'enum' => self::DELIVERY_MODES,
652 + 'default' => 'auto'
653 + ],
348 654 'include_posts' => [
349 655 'type' => 'boolean',
350 656 'title' => 'Include Posts',
351 657 'description' => 'Include blog posts in sitemap',
@@ -386,8 +692,32 @@
386 692 'type' => 'string',
387 693 'title' => 'Last Generated',
388 694 'description' => 'Timestamp of last sitemap generation',
389 695 'default' => ''
696 + ],
697 + 'styling_logo' => [
698 + 'type' => 'boolean',
699 + 'title' => 'Show Logo On Sitemap',
700 + 'description' => 'Show a logo above the sitemap heading',
701 + 'default' => false
702 + ],
703 + 'styling_logo_url' => [
704 + 'type' => 'string',
705 + 'title' => 'Sitemap Logo',
706 + 'description' => 'Logo image URL. Empty falls back to the site icon',
707 + 'default' => ''
708 + ],
709 + 'styling_color_main' => [
710 + 'type' => 'string',
711 + 'title' => 'Sitemap Main Color',
712 + 'description' => 'Hex color for the sitemap header, links and table head. Empty keeps the stock palette',
713 + 'default' => ''
714 + ],
715 + 'styling_color_accent' => [
716 + 'type' => 'string',
717 + 'title' => 'Sitemap Accent Color',
718 + 'description' => 'Hex color for the header gradient and link hovers. Empty keeps the stock palette',
719 + 'default' => ''
390 720 ]
391 721 ];
392 722 }
393 723
@@ -404,9 +734,14 @@
404 734 * @return string XML URL entry
405 735 */
406 736 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
407 737 $xml = " <url>\n";
408 - $xml .= " <loc>" . esc_url($url) . "</loc>\n";
738 + // Every <loc> in every sitemap passes through here, which is why the
739 + // scheme preference is applied at this one point rather than at each
740 + // of the dozen collectors that build URLs (#638). An http sitemap on
741 + // an https site hands search engines the wrong address for the whole
742 + // site at once.
743 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($url)) . "</loc>\n";
409 744 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
410 745 // than no timestamp, and an absent lastmod is valid per the spec.
411 746 if (!empty($lastmod)) {
412 747 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
@@ -416,9 +751,9 @@
416 751
417 752 // Add image entries if provided
418 753 foreach ($images as $image) {
419 754 $xml .= " <image:image>\n";
420 - $xml .= " <image:loc>" . esc_url($image['url']) . "</image:loc>\n";
755 + $xml .= " <image:loc>" . esc_url(Url_Scheme::apply((string) $image['url'])) . "</image:loc>\n";
421 756
422 757 if (!empty($image['title'])) {
423 758 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
424 759 }
@@ -531,9 +866,18 @@
531 866 if (empty($all_ids)) {
532 867 return;
533 868 }
534 869
535 - foreach (array_chunk($all_ids, 500) as $chunk) {
870 + // Walk the ID list with a moving window rather than array_chunk().
871 + // array_chunk() builds a second array holding every element again, so
872 + // peak memory was twice the ID list — on a 100k-post site that is ~16MB
873 + // where ~8MB is needed, and this walk is the one part of an otherwise
874 + // well-bounded routine with no ceiling (#402).
875 + $total = count($all_ids);
876 +
877 + for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
878 + $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
879 +
536 880 $posts = get_posts($this->filter_query_args([
537 881 'post_type' => $post_types,
538 882 'post_status' => 'publish',
539 883 'numberposts' => count($chunk),
@@ -542,9 +886,23 @@
542 886 ]));
543 887
544 888 foreach ($posts as $post) {
545 889 if ($this->should_include_in_sitemap($post, $settings)) {
546 - $url = get_permalink($post);
890 + /**
891 + * Filter a sitemap entry's permalink.
892 + *
893 + * The multilingual manager uses this to generate each
894 + * translation's URL in its OWN language: the sitemap query
895 + * deliberately runs with suppress_filters, and the cron
896 + * rebuild runs with no language context at all, so a bare
897 + * get_permalink() resolved every translation to the
898 + * default-language URL — N entries sharing one <loc> (#409).
899 + *
900 + * @since 2.0.1
901 + * @param string $url Permalink as WordPress resolved it.
902 + * @param \WP_Post $post Post the entry describes.
903 + */
904 + $url = apply_filters('thinkrank_sitemap_post_permalink', get_permalink($post), $post);
547 905 $lastmod = gmdate('c', strtotime($post->post_modified_gmt));
548 906 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
549 907 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
550 908 $images = $this->extract_post_images($post, $settings);
@@ -611,9 +969,14 @@
611 969 if (is_wp_error($all_ids) || empty($all_ids)) {
612 970 return;
613 971 }
614 972
615 - foreach (array_chunk($all_ids, 1000) as $chunk) {
973 + // Same moving window as the post walk above, for the same reason.
974 + $total = count($all_ids);
975 +
976 + for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
977 + $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
978 +
616 979 $terms = get_terms($this->filter_term_query_args([
617 980 'taxonomy' => $taxonomy,
618 981 'include' => $chunk,
619 982 'orderby' => 'include', // preserve the resolved order
@@ -624,8 +987,15 @@
624 987 continue;
625 988 }
626 989
627 990 foreach ($terms as $term) {
991 + // A term the user marked noindex must not be advertised in the
992 + // sitemap: the robots tag now honours term meta, so listing it
993 + // here would have the sitemap contradict the page's own tag.
994 + if ($this->term_is_noindexed((int) $term->term_id)) {
995 + continue;
996 + }
997 +
628 998 $url = get_term_link($term);
629 999 if (!is_wp_error($url)) {
630 1000 // Omit lastmod for terms — the generation time is not a real
631 1001 // modification time and would mislabel every term as just-changed.
@@ -637,8 +1007,43 @@
637 1007 }
638 1008 }
639 1009
640 1010 /**
1011 + * The XML declaration, ownership marker and optional stylesheet every
1012 + * sitemap document opens with.
1013 + *
1014 + * The marker is written unconditionally, and that is the point: removal on
1015 + * deactivate and uninstall deletes a web-root sitemap only when the file
1016 + * says it is ours, and our filenames are the canonical ones another SEO
1017 + * plugin writes too (#515). Tying the proof to `enable_styling` — the one
1018 + * marker older versions left — would mean a site with styling off either
1019 + * kept a shadowing file behind (#510) or had a competitor's deleted.
1020 + *
1021 + * @since 2.1.1
1022 + *
1023 + * The stylesheet URL is served by {@see Sitemap_Stylesheet}, not read off
1024 + * disk by the web server, because a static file cannot carry the site's own
1025 + * logo and colours (#639). It is a fixed URL: the palette is applied per
1026 + * request, so changing a brand colour needs no regeneration and shows up on
1027 + * sitemaps published long before.
1028 + *
1029 + * @param array $settings Sitemap settings (read for `enable_styling`).
1030 + * @param string $variant Stylesheet variant, `sitemap` or `index`.
1031 + * @return string Prolog lines, newline-terminated.
1032 + */
1033 + private function xml_prolog(array $settings, string $variant): string {
1034 + $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1035 + $xml .= THINKRANK_SITEMAP_MARKER . "\n";
1036 +
1037 + // The stylesheet is presentation only, so it stays opt-in.
1038 + if (!empty($settings['enable_styling'])) {
1039 + $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url(Sitemap_Stylesheet::url($variant)) . '"?>' . "\n";
1040 + }
1041 +
1042 + return $xml;
1043 + }
1044 +
1045 + /**
641 1046 * Wrap a set of <url> entry strings in a complete <urlset> document.
642 1047 *
643 1048 * @since 1.14.0
644 1049 *
@@ -647,14 +1052,10 @@
647 1052 * @param bool $with_image_ns Include the image sitemap namespace
648 1053 * @return string Full sitemap XML
649 1054 */
650 1055 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
651 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1056 + $xml = $this->xml_prolog($settings, 'sitemap');
652 1057
653 - if (!empty($settings['enable_styling'])) {
654 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
655 - }
656 -
657 1058 if ($with_image_ns && !empty($settings['include_images'])) {
658 1059 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
659 1060 } else {
660 1061 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
@@ -831,9 +1232,16 @@
831 1232 '_builtin' => false
832 1233 ], 'names');
833 1234
834 1235 foreach ($custom_taxonomies as $taxonomy) {
835 - if ($this->should_include_taxonomy($taxonomy)) {
1236 + if (!$this->should_include_taxonomy($taxonomy)) {
1237 + continue;
1238 + }
1239 +
1240 + // Same as the post-type walk above: an explicit per-taxonomy flag
1241 + // now decides, and an unset flag keeps the previous "included"
1242 + // behaviour (#660).
1243 + if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $settings)) {
836 1244 $taxonomies[] = $taxonomy;
837 1245 }
838 1246 }
839 1247
@@ -888,8 +1296,16 @@
888 1296 if (is_wp_error($all_terms)) {
889 1297 return [];
890 1298 }
891 1299
1300 + // Drop terms the user marked noindex. This path feeds the single general
1301 + // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
1302 + // so both need the filter or the two disagree about the same term.
1303 + $all_terms = array_values(array_filter(
1304 + $all_terms,
1305 + fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1306 + ));
1307 +
892 1308 // Group terms by taxonomy
893 1309 return $this->group_terms_by_taxonomy($all_terms);
894 1310 }
895 1311
@@ -1038,8 +1454,17 @@
1038 1454 * @param array $settings Sitemap settings
1039 1455 * @return bool Whether to include in sitemap
1040 1456 */
1041 1457 private function should_include_in_sitemap(\WP_Post $post, array $settings): bool {
1458 + // The WooCommerce cart, checkout and account pages are transactional,
1459 + // never indexable, and generate_default_robots_rules() already emits a
1460 + // Disallow for each of them. Listing them here submitted URLs our own
1461 + // robots.txt blocks, which Search Console reports as "Submitted URL
1462 + // blocked by robots.txt". Yoast and Rank Math exclude the same three.
1463 + if (in_array($post->ID, $this->woocommerce_excluded_page_ids(), true)) {
1464 + return false;
1465 + }
1466 +
1042 1467 // Respect user setting for password protected content
1043 1468 if (!empty($post->post_password) && !empty($settings['exclude_password_protected'])) {
1044 1469 return false;
1045 1470 }
@@ -1075,8 +1500,69 @@
1075 1500 return true;
1076 1501 }
1077 1502
1078 1503 /**
1504 + * WooCommerce pages that must never reach the sitemap.
1505 + *
1506 + * Resolved through wc_get_page_id() so a store that moved or renamed its
1507 + * cart/checkout/account pages is still matched. Returns an empty list when
1508 + * WooCommerce is not active. Memoised — should_include_in_sitemap() runs
1509 + * once per post.
1510 + *
1511 + * @since 2.0.1
1512 + *
1513 + * @return int[] Page IDs to exclude.
1514 + */
1515 + private function woocommerce_excluded_page_ids(): array {
1516 + if ($this->woocommerce_excluded_page_ids !== null) {
1517 + return $this->woocommerce_excluded_page_ids;
1518 + }
1519 +
1520 + $ids = [];
1521 +
1522 + if (function_exists('wc_get_page_id')) {
1523 + foreach (['cart', 'checkout', 'myaccount'] as $page) {
1524 + $id = (int) wc_get_page_id($page);
1525 + // wc_get_page_id() returns -1 when the page is not configured.
1526 + if ($id > 0) {
1527 + $ids[] = $id;
1528 + }
1529 + }
1530 + }
1531 +
1532 + $this->woocommerce_excluded_page_ids = $ids;
1533 +
1534 + return $ids;
1535 + }
1536 +
1537 + /**
1538 + * Whether a term carries an explicit noindex override.
1539 + *
1540 + * Mirrors the post-side check in should_include_post(); terms store the same
1541 + * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1542 + * by the update-term-seo ability and by the SEO importer.
1543 + *
1544 + * @since 1.31.0
1545 + *
1546 + * @param int $term_id Term to test.
1547 + * @return bool True when the term is marked noindex.
1548 + */
1549 + private function term_is_noindexed(int $term_id): bool {
1550 + if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1551 + return false;
1552 + }
1553 +
1554 + $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1555 + if (!is_string($raw) || $raw === '') {
1556 + return false;
1557 + }
1558 +
1559 + $robots = json_decode($raw, true);
1560 +
1561 + return is_array($robots) && !empty($robots['noindex']);
1562 + }
1563 +
1564 + /**
1079 1565 * Count total URLs in sitemap
1080 1566 *
1081 1567 * @since 1.0.0
1082 1568 *
@@ -1239,9 +1725,15 @@
1239 1725 if (!empty($settings['include_pages'])) {
1240 1726 $post_types[] = 'page';
1241 1727 }
1242 1728
1243 - // Auto-detect public custom post types that should be included
1729 + // Auto-detect public custom post types that should be included.
1730 + //
1731 + // A custom type's `include_<slug>` / `exclude_<slug>` flag is honoured
1732 + // here (#660). It was previously stored — additional_setting_keys()
1733 + // has always let those keys through — but never read, so a CPT was in
1734 + // the sitemap whatever the setting said. Unset still means included, so
1735 + // a site that never touched the flag is unaffected.
1244 1736 $custom_post_types = get_post_types([
1245 1737 'public' => true,
1246 1738 '_builtin' => false
1247 1739 ], 'names');
@@ -1246,9 +1738,13 @@
1246 1738 '_builtin' => false
1247 1739 ], 'names');
1248 1740
1249 1741 foreach ($custom_post_types as $post_type) {
1250 - if ($this->should_include_post_type($post_type)) {
1742 + if (!$this->should_include_post_type($post_type)) {
1743 + continue;
1744 + }
1745 +
1746 + if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $post_type, $settings)) {
1251 1747 $post_types[] = $post_type;
1252 1748 }
1253 1749 }
1254 1750
@@ -1269,18 +1765,11 @@
1269 1765 // Templately's internal `templately_library` store — which are template
1270 1766 // records, not standalone indexable URLs. BetterDocs `docs` and
1271 1767 // WooCommerce `product` register exclude_from_search => false, so they
1272 1768 // remain included.
1273 - if (!is_post_type_viewable($post_type)) {
1274 - return false;
1275 - }
1276 -
1277 - $post_type_obj = get_post_type_object($post_type);
1278 - if (!$post_type_obj || !empty($post_type_obj->exclude_from_search)) {
1279 - return false;
1280 - }
1281 -
1282 - return true;
1769 + // The predicate lives in Content_Type_Settings so the matrix can ask
1770 + // the same question before offering a switch for this post type.
1771 + return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_post_type($post_type);
1283 1772 }
1284 1773
1285 1774 /**
1286 1775 * Check if taxonomy should trigger regeneration
@@ -1289,11 +1778,13 @@
1289 1778 * @param string $taxonomy Taxonomy slug
1290 1779 * @return bool True if taxonomy should trigger regeneration
1291 1780 */
1292 1781 private function should_include_taxonomy(string $taxonomy): bool {
1293 - // Include all public taxonomies (presets control which sitemaps are created)
1294 - $taxonomy_obj = get_taxonomy($taxonomy);
1295 - return $taxonomy_obj && $taxonomy_obj->public;
1782 + // Public taxonomies only, and only the ones this generator can actually
1783 + // emit — the same predicate the content-type matrix asks before it
1784 + // offers a sitemap switch for one (presets still control which
1785 + // sitemaps are created).
1786 + return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_taxonomy($taxonomy);
1296 1787 }
1297 1788
1298 1789 /**
1299 1790 * Schedule debounced sitemap regeneration
@@ -1301,16 +1792,356 @@
1301 1792 * @since 1.0.0
1302 1793 * @return void
1303 1794 */
1304 1795 private function schedule_debounced_regeneration(): void {
1305 - // Clear any existing scheduled regeneration
1306 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap');
1796 + $this->mark_regeneration_pending('content');
1797 + $this->debounce_event('thinkrank_regenerate_sitemap');
1798 + }
1307 1799
1308 - // Schedule regeneration in 30 seconds to debounce rapid changes
1309 - wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap');
1800 + /**
1801 + * Schedule (or keep) the debounced single event behind a regeneration hook.
1802 + *
1803 + * An event that is already due is left alone. WP-Cron only runs when a
1804 + * request arrives, so on a site with DISABLE_WP_CRON, a blocked loopback or
1805 + * little traffic an overdue event can sit in the queue for a long time —
1806 + * clearing and re-scheduling it on every save pushed the rebuild
1807 + * permanently 30 seconds into the future and the sitemap never updated
1808 + * (#629). Debouncing only against an event that has not come due yet keeps
1809 + * the bulk-edit coalescing without starving the rebuild.
1810 + *
1811 + * @since 2.2.1
1812 + * @param string $hook Regeneration hook to debounce.
1813 + * @return void
1814 + */
1815 + private function debounce_event(string $hook): void {
1816 + $next = wp_next_scheduled($hook);
1817 +
1818 + if ($next !== false) {
1819 + if ($next <= time()) {
1820 + return;
1821 + }
1822 +
1823 + wp_clear_scheduled_hook($hook);
1824 + }
1825 +
1826 + wp_schedule_single_event(time() + self::REGENERATION_DEBOUNCE, $hook);
1310 1827 }
1311 1828
1312 1829 /**
1830 + * Record that a rebuild is outstanding, so an overdue one can be taken over
1831 + * by a later request and its staleness surfaced in the UI.
1832 + *
1833 + * `since` is the *oldest* outstanding change: it is what the takeover grace
1834 + * and the admin staleness warning are measured from, so successive edits
1835 + * must not push it forward. A settings change outranks a content change —
1836 + * it rebuilds regardless of the auto_generate toggle and handles a sitemap
1837 + * that has just been disabled — so once one is outstanding it stays the
1838 + * recorded source until the rebuild lands.
1839 + *
1840 + * @since 2.2.1
1841 + * @param string $source Either 'content' or 'settings'.
1842 + * @return void
1843 + */
1844 + private function mark_regeneration_pending(string $source): void {
1845 + // Whatever made the static files stale made the rendered ones stale
1846 + // too. Invalidating here rather than only on the rebuild keeps the two
1847 + // delivery modes reacting to exactly the same triggers, which is the
1848 + // only way a dynamic site stays as fresh as a static one (#752).
1849 + $this->flush_dynamic_cache();
1850 +
1851 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1852 + $pending = is_array($pending) ? $pending : [];
1853 +
1854 + $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1855 + $current = isset($pending['source']) ? (string) $pending['source'] : '';
1856 + $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1857 +
1858 + update_option(
1859 + self::REGENERATION_PENDING_OPTION,
1860 + [
1861 + 'since' => $since,
1862 + 'source' => $source,
1863 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
1864 + 'next_attempt' => !empty($pending['next_attempt'])
1865 + ? (int) $pending['next_attempt']
1866 + : time() + self::REGENERATION_TAKEOVER_GRACE,
1867 + // Bumped on every change so a rebuild can tell whether the edit
1868 + // it started for is still the newest one outstanding.
1869 + 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
1870 + ],
1871 + true
1872 + );
1873 + }
1874 +
1875 + /**
1876 + * The revision of the outstanding rebuild, for
1877 + * {@see mark_regeneration_complete()} to compare against once it is done.
1878 + *
1879 + * @since 2.2.1
1880 + * @return int Current revision, 0 when nothing is outstanding.
1881 + */
1882 + private function current_regeneration_revision(): int {
1883 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1884 +
1885 + return (is_array($pending) && !empty($pending['revision'])) ? (int) $pending['revision'] : 0;
1886 + }
1887 +
1888 + /**
1889 + * Clear the outstanding-rebuild marker and any recorded failure.
1890 + *
1891 + * Public because a manual generation satisfies whatever the automatic path
1892 + * was still waiting to write.
1893 + *
1894 + * @since 2.2.1
1895 + * @return void
1896 + */
1897 + public function mark_regeneration_complete(?int $revision = null): void {
1898 + // The write succeeded, so whatever failure was on record is history.
1899 + if (get_option(self::REGENERATION_ERROR_OPTION, null) !== null) {
1900 + delete_option(self::REGENERATION_ERROR_OPTION);
1901 + }
1902 +
1903 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
1904 +
1905 + if ($pending === null) {
1906 + return;
1907 + }
1908 +
1909 + // A change that landed while this rebuild was running is not covered by
1910 + // the files it just wrote, so it has to stay outstanding — otherwise, on
1911 + // a site where WP-Cron never fires, clearing the marker would strand it
1912 + // exactly the way #629 stranded everything.
1913 + if (
1914 + $revision !== null
1915 + && is_array($pending)
1916 + && (int) ($pending['revision'] ?? 0) !== $revision
1917 + ) {
1918 + return;
1919 + }
1920 +
1921 + delete_option(self::REGENERATION_PENDING_OPTION);
1922 + }
1923 +
1924 + /**
1925 + * Record a failed regeneration instead of discarding it.
1926 + *
1927 + * Keeps the pending marker in place so the rebuild is retried, but backs the
1928 + * next attempt off exponentially (capped) so a persistently failing
1929 + * generation cannot run on every admin request.
1930 + *
1931 + * @since 2.2.1
1932 + * @param string $message Failure detail.
1933 + * @param string $source Either 'content' or 'settings'.
1934 + * @return void
1935 + */
1936 + private function record_regeneration_failure(string $message, string $source): void {
1937 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1938 + $pending = is_array($pending) ? $pending : [];
1939 + $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
1940 +
1941 + $backoff = min(
1942 + self::REGENERATION_TAKEOVER_GRACE * (2 ** min($attempts, 10)),
1943 + self::REGENERATION_MAX_BACKOFF
1944 + );
1945 +
1946 + // Same precedence mark_regeneration_pending() enforces: a settings
1947 + // rebuild outranks a content one and must not be downgraded by a failed
1948 + // attempt. Overwriting it routed the retry back through the content
1949 + // path, where should_auto_generate() can be false and the completion
1950 + // marker then discards the settings rebuild entirely. Only the
1951 + // outstanding rebuild is upgraded — the recorded error keeps reporting
1952 + // whichever attempt actually failed.
1953 + $current = isset($pending['source']) ? (string) $pending['source'] : '';
1954 + $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1955 +
1956 + update_option(
1957 + self::REGENERATION_PENDING_OPTION,
1958 + [
1959 + 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
1960 + 'source' => $pending_source,
1961 + 'attempts' => $attempts,
1962 + 'next_attempt' => time() + $backoff,
1963 + 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
1964 + ],
1965 + true
1966 + );
1967 +
1968 + update_option(
1969 + self::REGENERATION_ERROR_OPTION,
1970 + [
1971 + 'message' => $message,
1972 + 'source' => $source,
1973 + 'attempts' => $attempts,
1974 + 'time' => time(),
1975 + ],
1976 + false
1977 + );
1978 +
1979 + if (defined('WP_DEBUG') && WP_DEBUG) {
1980 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1981 + error_log(sprintf('ThinkRank: sitemap %s regeneration failed — %s', $source, $message));
1982 + }
1983 + }
1984 +
1985 + /**
1986 + * Is a rebuild outstanding and past the point where WP-Cron should have run
1987 + * it?
1988 + *
1989 + * Deliberately cheap — one autoloaded option read — because it is consulted
1990 + * on every admin request to decide whether the takeover is needed.
1991 + *
1992 + * @since 2.2.1
1993 + * @return bool True when a request should rebuild the sitemap itself.
1994 + */
1995 + public static function has_overdue_regeneration(): bool {
1996 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1997 +
1998 + if (!is_array($pending) || empty($pending['since'])) {
1999 + return false;
2000 + }
2001 +
2002 + $due = !empty($pending['next_attempt'])
2003 + ? (int) $pending['next_attempt']
2004 + : (int) $pending['since'] + self::REGENERATION_TAKEOVER_GRACE;
2005 +
2006 + return time() >= $due;
2007 + }
2008 +
2009 + /**
2010 + * Rebuild the sitemap in-request when WP-Cron has not delivered.
2011 + *
2012 + * Hooked on `shutdown` for admin, REST and CLI requests only (see
2013 + * Plugin::register_sitemap_cron_listeners()), so the work happens after the
2014 + * response has been sent and never adds latency to a visitor page view.
2015 + *
2016 + * @since 2.2.1
2017 + * @return void
2018 + */
2019 + public function run_overdue_regeneration(): void {
2020 + if (!self::has_overdue_regeneration()) {
2021 + return;
2022 + }
2023 +
2024 + // Cron is running: it is about to do exactly this work.
2025 + if (wp_doing_cron()) {
2026 + return;
2027 + }
2028 +
2029 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2030 + $source = (is_array($pending) && isset($pending['source'])) ? (string) $pending['source'] : 'content';
2031 +
2032 + if ($source === 'settings') {
2033 + $this->regenerate_sitemap_from_settings();
2034 + return;
2035 + }
2036 +
2037 + $this->auto_regenerate_sitemap();
2038 + }
2039 +
2040 + /**
2041 + * Acquire the shared generation lock.
2042 + *
2043 + * @since 2.2.1
2044 + * @return bool True when this process may generate.
2045 + */
2046 + private function acquire_generation_lock(): bool {
2047 + if (get_transient(self::GENERATION_LOCK_TRANSIENT)) {
2048 + return false;
2049 + }
2050 +
2051 + set_transient(self::GENERATION_LOCK_TRANSIENT, time(), 5 * MINUTE_IN_SECONDS);
2052 +
2053 + return true;
2054 + }
2055 +
2056 + /**
2057 + * Release the shared generation lock.
2058 + *
2059 + * @since 2.2.1
2060 + * @return void
2061 + */
2062 + private function release_generation_lock(): void {
2063 + delete_transient(self::GENERATION_LOCK_TRANSIENT);
2064 + }
2065 +
2066 + /**
2067 + * Report how automatic regeneration is faring, for the admin UI.
2068 + *
2069 + * The feature used to fail invisibly: `last_generated` simply stopped
2070 + * advancing and nothing drew attention to it (#629).
2071 + *
2072 + * @since 2.2.1
2073 + * @return array Health payload.
2074 + */
2075 + public function get_regeneration_health(): array {
2076 + $settings = $this->get_settings('site');
2077 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2078 + $pending = is_array($pending) ? $pending : [];
2079 + $error = get_option(self::REGENERATION_ERROR_OPTION, []);
2080 + $error = is_array($error) ? $error : [];
2081 +
2082 + $since = !empty($pending['since']) ? (int) $pending['since'] : 0;
2083 +
2084 + $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap');
2085 + if ($next_scheduled === false) {
2086 + $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap_settings');
2087 + }
2088 +
2089 + return [
2090 + 'auto_generate' => !empty($settings['auto_generate']),
2091 + 'last_generated' => $settings['last_generated'] ?? '',
2092 + 'pending_since' => $since ? gmdate('c', $since) : null,
2093 + 'pending_seconds' => $since ? max(0, time() - $since) : 0,
2094 + 'next_scheduled' => $next_scheduled ? gmdate('c', (int) $next_scheduled) : null,
2095 + 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON,
2096 + 'stale' => $this->is_sitemap_stale($settings),
2097 + 'last_error' => !empty($error['message'])
2098 + ? [
2099 + 'message' => (string) $error['message'],
2100 + 'source' => isset($error['source']) ? (string) $error['source'] : 'content',
2101 + 'time' => !empty($error['time']) ? gmdate('c', (int) $error['time']) : null,
2102 + ]
2103 + : null,
2104 + ];
2105 + }
2106 +
2107 + /**
2108 + * Has published content changed since the served sitemap was last written?
2109 + *
2110 + * Uses core's cached last-modified lookup, which only considers published
2111 + * posts — the same content the sitemap covers.
2112 + *
2113 + * @since 2.2.1
2114 + * @param array $settings Sitemap settings.
2115 + * @return bool True when the sitemap is behind the content.
2116 + */
2117 + private function is_sitemap_stale(array $settings): bool {
2118 + if (empty($settings['enabled']) || empty($settings['last_generated'])) {
2119 + // Never generated is already reported separately by the UI.
2120 + return false;
2121 + }
2122 +
2123 + $generated = strtotime((string) $settings['last_generated']);
2124 + if (!$generated) {
2125 + return false;
2126 + }
2127 +
2128 + $modified = get_lastpostmodified('gmt');
2129 + if (!$modified) {
2130 + return false;
2131 + }
2132 +
2133 + $modified = strtotime($modified . ' UTC');
2134 + if (!$modified) {
2135 + return false;
2136 + }
2137 +
2138 + // A minute of slack keeps a rebuild that ran alongside the edit from
2139 + // reporting itself as stale.
2140 + return $modified > ($generated + MINUTE_IN_SECONDS);
2141 + }
2142 +
2143 + /**
1313 2144 * Public entry point to debounce-rebuild the sitemap after a settings change
1314 2145 * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
1315 2146 * file reflects the new settings instead of going stale until a content edit.
1316 2147 *
@@ -1319,10 +2150,10 @@
1319 2150 public function schedule_regeneration(): void {
1320 2151 // Debounce against rapid successive saves, but use the settings-specific
1321 2152 // hook so the rebuild runs regardless of the auto_generate toggle (which
1322 2153 // only governs content-change-triggered regeneration).
1323 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap_settings');
1324 - wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap_settings');
2154 + $this->mark_regeneration_pending('settings');
2155 + $this->debounce_event('thinkrank_regenerate_sitemap_settings');
1325 2156 }
1326 2157
1327 2158 /**
1328 2159 * Rebuild the served sitemap after an explicit settings change.
@@ -1334,8 +2165,15 @@
1334 2165 *
1335 2166 * @return void
1336 2167 */
1337 2168 public function regenerate_sitemap_from_settings(): void {
2169 + if (!$this->acquire_generation_lock()) {
2170 + // A manual generation (or another request's takeover) is already
2171 + // writing the files; the pending marker survives so this rebuild is
2172 + // retried rather than lost.
2173 + return;
2174 + }
2175 +
1338 2176 try {
1339 2177 $settings = $this->get_settings('site');
1340 2178 if (empty($settings['enabled'])) {
1341 2179 // The sitemap was disabled: remove the previously generated static
@@ -1341,13 +2179,31 @@
1341 2179 // The sitemap was disabled: remove the previously generated static
1342 2180 // files so the web server stops serving a stale sitemap that
1343 2181 // crawlers would otherwise keep fetching.
1344 2182 $this->delete_published_sitemaps();
2183 + $this->mark_regeneration_complete();
1345 2184 return;
1346 2185 }
1347 - $this->generate_and_save($settings);
2186 +
2187 + $revision = $this->current_regeneration_revision();
2188 +
2189 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2190 + $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2191 + return;
2192 + }
2193 +
2194 + if ($this->generate_and_save($settings)) {
2195 + $this->mark_regeneration_complete($revision);
2196 + } else {
2197 + $this->record_regeneration_failure(
2198 + $this->write_failure_message(),
2199 + 'settings'
2200 + );
2201 + }
1348 2202 } catch (\Throwable $e) {
1349 - // Settings-triggered regeneration failed - details in exception.
2203 + $this->record_regeneration_failure($e->getMessage(), 'settings');
2204 + } finally {
2205 + $this->release_generation_lock();
1350 2206 }
1351 2207 }
1352 2208
1353 2209 /**
@@ -1352,52 +2208,80 @@
1352 2208
1353 2209 /**
1354 2210 * Remove every static sitemap file ThinkRank publishes to the web root.
1355 2211 *
1356 - * Called when the sitemap feature is disabled so /sitemap.xml,
1357 - * /sitemap_index.xml, the segmented children (incl. paginated -N pages), and
1358 - * /local-sitemap.xml stop being served. Only ThinkRank's own filenames are
1359 - * targeted; WordPress core's wp-sitemap.xml is left untouched.
2212 + * Called when the sitemap feature is disabled, by the cleanup route, and by
2213 + * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
2214 + * children (incl. paginated -N pages), and /local-sitemap.xml stop being
2215 + * served. Only ThinkRank's own filenames are targeted; WordPress core's
2216 + * wp-sitemap.xml and any other plugin's sitemap in the web root are left
2217 + * untouched.
1360 2218 *
1361 - * @return void
2219 + * @since 1.31.0 Returns the filenames removed, and accepts the settings to
2220 + * derive them from, so a caller that already read them (and
2221 + * needs to report what went) does not have to re-read or
2222 + * re-derive the name list.
2223 + * @since 2.1.0 Delegates to thinkrank_webroot_delete_sitemaps(). Uninstall
2224 + * needs the same removal but has no autoloader to reach this
2225 + * class, so the logic moved to includes/cleanup-webroot.php
2226 + * and this stays as the in-plugin entry point.
2227 + *
2228 + * @param array|null $settings Optional. Sitemap settings; defaults to the
2229 + * saved site settings.
2230 + * @return array{deleted: string[], failed: string[]} Basenames removed, and
2231 + * those that existed but could not be removed.
1362 2232 */
1363 - public function delete_published_sitemaps(): void {
1364 - $settings = $this->get_settings('site');
2233 + public function delete_published_sitemaps(?array $settings = null): array {
2234 + return thinkrank_webroot_delete_sitemaps($settings ?? $this->get_settings('site'));
2235 + }
1365 2236
1366 - // Base filenames to remove. Derive the child/index names from the stored
1367 - // sitemap_urls so a custom custom_url_pattern (e.g. seo-{type}.xml) is
1368 - // honored, not just the default sitemap-*.xml naming. Include the current
1369 - // primary + the default names as a safety net.
1370 - $names = [
1371 - 'sitemap.xml',
1372 - 'sitemap_index.xml',
1373 - 'local-sitemap.xml',
1374 - $this->get_primary_sitemap_filename($settings),
1375 - ];
2237 + /**
2238 + * Every child-sitemap filename this site could have published.
2239 + *
2240 + * @since 1.31.0
2241 + * @since 2.1.0 Delegates to thinkrank_webroot_segment_filenames().
2242 + *
2243 + * @param array $settings Sitemap settings (read for `custom_url_pattern`).
2244 + * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
2245 + */
2246 + private function publishable_segment_filenames(array $settings): array {
2247 + return thinkrank_webroot_segment_filenames($settings);
2248 + }
1376 2249
1377 - foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
1378 - if (empty($config['url'])) {
1379 - continue;
1380 - }
1381 - $name = basename((string) wp_parse_url($config['url'], PHP_URL_PATH));
1382 - if ($name !== '') {
1383 - $names[] = $name;
1384 - }
1385 - }
1386 -
1387 - foreach (array_unique(array_filter($names)) as $name) {
1388 - // Remove the file itself and any paginated -N variants of its stem
1389 - // (e.g. seo-posts.xml plus seo-posts-2.xml, seo-posts-3.xml…).
1390 - $path = ABSPATH . $name;
1391 - if (file_exists($path)) {
1392 - wp_delete_file($path);
1393 - }
1394 - if (preg_match('/^(.*)\.xml$/i', $name, $m)) {
1395 - foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
1396 - wp_delete_file($paged);
1397 - }
1398 - }
1399 - }
2250 + /**
2251 + * Can this web-root file be shown to be a sitemap ThinkRank wrote?
2252 + *
2253 + * The generator deletes as often as cleanup does — a segment that dropped
2254 + * out of the set, a pagination page beyond the new count, the local sitemap
2255 + * after the business identity was cleared — and until 2.1.1 it did all
2256 + * three by filename alone. That is the #515 bug on a far more frequent
2257 + * trigger: our names are the canonical ones, so an ordinary regeneration
2258 + * (post save, term change, settings save) destroyed RankMath's
2259 + * `sitemap-tags.xml` and `local-sitemap.xml` with no deactivation involved.
2260 + *
2261 + * Every name the generator derives comes from the current settings — the
2262 + * url pattern, the configured `sitemap_urls`, `local-sitemap.xml` — but the
2263 + * ownership test is still asked with `$name_derived = false`, which switches
2264 + * off the legacy fallback for the whole generator side.
2265 + *
2266 + * The fallback exists to recover a pre-2.1.1 file written with
2267 + * `enable_styling` off, which carries neither marker. That recovery belongs
2268 + * to the once-off cleanup paths. Here it can only do harm: this method runs
2269 + * on every post save, and everything this version writes carries
2270 + * THINKRANK_SITEMAP_MARKER, so after the site's first regeneration an
2271 + * unmarked file at one of our names is by definition somebody else's — and
2272 + * deleting it on an ordinary regeneration is #515 through the more common
2273 + * door. The cost is a stale unmarked segment left on disk until deactivation
2274 + * picks it up, which is the safe direction to fail in.
2275 + *
2276 + * @since 2.1.1
2277 + *
2278 + * @param string $path Absolute path to a file in the web root.
2279 + * @param array $settings Sitemap settings.
2280 + * @return bool True when the file may be deleted.
2281 + */
2282 + private function webroot_sitemap_is_ours(string $path, array $settings): bool {
2283 + return thinkrank_webroot_sitemap_is_ours($path, $settings, false);
1400 2284 }
1401 2285
1402 2286 /**
1403 2287 * Auto-regenerate sitemap (called by scheduled action)
@@ -1405,20 +2289,48 @@
1405 2289 * @since 1.0.0
1406 2290 * @return void
1407 2291 */
1408 2292 public function auto_regenerate_sitemap(): void {
2293 + if (!$this->acquire_generation_lock()) {
2294 + // A manual generation (or another request's takeover) is already
2295 + // writing the files; the pending marker survives so this rebuild is
2296 + // retried rather than lost.
2297 + return;
2298 + }
2299 +
1409 2300 try {
1410 2301 // Double-check that auto-generation is still enabled
1411 2302 if (!$this->should_auto_generate()) {
2303 + // Nothing outstanding can be delivered while the feature is off,
2304 + // so drop the marker rather than let the takeover retry forever.
2305 + $this->mark_regeneration_complete();
1412 2306 return;
1413 2307 }
1414 2308
1415 - $this->generate_and_save($this->get_settings('site'));
2309 + $revision = $this->current_regeneration_revision();
2310 + $settings = $this->get_settings('site');
1416 2311
1417 - // Sitemap auto-regenerated successfully
2312 + // See regenerate_sitemap_from_settings(): nothing to write.
2313 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2314 + $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2315 + return;
2316 + }
1418 2317
2318 + if ($this->generate_and_save($settings)) {
2319 + $this->mark_regeneration_complete($revision);
2320 + } else {
2321 + // Previously this returned quietly and last_generated simply
2322 + // stopped advancing, leaving the site owner with no way to learn
2323 + // the sitemap had stopped updating (#629).
2324 + $this->record_regeneration_failure(
2325 + $this->write_failure_message(),
2326 + 'content'
2327 + );
2328 + }
1419 2329 } catch (\Throwable $e) {
1420 - // Sitemap auto-regeneration failed - error details available in exception
2330 + $this->record_regeneration_failure($e->getMessage(), 'content');
2331 + } finally {
2332 + $this->release_generation_lock();
1421 2333 }
1422 2334 }
1423 2335
1424 2336 /**
@@ -1433,8 +2345,26 @@
1433 2345 * @param array $settings Sitemap settings.
1434 2346 * @return bool True when the sitemap files were written.
1435 2347 */
1436 2348 public function generate_and_save(array $settings): bool {
2349 + // Dynamic delivery publishes no files, so writing them here would put a
2350 + // static copy back in the web root for the server to serve in place of
2351 + // the dynamic route. Guarding at each call site left gaps — the
2352 + // snapshot migrator's post-import regeneration had none — so the rule
2353 + // lives with the writing instead.
2354 + //
2355 + // `is_collecting()` is the exception that makes dynamic delivery work
2356 + // at all: render_document() and collect_documents() reach this same
2357 + // method with the writer swapped for a collector, and that is precisely
2358 + // the dynamic build. Only a real write is skipped.
2359 + if (!$this->is_collecting() && 'dynamic' === $this->resolve_delivery_mode($settings)) {
2360 + // Whatever prompted this call changed the sitemap's content, so the
2361 + // rendered copies must not outlive it.
2362 + $this->flush_dynamic_cache();
2363 +
2364 + return true;
2365 + }
2366 +
1437 2367 // Index mode is driven by the use_sitemap_index toggle (not merely by how
1438 2368 // many sitemap_urls happen to be configured). When the toggle is on but
1439 2369 // no child sitemaps are set up yet, synthesize the per-type segmented set
1440 2370 // so we emit a real <sitemapindex> with paginated children instead of a
@@ -1445,16 +2375,29 @@
1445 2375 $results = $this->generate_multiple_sitemaps($settings);
1446 2376 $written = !empty($results['success']);
1447 2377 } else {
1448 2378 $xml = $this->generate_sitemap($settings);
1449 - $written = $this->save_sitemap_to_file($xml, $this->get_primary_sitemap_filename($settings));
2379 + $primary = $this->get_primary_sitemap_filename($settings);
2380 + $written = $this->save_sitemap_to_file($xml, $primary);
1450 2381
1451 2382 // Local business sitemap is a standalone file, regenerated on the
1452 2383 // single-sitemap path too (this is the default mode).
1453 2384 $this->regenerate_local_sitemap($settings);
2385 +
2386 + // Switching out of index mode leaves sitemap_index.xml and every
2387 + // child on disk, still served and never refreshed again. The index
2388 + // path already prunes what it no longer owns; this path never did,
2389 + // so the site kept serving two sitemap trees (#563). Ownership is
2390 + // still tested per file, so another plugin's sitemap at one of our
2391 + // names is never touched (#515).
2392 + $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
1454 2393 }
1455 2394
1456 - if ($written) {
2395 + // last_generated describes what is on disk. A dynamic render publishes
2396 + // nothing, so advancing it would report a static publication that never
2397 + // happened and would let primary_sitemap_file_exists() callers believe
2398 + // there is a file to serve.
2399 + if ($written && !$this->is_collecting()) {
1457 2400 $settings['last_generated'] = gmdate('c');
1458 2401 $this->save_settings('site', null, $settings);
1459 2402 }
1460 2403
@@ -1461,8 +2404,398 @@
1461 2404 return $written;
1462 2405 }
1463 2406
1464 2407 /**
2408 + * Whether this instance is rendering documents rather than publishing them.
2409 + *
2410 + * @since 2.9.0
2411 + *
2412 + * @return bool
2413 + */
2414 + private function is_collecting(): bool {
2415 + return $this->document_sink !== null;
2416 + }
2417 +
2418 + /**
2419 + * Complete a regeneration that delivers dynamically, retiring stale files.
2420 + *
2421 + * Dynamic delivery renders nothing to disk, but that is only half the job.
2422 + * A web server hands back an existing `/sitemap.xml` without ever loading
2423 + * WordPress, so any file left over from a previous static generation goes on
2424 + * being served forever and {@see \ThinkRank\Frontend\SEO_Manager
2425 + * ::maybe_serve_sitemap()} is never reached. Switching to dynamic while
2426 + * leaving those files in place would therefore appear to do nothing at all.
2427 + *
2428 + * Both transitions matter and they differ:
2429 + *
2430 + * - An explicit switch to `dynamic` happens on a site whose root is usually
2431 + * still writable, so the files can simply be removed.
2432 + * - An `auto` site that becomes read-only cannot remove them, because
2433 + * deleting an entry needs write permission on the directory that holds
2434 + * it. There the stale sitemap really is stuck in front of us, and the
2435 + * honest outcome is a recorded failure naming it rather than a rebuild
2436 + * reported as complete (#754 review).
2437 + *
2438 + * Ownership is tested per file by the shared helper, so another plugin's
2439 + * sitemap at one of our names is never deleted (#515).
2440 + *
2441 + * @since 2.9.0
2442 + *
2443 + * @param array $settings Sitemap settings.
2444 + * @param int $revision Revision this rebuild is completing.
2445 + * @param string $source 'settings' or 'content', for the failure record.
2446 + * @return void
2447 + */
2448 + private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): void {
2449 + $this->flush_dynamic_cache();
2450 +
2451 + $removal = $this->delete_published_sitemaps($settings);
2452 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2453 +
2454 + if (!empty($stuck)) {
2455 + $this->record_regeneration_failure(
2456 + sprintf(
2457 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2458 + __('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'),
2459 + implode(', ', $stuck),
2460 + untrailingslashit(ABSPATH)
2461 + ),
2462 + $source
2463 + );
2464 +
2465 + return;
2466 + }
2467 +
2468 + $this->mark_regeneration_complete($revision);
2469 + }
2470 +
2471 + /**
2472 + * What to tell the site owner when publishing the files failed.
2473 + *
2474 + * The old wording stated the symptom and stopped there, so the reported
2475 + * cause was a guess and this reached support as a plugin fault rather than
2476 + * a folder permission (#752, #753). When the root is demonstrably
2477 + * unwritable, say that, and say what to do about it.
2478 + *
2479 + * @since 2.9.0
2480 + *
2481 + * @return string
2482 + */
2483 + private function write_failure_message(): string {
2484 + if (!wp_is_writable(ABSPATH)) {
2485 + return sprintf(
2486 + /* translators: %s: absolute path to the WordPress root. */
2487 + __('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'),
2488 + untrailingslashit(ABSPATH)
2489 + );
2490 + }
2491 +
2492 + return __('The sitemap files could not be written to the site root.', 'thinkrank');
2493 + }
2494 +
2495 + /**
2496 + * How this site delivers its sitemap.
2497 + *
2498 + * `auto` is resolved on whether the web root can be written. That is the
2499 + * right signal here (unlike llms.txt, where the question is whether the
2500 + * server applies the .htaccess charset block): a site whose root is
2501 + * read-only cannot publish a sitemap file at all, and before this existed
2502 + * the feature simply failed with "The sitemap files could not be written to
2503 + * the site root." and served nothing (#752).
2504 + *
2505 + * @since 2.9.0
2506 + *
2507 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2508 + * @return string One of 'static' or 'dynamic'. Never 'auto'.
2509 + */
2510 + public function resolve_delivery_mode(?array $settings = null): string {
2511 + $settings = $settings ?? $this->get_settings('site');
2512 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
2513 +
2514 + if ('static' === $mode || 'dynamic' === $mode) {
2515 + return $mode;
2516 + }
2517 +
2518 + return wp_is_writable(ABSPATH) ? 'static' : 'dynamic';
2519 + }
2520 +
2521 + /**
2522 + * Render one published sitemap document without touching the filesystem.
2523 + *
2524 + * Runs the ordinary build pipeline with the writer swapped for a collector,
2525 + * so the bytes returned here are the bytes the static path would have
2526 + * written. `SitemapDeliveryParityTest` asserts that equivalence rather than
2527 + * trusting it.
2528 + *
2529 + * The whole set is built to answer for one file, because the index can only
2530 + * be assembled from the children that were actually produced. The result is
2531 + * cached per document, so that cost is paid once per change and not once
2532 + * per crawler request.
2533 + *
2534 + * @since 2.9.0
2535 + *
2536 + * @param string $filename Published file name, e.g. 'sitemap.xml'.
2537 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2538 + * @return string|null XML, or null when this site does not publish that name.
2539 + */
2540 + public function render_document(string $filename, ?array $settings = null): ?string {
2541 + $filename = basename($filename);
2542 + $settings = $settings ?? $this->get_settings('site');
2543 +
2544 + if (empty($settings['enabled'])) {
2545 + return null;
2546 + }
2547 +
2548 + $cached = get_transient($this->dynamic_cache_key($filename));
2549 + if (self::ABSENT_MARKER === $cached) {
2550 + return null;
2551 + }
2552 + if (is_string($cached) && '' !== $cached) {
2553 + return $cached;
2554 + }
2555 +
2556 + // A miss builds the whole set, because the index can only be assembled
2557 + // from the children that were actually produced. Caching only the
2558 + // requested document therefore made a crawler walking the index and its
2559 + // children rebuild the entire site's sitemap once per file — every post
2560 + // and taxonomy query repeated N times on a public endpoint (#754
2561 + // review). The set is built once and stored in full.
2562 + return $this->stream_documents($settings, $filename);
2563 + }
2564 +
2565 + /**
2566 + * Build every document, caching each as it is produced, keeping one.
2567 + *
2568 + * A miss has to build the whole set, because the index can only be
2569 + * assembled from the children that were actually produced. It does not have
2570 + * to *hold* the whole set: the static path never keeps more than one page
2571 + * in memory, writing each to disk as it goes, and buffering every
2572 + * document's XML to return one of them undid that on the request path,
2573 + * where a large site's entire sitemap corpus would sit in a single PHP
2574 + * process (#754 review).
2575 + *
2576 + * So the sink writes each document straight to its cache entry and lets it
2577 + * go, retaining only the one this request is answering. Peak retention is
2578 + * one document, whatever the site's size.
2579 + *
2580 + * Concurrency: the first request through takes a short lock and does the
2581 + * work. One that finds the lock held waits a bounded moment for the winner
2582 + * to publish, then builds anyway, because serving a correct sitemap late
2583 + * beats serving none.
2584 + *
2585 + * @since 2.9.0
2586 + *
2587 + * @param array $settings Sitemap settings.
2588 + * @param string $wanted Document this request is answering.
2589 + * @return string|null XML for $wanted, or null when the site does not publish it.
2590 + */
2591 + private function stream_documents(array $settings, string $wanted): ?string {
2592 + $lock = self::DYNAMIC_CACHE_PREFIX . 'lock';
2593 +
2594 + if (!$this->acquire_render_lock($lock)) {
2595 + for ($attempt = 0; $attempt < self::RENDER_LOCK_WAIT_ATTEMPTS; $attempt++) {
2596 + usleep(self::RENDER_LOCK_WAIT_MICROSECONDS);
2597 +
2598 + $cached = get_transient($this->dynamic_cache_key($wanted));
2599 + if (self::ABSENT_MARKER === $cached) {
2600 + return null;
2601 + }
2602 + if (is_string($cached) && '' !== $cached) {
2603 + return $cached;
2604 + }
2605 + }
2606 + }
2607 +
2608 + $kept = null;
2609 + // Names only. Keeping the bodies here would be the very retention this
2610 + // method exists to avoid.
2611 + $produced = [];
2612 +
2613 + $previous = $this->document_sink;
2614 + $this->document_sink = function (string $name, string $xml) use (&$kept, &$produced, $wanted): void {
2615 + $produced[$name] = true;
2616 + set_transient($this->dynamic_cache_key($name), $xml, self::DYNAMIC_CACHE_TTL);
2617 +
2618 + if ($name === $wanted) {
2619 + $kept = $xml;
2620 + }
2621 + };
2622 +
2623 + try {
2624 + $this->generate_and_save($settings);
2625 +
2626 + // Names the configuration lists but this build did not produce get
2627 + // a negative entry, so asking for one again is a cache hit rather
2628 + // than another full rebuild.
2629 + $absent = $this->published_document_names($settings);
2630 +
2631 + // Also the exact name this request asked for: a paginated page past
2632 + // the end of a stem is a legitimate request shape that the base
2633 + // list cannot enumerate, and without an entry it would rebuild on
2634 + // every hit.
2635 + $absent[] = $wanted;
2636 +
2637 + foreach (array_unique($absent) as $name) {
2638 + if (!isset($produced[$name])) {
2639 + set_transient($this->dynamic_cache_key($name), self::ABSENT_MARKER, self::DYNAMIC_CACHE_TTL);
2640 + }
2641 + }
2642 + } finally {
2643 + $this->document_sink = $previous;
2644 + delete_transient($lock);
2645 + }
2646 +
2647 + return $kept;
2648 + }
2649 +
2650 + /**
2651 + * Take the render lock, if it is free.
2652 + *
2653 + * Not atomic across processes, and deliberately so: the fallback for losing
2654 + * a race is duplicated work, never a wrong or missing sitemap, so a
2655 + * heavier primitive would buy nothing here.
2656 + *
2657 + * @since 2.9.0
2658 + *
2659 + * @param string $lock Lock transient name.
2660 + * @return bool True when this request holds the lock.
2661 + */
2662 + private function acquire_render_lock(string $lock): bool {
2663 + if (false !== get_transient($lock)) {
2664 + return false;
2665 + }
2666 +
2667 + set_transient($lock, time(), self::RENDER_LOCK_TTL);
2668 +
2669 + return true;
2670 + }
2671 +
2672 + /**
2673 + * Build every document this site publishes and return them all.
2674 + *
2675 + * Verification and tooling only. This retains the whole set in memory, so
2676 + * it must never be used to answer a request: {@see self::stream_documents()}
2677 + * is the serving path and keeps one document at a time regardless of site
2678 + * size (#754 review). `SitemapDeliveryParityTest` enforces that separation
2679 + * by failing if the request path routes back through here.
2680 + *
2681 + * @since 2.9.0
2682 + *
2683 + * @param array $settings Sitemap settings.
2684 + * @return array<string,string> Filename => XML.
2685 + */
2686 + public function collect_documents(array $settings): array {
2687 + $documents = [];
2688 +
2689 + $previous = $this->document_sink;
2690 + $this->document_sink = static function (string $name, string $xml) use (&$documents): void {
2691 + $documents[$name] = $xml;
2692 + };
2693 +
2694 + try {
2695 + $this->generate_and_save($settings);
2696 + } finally {
2697 + $this->document_sink = $previous;
2698 + }
2699 +
2700 + return $documents;
2701 + }
2702 +
2703 + /**
2704 + * The file names this site publishes, without building their contents.
2705 + *
2706 + * Used by the request router to decide whether a URL is ours before doing
2707 + * any work. Cheap: it reads the configured child list rather than querying
2708 + * for entries.
2709 + *
2710 + * @since 2.9.0
2711 + *
2712 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2713 + * @return string[] File names, including paginated pages that may exist.
2714 + */
2715 + public function published_document_names(?array $settings = null): array {
2716 + $settings = $settings ?? $this->get_settings('site');
2717 + $resolved = $this->maybe_promote_to_index($settings);
2718 +
2719 + $names = [$this->get_primary_sitemap_filename($settings), 'local-sitemap.xml'];
2720 +
2721 + foreach ((array) ($resolved['sitemap_urls'] ?? []) as $child) {
2722 + if (!is_array($child) || empty($child['enabled'])) {
2723 + continue;
2724 + }
2725 +
2726 + $path = (string) wp_parse_url((string) ($child['url'] ?? ''), PHP_URL_PATH);
2727 + if ('' !== $path) {
2728 + $names[] = basename($path);
2729 + }
2730 + }
2731 +
2732 + return array_values(array_unique(array_filter($names)));
2733 + }
2734 +
2735 + /**
2736 + * Does this site publish a document under that name?
2737 + *
2738 + * Not a plain membership test against {@see self::published_document_names()}:
2739 + * that lists the configured children, and a child over the per-file URL cap
2740 + * is split into `<stem>-2.xml`, `<stem>-3.xml` and so on, with every page
2741 + * listed in the index. Gating the request router on the base list alone
2742 + * therefore 404'd exactly the pages the index points at, which is worse than
2743 + * not serving them at all.
2744 + *
2745 + * Page counts are not knowable without building, so the stem is what is
2746 + * matched; a page that does not exist is answered by the build finding
2747 + * nothing for it, and is then cached as absent.
2748 + *
2749 + * @since 2.9.0
2750 + *
2751 + * @param string $name Requested file name.
2752 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2753 + * @return bool
2754 + */
2755 + public function publishes_document_name(string $name, ?array $settings = null): bool {
2756 + $names = $this->published_document_names($settings);
2757 +
2758 + if (in_array($name, $names, true)) {
2759 + return true;
2760 + }
2761 +
2762 + if (!preg_match('/^(.*)-\d+\.xml$/i', $name, $m)) {
2763 + return false;
2764 + }
2765 +
2766 + return in_array($m[1] . '.xml', $names, true);
2767 + }
2768 +
2769 + /**
2770 + * Transient key for a rendered document.
2771 + *
2772 + * @since 2.9.0
2773 + *
2774 + * @param string $filename Published file name.
2775 + * @return string
2776 + */
2777 + private function dynamic_cache_key(string $filename): string {
2778 + return self::DYNAMIC_CACHE_PREFIX . md5($filename);
2779 + }
2780 +
2781 + /**
2782 + * Drop every cached dynamic document.
2783 + *
2784 + * Called from the same places that mark the static files stale, so the two
2785 + * delivery modes invalidate on identical triggers.
2786 + *
2787 + * @since 2.9.0
2788 + *
2789 + * @return void
2790 + */
2791 + public function flush_dynamic_cache(): void {
2792 + foreach ($this->published_document_names() as $name) {
2793 + delete_transient($this->dynamic_cache_key($name));
2794 + }
2795 + }
2796 +
2797 + /**
1465 2798 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
1466 2799 *
1467 2800 * - When use_sitemap_index is on but no child sitemaps are configured, build
1468 2801 * the per-type segmented set so a real <sitemapindex> is produced (#127).
@@ -1473,15 +2806,90 @@
1473 2806 * @param array $settings Sitemap settings.
1474 2807 * @return array Possibly-updated settings.
1475 2808 */
1476 2809 public function maybe_promote_to_index(array $settings): array {
1477 - $has_children = count((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : [])) > 1;
2810 + $saved = $this->get_settings('site');
1478 2811
2812 + // Read from the *payload*, before the merge below folds the saved values
2813 + // in: "the caller named this" and "this has a value" are different
2814 + // questions, and the mode resolution turns on the former.
2815 + $mode_supplied = array_key_exists('use_sitemap_index', $settings);
2816 + $urls_supplied = is_array($settings['sitemap_urls'] ?? null);
2817 + $has_children = count($urls_supplied ? $settings['sitemap_urls'] : []) > 1;
2818 +
2819 + // Which inclusion flags did the caller actually name? The child list is
2820 + // the only thing that reads them, and inheriting a saved one skipped
2821 + // that — so on an index-mode site the include_* flags were enforced
2822 + // nowhere but in the browser, where SitemapGeneration.js recomputes
2823 + // sitemap_urls itself. Every non-UI client, the shipped
2824 + // `update-sitemap-settings` ability included, saved the flag and changed
2825 + // nothing (#398). Read from the payload for the same reason as above:
2826 + // after the merge every saved flag would look like one the caller named.
2827 + $named_inclusions = array_intersect(
2828 + array_keys(self::INCLUSION_CHILD_TYPES),
2829 + array_keys($settings)
2830 + );
2831 + $inclusions_supplied = (bool) $named_inclusions;
2832 +
1479 2833 // Inclusion flags may be absent from a partial payload (e.g. the manual
1480 2834 // generate endpoint) — fall back to saved settings so synthesized child
1481 2835 // sitemaps reflect the real include_posts/pages/categories choices.
1482 - $inclusions = array_merge($this->get_settings('site'), $settings);
2836 + $inclusions = array_merge($saved, $settings);
1483 2837
2838 + // Hand the generators a *complete* settings array. Only the mode was
2839 + // resolved before, so every other unnamed key reached them missing: a
2840 + // bare `{}` from a REST/MCP client republished the sitemap with
2841 + // enable_styling and include_images read as off, overwriting the live
2842 + // files with output that had lost its XSL stylesheet, its image
2843 + // namespace and its image entries. Presentation and inclusion settings
2844 + // are not something a generate call opts into — they are the site's
2845 + // configuration, and only a value actually present in the payload
2846 + // overrides them.
2847 + $settings = $inclusions;
2848 +
2849 + // The two keys that drive mode keep their own resolution rules below,
2850 + // so they must go back to "not specified" when the caller omitted them.
2851 + if (!$mode_supplied) {
2852 + unset($settings['use_sitemap_index']);
2853 + }
2854 + if (!$urls_supplied) {
2855 + unset($settings['sitemap_urls']);
2856 + }
2857 +
2858 + // An absent use_sitemap_index means "not specified", which is not the
2859 + // same as "single file". Reading it as the latter meant a partial payload
2860 + // — `{}` from a REST/MCP client, or anything short of the full settings
2861 + // object the admin bundle sends — republished one flat sitemap.xml on an
2862 + // index-mode site and left sitemap_index.xml and its children stale or
2863 + // missing. Inherit the saved mode instead; only a value actually present
2864 + // in the payload decides the mode.
2865 + if (!array_key_exists('use_sitemap_index', $settings)) {
2866 + $settings['use_sitemap_index'] = $saved['use_sitemap_index'] ?? '';
2867 +
2868 + // Inheriting the mode means inheriting its children too, unless the
2869 + // caller named its own set.
2870 + $saved_children = (is_array($saved['sitemap_urls'] ?? null) ? $saved['sitemap_urls'] : []);
2871 + if (!empty($settings['use_sitemap_index'])
2872 + && !$urls_supplied
2873 + && count($saved_children) > 1) {
2874 + // A named inclusion flag is applied *to* the inherited list, not
2875 + // used to regenerate it. build_segmented_sitemap_urls() also adds
2876 + // a child for every public custom post type, so rebuilding here
2877 + // would make `{include_pages: false}` — one thing off — silently
2878 + // switch on children the saved list never had (an Elementor
2879 + // internal CPT, a WooCommerce product feed). Only the flags the
2880 + // caller actually named change anything.
2881 + $settings['sitemap_urls'] = $inclusions_supplied
2882 + ? $this->apply_inclusion_flags_to_children($saved_children, $named_inclusions, $inclusions)
2883 + : $saved_children;
2884 +
2885 + // The children are resolved either way — including when the
2886 + // caller switched the last one off, which leaves a bare index and
2887 + // is what they asked for.
2888 + $has_children = true;
2889 + }
2890 + }
2891 +
1484 2892 if (!empty($settings['use_sitemap_index'])) {
1485 2893 if (!$has_children) {
1486 2894 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
1487 2895 }
@@ -1538,26 +2946,9 @@
1538 2946 * @param array $settings Sitemap settings.
1539 2947 * @return string Sitemap filename.
1540 2948 */
1541 2949 public function get_primary_sitemap_filename(array $settings): string {
1542 - $use_index = !empty($settings['use_sitemap_index']);
1543 -
1544 - foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
1545 - if (empty($config['enabled']) || empty($config['url'])) {
1546 - continue;
1547 - }
1548 -
1549 - if ($use_index !== (($config['type'] ?? '') === 'index')) {
1550 - continue;
1551 - }
1552 -
1553 - $path = wp_parse_url($config['url'], PHP_URL_PATH);
1554 - if (!empty($path)) {
1555 - return basename($path);
1556 - }
1557 - }
1558 -
1559 - return $use_index ? 'sitemap_index.xml' : 'sitemap.xml';
2950 + return thinkrank_webroot_primary_sitemap_filename($settings);
1560 2951 }
1561 2952
1562 2953 /**
1563 2954 * Public URL of the sitemap the site serves.
@@ -1599,8 +2990,18 @@
1599 2990 // File validation failed - error details available in exception
1600 2991 return false;
1601 2992 }
1602 2993
2994 + // Dynamic delivery: hand the document to the collector instead of the
2995 + // filesystem. Reported as published, because for this run it is — the
2996 + // caller's success/failure bookkeeping and the index assembly both key
2997 + // off this return value.
2998 + if ($this->document_sink !== null) {
2999 + ($this->document_sink)($filename, $sitemap_xml);
3000 +
3001 + return true;
3002 + }
3003 +
1603 3004 $sitemap_path = ABSPATH . $filename;
1604 3005
1605 3006 // Use WordPress filesystem API for better security
1606 3007 global $wp_filesystem;
@@ -1609,9 +3010,22 @@
1609 3010 WP_Filesystem();
1610 3011 }
1611 3012
1612 3013 if ($wp_filesystem) {
1613 - return $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3014 + $written = $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3015 +
3016 + if ($written) {
3017 + // Every sitemap this version writes carries the ownership
3018 + // marker, so once one has been written an unmarked file at one
3019 + // of our names cannot be ours. Recording that retires the
3020 + // legacy fallback for this install — see
3021 + // thinkrank_webroot_sitemap_is_ours().
3022 + if (get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1') {
3023 + update_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION, '1', false);
3024 + }
3025 + }
3026 +
3027 + return $written;
1614 3028 }
1615 3029
1616 3030 // WP_Filesystem initialization failed
1617 3031 return false;
@@ -1634,52 +3048,164 @@
1634 3048 */
1635 3049 public function build_segmented_sitemap_urls(array $inclusions): array {
1636 3050 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
1637 3051
1638 - $entry = static function (string $url, string $type): array {
1639 - return [
1640 - 'url' => $url,
1641 - 'type' => $type,
1642 - 'enabled' => true,
1643 - 'last_checked' => null,
1644 - 'status' => 'unknown',
1645 - ];
1646 - };
1647 - $child = static function (string $type) use ($pattern, $entry): array {
1648 - $file = str_replace('{type}', $type, $pattern);
1649 - if (strpos($file, '/') !== 0) {
1650 - $file = '/' . $file;
3052 + $urls = [$this->sitemap_child_entry('/sitemap_index.xml', 'index')];
3053 +
3054 + foreach (self::INCLUSION_CHILD_TYPES as $flag => $type) {
3055 + if (!empty($inclusions[$flag])) {
3056 + $urls[] = $this->build_child_sitemap_entry($type, $pattern);
1651 3057 }
1652 - return $entry($file, $type);
1653 - };
1654 -
1655 - $urls = [$entry('/sitemap_index.xml', 'index')];
1656 -
1657 - if (!empty($inclusions['include_posts'])) {
1658 - $urls[] = $child('posts');
1659 3058 }
1660 - if (!empty($inclusions['include_pages'])) {
1661 - $urls[] = $child('pages');
1662 - }
1663 - if (!empty($inclusions['include_categories'])) {
1664 - $urls[] = $child('categories');
1665 - }
1666 - if (!empty($inclusions['include_tags'])) {
1667 - $urls[] = $child('tags');
1668 - }
1669 3059
1670 3060 // Public custom post types each get a child sitemap (parity with the
1671 3061 // "complete" preset and with Rank Math, which lists every public CPT).
1672 3062 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
1673 - if ($this->should_include_post_type($cpt)) {
1674 - $urls[] = $child($cpt);
3063 + if (!$this->should_include_post_type($cpt)) {
3064 + continue;
1675 3065 }
3066 +
3067 + // ...and the per-content-type sitemap switch (#660).
3068 + // get_enabled_post_types() already honours it, but this list is what
3069 + // index mode builds its children from — so without the same test a
3070 + // CPT the user had switched off still got its own child sitemap,
3071 + // created and streamed in full. The flags live in $inclusions, which
3072 + // is the settings array these children are derived from.
3073 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $cpt, $inclusions)) {
3074 + continue;
3075 + }
3076 +
3077 + $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
1676 3078 }
1677 3079
3080 + // Public custom taxonomies get the same treatment (#690). Flat mode has
3081 + // always walked them through get_enabled_taxonomies(); index mode built
3082 + // its children from the list above and never consulted a taxonomy at
3083 + // all, so every custom-taxonomy archive silently vanished from the
3084 + // sitemap the moment a site switched modes — and the per-taxonomy switch
3085 + // the matrix writes had nothing to act on. Same two tests the post-type
3086 + // walk applies, in the same order.
3087 + $taken = array_column($urls, 'type');
3088 +
3089 + foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
3090 + if (!$this->should_include_taxonomy($taxonomy)) {
3091 + continue;
3092 + }
3093 +
3094 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $inclusions)) {
3095 + continue;
3096 + }
3097 +
3098 + // Post types and taxonomies are separate registries, so a site can
3099 + // hold both a `foo` post type and a `foo` taxonomy. They would
3100 + // resolve to one filename, and stream_type_entries() answers post
3101 + // types first, so the second child would list the first one's file
3102 + // twice in the index rather than adding anything.
3103 + if (in_array($taxonomy, $taken, true)) {
3104 + continue;
3105 + }
3106 +
3107 + $urls[] = $this->build_child_sitemap_entry($taxonomy, $pattern);
3108 + }
3109 +
1678 3110 return $urls;
1679 3111 }
1680 3112
1681 3113 /**
3114 + * Apply only the inclusion flags the caller named to an existing child list.
3115 + *
3116 + * The narrow counterpart to build_segmented_sitemap_urls(): that one
3117 + * regenerates the whole set from scratch, which is right when there is no set
3118 + * yet and wrong when there is. Rebuilding an existing list would add a child
3119 + * for every public custom post type it had never contained, so a payload that
3120 + * switches one thing off would switch others on. Here a flag adds or removes
3121 + * exactly its own child and leaves every other entry — custom post types,
3122 + * hand-added URLs, per-child enabled/status state — untouched (#398).
3123 + *
3124 + * @since 1.31.0
3125 + *
3126 + * @param array $children Existing child sitemap entries.
3127 + * @param string[] $named_inclusions Inclusion flag keys present in the payload.
3128 + * @param array $inclusions Merged settings, for the resolved flag
3129 + * values and custom_url_pattern.
3130 + * @return array Updated child sitemap entries.
3131 + */
3132 + private function apply_inclusion_flags_to_children(
3133 + array $children,
3134 + array $named_inclusions,
3135 + array $inclusions
3136 + ): array {
3137 + $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
3138 +
3139 + foreach ($named_inclusions as $flag) {
3140 + $type = self::INCLUSION_CHILD_TYPES[$flag];
3141 +
3142 + $present = false;
3143 + foreach ($children as $entry) {
3144 + if (($entry['type'] ?? '') === $type) {
3145 + $present = true;
3146 + break;
3147 + }
3148 + }
3149 +
3150 + if (empty($inclusions[$flag])) {
3151 + if ($present) {
3152 + $children = array_values(array_filter(
3153 + $children,
3154 + static function ($entry) use ($type): bool {
3155 + return (is_array($entry) ? ($entry['type'] ?? '') : '') !== $type;
3156 + }
3157 + ));
3158 + }
3159 + continue;
3160 + }
3161 +
3162 + if (!$present) {
3163 + $children[] = $this->build_child_sitemap_entry($type, $pattern);
3164 + }
3165 + }
3166 +
3167 + return $children;
3168 + }
3169 +
3170 + /**
3171 + * Build one child sitemap entry, resolving its filename from the url pattern.
3172 + *
3173 + * @since 1.31.0
3174 + *
3175 + * @param string $type Child sitemap type (posts, pages, a post type name).
3176 + * @param string $pattern Filename pattern containing {type}.
3177 + * @return array Sitemap URL config.
3178 + */
3179 + private function build_child_sitemap_entry(string $type, string $pattern): array {
3180 + $file = str_replace('{type}', $type, $pattern);
3181 + if (strpos($file, '/') !== 0) {
3182 + $file = '/' . $file;
3183 + }
3184 +
3185 + return $this->sitemap_child_entry($file, $type);
3186 + }
3187 +
3188 + /**
3189 + * The shape generate_multiple_sitemaps() expects of a sitemap_urls entry.
3190 + *
3191 + * @since 1.31.0
3192 + *
3193 + * @param string $url Sitemap path.
3194 + * @param string $type Entry type.
3195 + * @return array Sitemap URL config.
3196 + */
3197 + private function sitemap_child_entry(string $url, string $type): array {
3198 + return [
3199 + 'url' => $url,
3200 + 'type' => $type,
3201 + 'enabled' => true,
3202 + 'last_checked' => null,
3203 + 'status' => 'unknown',
3204 + ];
3205 + }
3206 +
3207 + /**
1682 3208 * Generate multiple sitemaps based on settings
1683 3209 *
1684 3210 * @since 1.0.0
1685 3211 * @param array $settings Sitemap settings
@@ -1728,8 +3254,48 @@
1728 3254 if (post_type_exists($type) && !$this->should_include_post_type($type)) {
1729 3255 continue;
1730 3256 }
1731 3257
3258 + // Same for the matrix switch: a child list saved before the user
3259 + // excluded this content type still names it, and regenerating from
3260 + // that list would rewrite the file they asked not to have. Built-in
3261 + // aggregates ('posts', 'pages', ...) are not post type names, so
3262 + // post_type_exists() keeps this to real custom post types.
3263 + if (post_type_exists($type)
3264 + && !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $type, $settings)) {
3265 + continue;
3266 + }
3267 +
3268 + // The taxonomy counterpart of the two guards above (#690). A child
3269 + // list saved while a taxonomy was still included keeps naming it, so
3270 + // without this, excluding one in the matrix would still rewrite and
3271 + // re-list the file the user asked not to have. The built-in
3272 + // aggregates are named 'categories'/'tags' rather than
3273 + // 'category'/'post_tag', so taxonomy_exists() leaves them to the
3274 + // inclusion-flag check below.
3275 + $child_taxonomy = self::CHILD_TYPE_ALIASES[$type] ?? $type;
3276 + if (taxonomy_exists($child_taxonomy)
3277 + && (!$this->should_include_taxonomy($child_taxonomy)
3278 + || !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $child_taxonomy, $settings))) {
3279 + continue;
3280 + }
3281 +
3282 + // The built-in aggregates carry their switch in the inclusion flag
3283 + // rather than under a post type name, and they are the four most
3284 + // people actually use. build_segmented_sitemap_urls() drops a child
3285 + // whose flag is empty; regenerating from a list saved while it was
3286 + // still on has to make the same decision, or turning Posts, Pages,
3287 + // Categories or Tags off in the matrix rewrites and re-lists the
3288 + // very file it was asked to remove.
3289 + // An absent flag means "not configured", which every other reader
3290 + // treats as included; only a flag that is present and off excludes.
3291 + $aggregate_flag = array_search($type, self::INCLUSION_CHILD_TYPES, true);
3292 + if ($aggregate_flag !== false
3293 + && array_key_exists($aggregate_flag, $settings)
3294 + && empty($settings[$aggregate_flag])) {
3295 + continue;
3296 + }
3297 +
1732 3298 try {
1733 3299 // The single "general"/"WordPress" sitemap is one un-paginated file
1734 3300 // (there is no index to reference extra pages); it no longer drops
1735 3301 // overflow URLs.
@@ -1774,9 +3340,9 @@
1774 3340 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
1775 3341 }
1776 3342
1777 3343 // Remove pages left over from a previous, larger generation.
1778 - $this->cleanup_stale_pages($sitemap_config['url'], $page);
3344 + $this->cleanup_stale_pages($sitemap_config['url'], $page, $settings);
1779 3345
1780 3346 } catch (\Exception $e) {
1781 3347 $results['errors'][] = "Error generating {$type} sitemap: " . $e->getMessage();
1782 3348 $results['success'] = false;
@@ -1795,10 +3361,16 @@
1795 3361 $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
1796 3362 $results['success'] = false;
1797 3363 }
1798 3364
1799 - // Build the index last, from the child files actually generated.
3365 + // Build the index last, from the child files actually generated, plus the
3366 + // sitemaps other plugins own: those serve their own URLs and write no
3367 + // file here, so they are appended to the index only (#104).
1800 3368 if ($index_config !== null) {
3369 + foreach (self::additional_sitemaps() as $extra) {
3370 + $index_children[] = ['url' => $extra];
3371 + }
3372 +
1801 3373 try {
1802 3374 $index_xml = $this->generate_sitemap_index($index_children, $settings);
1803 3375 $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
1804 3376
@@ -1818,12 +3390,111 @@
1818 3390 $results['success'] = false;
1819 3391 }
1820 3392 }
1821 3393
3394 + // Remove segments that are no longer part of the set. Publishing was
3395 + // purely additive: a type that dropped out (Categories unticked, a CPT
3396 + // that stopped qualifying) simply stopped being overwritten, so its file
3397 + // kept serving and — until the index happened to be rebuilt — kept being
3398 + // listed in it. The index above is built from the children actually
3399 + // generated, so pruning here leaves disk and index agreeing.
3400 + $this->prune_orphaned_segments($settings, $results['sitemaps_generated']);
3401 +
1822 3402 return $results;
1823 3403 }
1824 3404
1825 3405 /**
3406 + * Delete published segment files that this run did not write.
3407 + *
3408 + * Only filenames this site could have published under its own url pattern
3409 + * are considered, so another plugin's or core's sitemap in the web root is
3410 + * never a candidate — the same reason cleanup does not glob 'sitemap-*.xml'.
3411 + *
3412 + * @since 1.31.0
3413 + *
3414 + * @param array $settings Sitemap settings (read for `custom_url_pattern`).
3415 + * @param array $generated Entries from $results['sitemaps_generated'].
3416 + * @return string[] Basenames removed.
3417 + */
3418 + private function prune_orphaned_segments(array $settings, array $generated): array {
3419 + // Rendering for a request, not publishing: there is nothing on disk
3420 + // this run owns, and a dynamic render must never delete the files a
3421 + // site's previous static mode left behind.
3422 + if ($this->is_collecting()) {
3423 + return [];
3424 + }
3425 +
3426 + $kept = [];
3427 + foreach ($generated as $entry) {
3428 + if (!empty($entry['filename'])) {
3429 + $kept[strtolower((string) $entry['filename'])] = true;
3430 + }
3431 + }
3432 +
3433 + // The current mode's primary and the local business sitemap are written
3434 + // by their own paths and are never orphans here.
3435 + $primary = strtolower(basename($this->get_primary_sitemap_filename($settings)));
3436 + $kept[$primary] = true;
3437 + $kept['local-sitemap.xml'] = true;
3438 +
3439 + // The OTHER mode's primary is an orphan the moment the mode changes:
3440 + // index mode leaves sitemap.xml behind, flat mode leaves
3441 + // sitemap_index.xml and its children. Both used to be kept
3442 + // unconditionally, so the site served two sitemap trees and only ever
3443 + // refreshed one (#563). The children are already covered by the segment
3444 + // sweep below, which now sees them because the index is no longer kept.
3445 + $stale_primaries = array_diff(['sitemap.xml', 'sitemap_index.xml'], [$primary]);
3446 +
3447 + $removed = [];
3448 +
3449 + global $wp_filesystem;
3450 + if (!$wp_filesystem) {
3451 + require_once ABSPATH . 'wp-admin/includes/file.php';
3452 + WP_Filesystem();
3453 + }
3454 + if (!$wp_filesystem) {
3455 + return $removed;
3456 + }
3457 +
3458 + $candidates = array_merge($this->publishable_segment_filenames($settings), $stale_primaries);
3459 +
3460 + foreach ($candidates as $candidate) {
3461 + if (isset($kept[strtolower($candidate)])) {
3462 + continue;
3463 + }
3464 +
3465 + if (!preg_match('/^(.*)\.xml$/i', $candidate, $m)) {
3466 + continue;
3467 + }
3468 +
3469 + // The base file plus its numeric pagination pages.
3470 + $paths = [ABSPATH . $candidate];
3471 + foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
3472 + if (preg_match('/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i', basename($paged))) {
3473 + $paths[] = $paged;
3474 + }
3475 + }
3476 +
3477 + foreach ($paths as $path) {
3478 + if (!file_exists($path)) {
3479 + continue;
3480 + }
3481 + // A name we could have published is not proof we published
3482 + // this file: RankMath and core write at the same paths (#515).
3483 + if (!$this->webroot_sitemap_is_ours($path, $settings)) {
3484 + continue;
3485 + }
3486 + if ($wp_filesystem->delete($path)) {
3487 + $removed[] = basename($path);
3488 + }
3489 + }
3490 + }
3491 +
3492 + return $removed;
3493 + }
3494 +
3495 +
3496 + /**
1826 3497 * Collect the full (un-paginated) entry list for a content sitemap type.
1827 3498 *
1828 3499 * @since 1.14.0
1829 3500 *
@@ -1848,11 +3519,15 @@
1848 3519 $settings = $settings ?? $this->get_settings('site');
1849 3520 $entries = $this->collect_local_entries();
1850 3521
1851 3522 if (empty($entries)) {
1852 - // Business identity was cleared — drop any file left from before.
3523 + // Business identity was cleared — drop the file we left from
3524 + // before, but only ours. `local-sitemap.xml` is the name Rank Math
3525 + // publishes under too (this method mirrors it deliberately), so on
3526 + // a migrated site the file at that path may never have been ours
3527 + // to delete (#515).
1853 3528 $path = ABSPATH . 'local-sitemap.xml';
1854 - if (file_exists($path)) {
3529 + if (!$this->is_collecting() && file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
1855 3530 wp_delete_file($path);
1856 3531 }
1857 3532 return false;
1858 3533 }
@@ -1874,8 +3549,25 @@
1874 3549 *
1875 3550 * @since 1.15.x
1876 3551 * @return array Zero or one URL entry
1877 3552 */
3553 + /**
3554 + * Does this site publish a local business sitemap right now?
3555 + *
3556 + * The same gate {@see self::regenerate_local_sitemap()} applies, asked
3557 + * without writing anything. Callers that need to know whether the document
3558 + * exists must not test the filesystem: under dynamic delivery it is served
3559 + * from PHP and there is no file, which is how `local-sitemap.xml` came to be
3560 + * dropped from robots.txt on exactly those sites (#752).
3561 + *
3562 + * @since 2.9.0
3563 + *
3564 + * @return bool True when the local sitemap has content to publish.
3565 + */
3566 + public function publishes_local_sitemap(): bool {
3567 + return !empty($this->collect_local_entries());
3568 + }
3569 +
1878 3570 private function collect_local_entries(): array {
1879 3571 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
1880 3572 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
1881 3573 }
@@ -1947,17 +3639,43 @@
1947 3639 case 'products':
1948 3640 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
1949 3641 return ['entries' => $entries, 'image_ns' => true];
1950 3642
1951 - case 'product_categories':
1952 - $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
1953 - return ['entries' => $entries, 'image_ns' => false];
3643 + default:
3644 + // Resolve a preset's display name to the object it streams, so
3645 + // 'product_categories' is an ordinary taxonomy child rather than
3646 + // a case of its own (#690).
3647 + $alias = self::CHILD_TYPE_ALIASES[$type] ?? null;
3648 + $object = $alias ?? $type;
1954 3649
1955 - default:
1956 3650 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
1957 - if (in_array($type, $custom_post_types, true)) {
1958 - return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
3651 + if (in_array($object, $custom_post_types, true)) {
3652 + return ['entries' => $this->collect_post_entries_iter([$object], $settings), 'image_ns' => true];
1959 3653 }
3654 +
3655 + // Custom taxonomies reach index mode here, streamed through the
3656 + // same iterator flat mode uses so the two modes emit identical
3657 + // URLs for the same settings.
3658 + if (taxonomy_exists($object) && $this->should_include_taxonomy($object)) {
3659 + return ['entries' => $this->collect_taxonomy_entries_iter($object, $settings), 'image_ns' => false];
3660 + }
3661 +
3662 + // An aliased child whose object is gone (WooCommerce deactivated)
3663 + // keeps writing the empty file it always wrote. Returning null
3664 + // here would hand it the whole-site fallback instead, dumping
3665 + // every URL on the site into a file named for products.
3666 + //
3667 + // A registered taxonomy this generator will not emit
3668 + // (`post_format`, `nav_menu`, a non-public one) needs the same
3669 + // answer for the same reason. generate_multiple_sitemaps()
3670 + // skips those before they reach here, so nothing takes this
3671 + // path today — but it is the one branch where falling through
3672 + // to null is silently catastrophic rather than merely wrong,
3673 + // and the guard keeping it unreachable lives in another method.
3674 + if ($alias !== null || taxonomy_exists($object)) {
3675 + return ['entries' => [], 'image_ns' => false];
3676 + }
3677 +
1960 3678 return null;
1961 3679 }
1962 3680 }
1963 3681
@@ -2000,13 +3718,23 @@
2000 3718 * which has no -N suffix) is never touched.
2001 3719 *
2002 3720 * @since 1.14.0
2003 3721 *
3722 + * @since 2.1.1 Each candidate must pass the content ownership test — a
3723 + * `-N.xml` page of another plugin's sitemap paginates our
3724 + * stem exactly as ours does (#515).
3725 + *
2004 3726 * @param string $base_url Base (page 1) sitemap URL
2005 3727 * @param int $current_pages Number of pages generated this run
3728 + * @param array $settings Sitemap settings, for the ownership test.
2006 3729 * @return void
2007 3730 */
2008 - private function cleanup_stale_pages(string $base_url, int $current_pages): void {
3731 + private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
3732 + // See prune_orphaned_segments(): a dynamic render deletes nothing.
3733 + if ($this->is_collecting()) {
3734 + return;
3735 + }
3736 +
2009 3737 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2010 3738 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2011 3739 return;
2012 3740 }
@@ -2023,9 +3751,11 @@
2023 3751
2024 3752 $candidates = glob(ABSPATH . $stem . '-*.xml') ?: [];
2025 3753 foreach ($candidates as $path) {
2026 3754 // Only delete numeric-suffixed pages beyond the current count.
2027 - if (preg_match('/-(\d+)\.xml$/', basename($path), $mm) && (int) $mm[1] > $current_pages) {
3755 + if (preg_match('/-(\d+)\.xml$/', basename($path), $mm)
3756 + && (int) $mm[1] > $current_pages
3757 + && $this->webroot_sitemap_is_ours($path, $settings)) {
2028 3758 $wp_filesystem->delete($path);
2029 3759 }
2030 3760 }
2031 3761 }
@@ -2030,8 +3760,97 @@
2030 3760 }
2031 3761 }
2032 3762
2033 3763 /**
3764 + * Sitemap URLs contributed by other plugins.
3765 + *
3766 + * ThinkRank owns the sitemap index and the robots.txt `Sitemap:` lines, so a
3767 + * companion plugin that serves its own sitemap — Pro's news and video
3768 + * sitemaps, for instance — had no way to be discovered: it appeared in
3769 + * neither, leaving manual Search Console submission as the only route in
3770 + * (#104). Registering here puts a sitemap in the index when one exists, and
3771 + * in robots.txt when it does not.
3772 + *
3773 + * Callers get root-relative paths. Entries are normalised to a leading
3774 + * slash, de-duplicated, and anything that is not a non-empty string is
3775 + * dropped, so one badly-behaved callback cannot produce a malformed index.
3776 + *
3777 + * @since 2.3.1
3778 + *
3779 + * @return string[] Root-relative sitemap paths, e.g. ['/news-sitemap.xml'].
3780 + */
3781 + public static function additional_sitemaps(): array {
3782 + /**
3783 + * Filters the sitemaps contributed by other plugins.
3784 + *
3785 + * @since 2.3.1
3786 + *
3787 + * @param string[] $sitemaps Root-relative sitemap paths.
3788 + */
3789 + $sitemaps = apply_filters('thinkrank_additional_sitemaps', []);
3790 +
3791 + if (!is_array($sitemaps)) {
3792 + return [];
3793 + }
3794 +
3795 + // Both consumers resolve an entry with home_url(), which prefixes the
3796 + // install's own directory. Everything below is measured against that so
3797 + // an absolute URL is reduced to what home_url() will put back.
3798 + $home = wp_parse_url(home_url('/'));
3799 + $home_host = strtolower((string) ($home['host'] ?? ''));
3800 + $home_path = '/' . trim((string) ($home['path'] ?? ''), '/');
3801 +
3802 + $clean = [];
3803 + foreach ($sitemaps as $sitemap) {
3804 + if (!is_string($sitemap)) {
3805 + continue;
3806 + }
3807 +
3808 + $sitemap = trim($sitemap);
3809 + if ('' === $sitemap) {
3810 + continue;
3811 + }
3812 +
3813 + // A full URL on this site is accepted and reduced to the part
3814 + // home_url() does not already supply, so a caller that reached for
3815 + // home_url() still lands in the right place — including on a
3816 + // subdirectory install, where keeping the whole path would repeat
3817 + // the directory. A URL on another host is dropped rather than
3818 + // rewritten: the sitemaps protocol will not accept a cross-host
3819 + // child anyway, and reusing its path would advertise a URL on this
3820 + // site that does not exist.
3821 + if (preg_match('#^(https?:)?//#i', $sitemap)) {
3822 + $parts = wp_parse_url('//' === substr($sitemap, 0, 2) ? 'https:' . $sitemap : $sitemap);
3823 + if (!is_array($parts)) {
3824 + continue;
3825 + }
3826 +
3827 + if (strtolower((string) ($parts['host'] ?? '')) !== $home_host) {
3828 + continue;
3829 + }
3830 +
3831 + $path = (string) ($parts['path'] ?? '');
3832 + if ('' === $path) {
3833 + continue;
3834 + }
3835 +
3836 + if ('/' !== $home_path && ($path === $home_path || 0 === strpos($path, $home_path . '/'))) {
3837 + $path = substr($path, strlen($home_path));
3838 + }
3839 +
3840 + // A sitemap served from a query string keeps it; dropping the
3841 + // query would point at a different document.
3842 + $query = (string) ($parts['query'] ?? '');
3843 + $sitemap = $path . ('' !== $query ? '?' . $query : '');
3844 + }
3845 +
3846 + $clean[] = '/' . ltrim($sitemap, '/');
3847 + }
3848 +
3849 + return array_values(array_unique($clean));
3850 + }
3851 +
3852 + /**
2034 3853 * Generate sitemap index XML from the list of child sitemap files produced
2035 3854 * during generation (each already resolved to its final, possibly paginated,
2036 3855 * URL).
2037 3856 *
@@ -2040,14 +3859,9 @@
2040 3859 * @param array $settings Sitemap settings
2041 3860 * @return string Sitemap index XML
2042 3861 */
2043 3862 private function generate_sitemap_index(array $children, array $settings): string {
2044 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
2045 -
2046 - // Add XSL stylesheet only if styling is enabled
2047 - if (!empty($settings['enable_styling'])) {
2048 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap-index.xsl') . '"?>' . "\n";
2049 - }
3863 + $xml = $this->xml_prolog($settings, 'index');
2050 3864 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
2051 3865
2052 3866 $site_url = home_url();
2053 3867
@@ -2060,9 +3874,9 @@
2060 3874 $sitemap_url = $site_url . $sitemap_url;
2061 3875 }
2062 3876
2063 3877 $xml .= " <sitemap>\n";
2064 - $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
3878 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($sitemap_url)) . "</loc>\n";
2065 3879 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
2066 3880 $xml .= " </sitemap>\n";
2067 3881 }
2068 3882