PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 releases
← All changes | includes/seo/class-sitemap-generator.php +2847 -315 1.0.0 → 2.10.0 View file →
@@ -14,8 +14,20 @@
14 14 declare(strict_types=1);
15 15
16 16 namespace ThinkRank\SEO;
17 17
18 +use InvalidArgumentException;
19 +
20 +// Prevent direct access.
21 +if ( ! defined( 'ABSPATH' ) ) {
22 + exit;
23 +}
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 +
18 30 /**
19 31 * Sitemap Generator Class
20 32 *
21 33 * Generates and manages XML sitemaps for search engine optimization.
@@ -26,13 +38,221 @@
26 38 */
27 39 class Sitemap_Generator extends Abstract_SEO_Manager {
28 40
29 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 + /**
30 83 * Supported sitemap types
31 84 *
32 85 * @since 1.0.0
33 86 * @var array
34 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 +
35 255 private array $sitemap_types = [
36 256 'posts' => [
37 257 'name' => 'Posts',
38 258 'post_types' => ['post'],
@@ -62,17 +282,67 @@
62 282 /**
63 283 * Constructor
64 284 *
65 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.
66 292 */
67 - public function __construct() {
293 + public function __construct(bool $register_hooks = true) {
68 294 parent::__construct('sitemap');
69 295
70 296 // Initialize auto-generation hooks
71 - $this->init_auto_generation_hooks();
297 + if ($register_hooks) {
298 + $this->init_auto_generation_hooks();
299 + }
72 300 }
73 301
74 302 /**
303 + * Filter the args of a sitemap post query.
304 + *
305 + * Exists so integrations can widen what the sitemap sees — the multilingual
306 + * manager uses it to include every language, since these queries otherwise
307 + * run in whichever language happened to be active at generation time.
308 + *
309 + * @since 1.23.0
310 + *
311 + * @param array $args get_posts() arguments.
312 + * @return array Filtered arguments.
313 + */
314 + private function filter_query_args(array $args): array {
315 + /**
316 + * Filter the arguments of a sitemap post query.
317 + *
318 + * @since 1.23.0
319 + *
320 + * @param array $args get_posts() arguments.
321 + */
322 + return (array) apply_filters('thinkrank_sitemap_query_args', $args);
323 + }
324 +
325 + /**
326 + * Filter the args of a sitemap term query.
327 + *
328 + * @since 1.23.0
329 + *
330 + * @param array $args get_terms() arguments.
331 + * @return array Filtered arguments.
332 + */
333 + private function filter_term_query_args(array $args): array {
334 + /**
335 + * Filter the arguments of a sitemap term query.
336 + *
337 + * @since 1.23.0
338 + *
339 + * @param array $args get_terms() arguments.
340 + */
341 + return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
342 + }
343 +
344 + /**
75 345 * Initialize WordPress hooks for auto-generation
76 346 *
77 347 * @since 1.0.0
78 348 * @return void
@@ -88,10 +358,14 @@
88 358 add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
89 359 add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
90 360 add_action('delete_term', [$this, 'handle_taxonomy_change'], 20, 3);
91 361
92 - // Debounced regeneration to prevent multiple rapid-fire generations
93 - add_action('thinkrank_regenerate_sitemap', [$this, 'auto_regenerate_sitemap']);
362 + // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
363 + // and thinkrank_regenerate_sitemap_settings) are registered at plugin
364 + // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
365 + // rather than here. A cron run never builds this class via the REST
366 + // endpoint (no rest_api_init), so registering them in the constructor
367 + // would leave the scheduled events with no listener at cron time.
94 368 }
95 369
96 370 /**
97 371 * Generate XML sitemap
@@ -103,15 +377,10 @@
103 377 */
104 378 public function generate_sitemap(array $options = []): string {
105 379 $settings = $this->get_settings('site');
106 380
107 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
381 + $xml = $this->xml_prolog($settings, 'sitemap');
108 382
109 - // Add XSL stylesheet only if styling is enabled
110 - if (!empty($settings['enable_styling'])) {
111 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap.xsl') . '"?>' . "\n";
112 - }
113 -
114 383 // Add image namespace if images are enabled
115 384 if (!empty($settings['include_images'])) {
116 385 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
117 386 } else {
@@ -117,15 +386,19 @@
117 386 } else {
118 387 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
119 388 }
120 389
121 - // Add homepage
122 - $xml .= $this->generate_url_entry(home_url('/'), gmdate('c'), 1.0, 'daily');
390 + // Add homepage. Use the static front page's real modified time when set,
391 + // so lastmod reflects real changes rather than the generation time.
392 + $xml .= $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
123 393
124 - // MEMORY-EFFICIENT: Generate posts using chunked processing
394 + // MEMORY-EFFICIENT: Generate posts using chunked processing.
395 + // Note: the single "general" sitemap includes every URL (no per-file cap);
396 + // per-file chunking applies to the segmented/index model where each type
397 + // gets its own paginated file (see generate_multiple_sitemaps).
125 398 $enabled_post_types = $this->get_enabled_post_types($settings);
126 399 if (!empty($enabled_post_types)) {
127 - $xml .= $this->generate_posts_chunked($enabled_post_types, $settings);
400 + $xml .= implode('', $this->collect_post_entries($enabled_post_types, $settings));
128 401 }
129 402
130 403 // OPTIMIZED: Get all enabled taxonomies and fetch in single query
131 404 $enabled_taxonomies = $this->get_enabled_taxonomies($settings);
@@ -219,8 +492,34 @@
219 492 ];
220 493 }
221 494
222 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 + /**
223 522 * Get default settings for a context type (implements interface)
224 523 *
225 524 * @since 1.0.0
226 525 *
@@ -243,8 +542,15 @@
243 542 ]
244 543 ],
245 544 'use_sitemap_index' => false,
246 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 +
247 553 // General Settings
248 554 'links_per_sitemap' => 1000,
249 555 'include_images' => true,
250 556 'include_featured_images' => false,
@@ -266,8 +572,17 @@
266 572 // Advanced Options
267 573 'enable_styling' => true,
268 574 'custom_url_pattern' => 'sitemap-{type}.xml',
269 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 +
270 585 // Generation tracking
271 586 'last_generated' => ''
272 587 ];
273 588 }
@@ -272,8 +587,49 @@
272 587 ];
273 588 }
274 589
275 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 + /**
276 632 * Get settings schema definition (implements interface)
277 633 *
278 634 * @since 1.0.0
279 635 *
@@ -287,8 +643,15 @@
287 643 'title' => 'Enable Sitemap',
288 644 'description' => 'Generate XML sitemap for search engines',
289 645 'default' => true
290 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 + ],
291 654 'include_posts' => [
292 655 'type' => 'boolean',
293 656 'title' => 'Include Posts',
294 657 'description' => 'Include blog posts in sitemap',
@@ -329,8 +692,32 @@
329 692 'type' => 'string',
330 693 'title' => 'Last Generated',
331 694 'description' => 'Timestamp of last sitemap generation',
332 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' => ''
333 720 ]
334 721 ];
335 722 }
336 723
@@ -347,10 +734,19 @@
347 734 * @return string XML URL entry
348 735 */
349 736 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
350 737 $xml = " <url>\n";
351 - $xml .= " <loc>" . esc_url($url) . "</loc>\n";
352 - $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\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";
744 + // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
745 + // than no timestamp, and an absent lastmod is valid per the spec.
746 + if (!empty($lastmod)) {
747 + $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
748 + }
353 749 $xml .= " <priority>" . number_format($priority, 1) . "</priority>\n";
354 750 $xml .= " <changefreq>" . esc_html($changefreq) . "</changefreq>\n";
355 751
356 752 // Add image entries if provided
@@ -355,9 +751,9 @@
355 751
356 752 // Add image entries if provided
357 753 foreach ($images as $image) {
358 754 $xml .= " <image:image>\n";
359 - $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";
360 756
361 757 if (!empty($image['title'])) {
362 758 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
363 759 }
@@ -374,33 +770,62 @@
374 770 return $xml;
375 771 }
376 772
377 773 /**
378 - * Generate post entries for sitemap (MEMORY-EFFICIENT VERSION)
774 + * Collect every post <url> entry for the given post type(s) as an array of
775 + * XML strings, using memory-efficient chunked fetching.
379 776 *
380 - * @since 1.0.0
777 + * Unlike the old capped generator, this returns ALL matching entries — the
778 + * links-per-sitemap limit is applied later by paginating this array into
779 + * separate files (see generate_multiple_sitemaps), matching how Rank Math
780 + * splits large sitemaps instead of truncating them.
381 781 *
382 - * @param string $post_type Post type
383 - * @param array $settings Sitemap settings
384 - * @return string XML entries
782 + * @since 1.14.0
783 + *
784 + * @param array $post_types Array of post types to fetch
785 + * @param array $settings Sitemap settings
786 + * @return array<string> Individual <url>…</url> entry strings
385 787 */
386 - private function generate_post_entries(string $post_type, array $settings): string {
387 - // Use memory-efficient chunked processing for single post type
388 - return $this->generate_posts_chunked([$post_type], $settings);
788 + /**
789 + * lastmod for the homepage <url> entry: the static front page's real
790 + * modification time when one is set, otherwise the generation time.
791 + *
792 + * @return string ISO-8601 date.
793 + */
794 + private function get_homepage_lastmod(): string {
795 + if (get_option('show_on_front') === 'page') {
796 + $front_id = (int) get_option('page_on_front');
797 + if ($front_id) {
798 + $modified = get_post_field('post_modified_gmt', $front_id);
799 + if (!empty($modified) && $modified !== '0000-00-00 00:00:00') {
800 + return gmdate('c', strtotime($modified));
801 + }
802 + }
803 + }
804 + return gmdate('c');
389 805 }
390 806
807 + private function collect_post_entries(array $post_types, array $settings): array {
808 + // Materialize the streaming source for callers that need the full set
809 + // (e.g. the single un-paginated "general" sitemap). The paginated index
810 + // path streams collect_post_entries_iter() directly to bound memory.
811 + return iterator_to_array($this->collect_post_entries_iter($post_types, $settings), false);
812 + }
813 +
391 814 /**
392 - * Generate posts XML using memory-efficient chunked processing
815 + * Stream <url> entries for the given post types, yielding one at a time.
393 816 *
394 - * @since 1.0.0
817 + * Same chunked query, filtering, and ordering as before, but yields each
818 + * entry instead of accumulating the whole set — so the paginated index
819 + * generator never holds every URL of a large post type in memory at once.
395 820 *
396 - * @param array $post_types Array of post types to fetch
397 - * @param array $settings Sitemap settings
398 - * @return string XML entries for all posts
821 + * @param array $post_types Post types to include.
822 + * @param array $settings Sitemap settings.
823 + * @return \Generator<string> <url> entry strings.
399 824 */
400 - private function generate_posts_chunked(array $post_types, array $settings): string {
825 + private function collect_post_entries_iter(array $post_types, array $settings): \Generator {
401 826 if (empty($post_types)) {
402 - return '';
827 + return;
403 828 }
404 829
405 830 // Parse and validate exclude_posts setting
406 831 $exclude_ids = [];
@@ -411,66 +836,261 @@
411 836 // Continue with empty array on validation failure - error details available in exception
412 837 }
413 838 }
414 839
415 - // Use links_per_sitemap setting to limit posts per sitemap
416 - $links_per_sitemap = !empty($settings['links_per_sitemap']) ?
417 - intval($settings['links_per_sitemap']) : 1000;
840 + // The static front page is emitted once as the explicit homepage entry,
841 + // so exclude it here to avoid a duplicate <loc> (its permalink equals
842 + // home_url('/')).
843 + if (get_option('show_on_front') === 'page') {
844 + $front_id = (int) get_option('page_on_front');
845 + if ($front_id) {
846 + $exclude_ids[] = $front_id;
847 + }
848 + }
418 849
419 - // Ensure value is within reasonable bounds
420 - $links_per_sitemap = max(1, min(50000, $links_per_sitemap));
850 + // Resolve the ordered ID list in one indexed query, then hydrate in
851 + // chunks via post__in. This avoids large OFFSET windows (which MySQL
852 + // must scan-and-discard, making a full walk O(n^2)) while still loading
853 + // only one chunk of full post objects into memory at a time. The
854 + // original order is preserved (the id query and post__in hydration both
855 + // use it).
856 + $all_ids = get_posts($this->filter_query_args([
857 + 'post_type' => $post_types,
858 + 'post_status' => 'publish',
859 + 'numberposts' => -1,
860 + 'exclude' => $exclude_ids,
861 + 'orderby' => 'post_type post_date',
862 + 'order' => 'ASC DESC',
863 + 'fields' => 'ids',
864 + ]));
421 865
422 - $xml = '';
423 - $chunk_size = 500; // Process 500 posts at a time
424 - $offset = 0;
425 - $total_processed = 0;
866 + if (empty($all_ids)) {
867 + return;
868 + }
426 869
427 - do {
428 - // Fetch posts in chunks to prevent memory issues
429 - $posts = get_posts([
430 - 'post_type' => $post_types,
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 +
880 + $posts = get_posts($this->filter_query_args([
881 + 'post_type' => $post_types,
431 882 'post_status' => 'publish',
432 - 'numberposts' => $chunk_size,
433 - 'offset' => $offset,
434 - 'exclude' => $exclude_ids,
435 - 'orderby' => 'post_type post_date',
436 - 'order' => 'ASC DESC'
437 - ]);
883 + 'numberposts' => count($chunk),
884 + 'post__in' => $chunk,
885 + 'orderby' => 'post__in', // preserve the resolved order
886 + ]));
438 887
439 - // Process each post in the chunk
440 888 foreach ($posts as $post) {
441 - if ($total_processed >= $links_per_sitemap) {
442 - break 2; // Exit both loops when limit reached
443 - }
444 -
445 889 if ($this->should_include_in_sitemap($post, $settings)) {
446 - $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);
447 905 $lastmod = gmdate('c', strtotime($post->post_modified_gmt));
448 906 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
449 907 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
450 908 $images = $this->extract_post_images($post, $settings);
451 909
452 - $xml .= $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
453 - $total_processed++;
910 + yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
454 911 }
455 912 }
456 913
457 - $offset += $chunk_size;
914 + // Free the hydrated chunk before loading the next one.
915 + unset($posts);
916 + }
917 + }
458 918
459 - // Store count before freeing memory
460 - $posts_count = count($posts);
919 + /**
920 + * Resolve and bound the configured links-per-sitemap limit.
921 + *
922 + * @since 1.14.0
923 + *
924 + * @param array $settings Sitemap settings
925 + * @return int Links per sitemap file (1–50000)
926 + */
927 + private function get_links_per_sitemap(array $settings): int {
928 + $limit = !empty($settings['links_per_sitemap']) ? intval($settings['links_per_sitemap']) : 1000;
461 929
462 - // Free memory after each chunk
463 - unset($posts);
930 + return max(1, min(50000, $limit));
931 + }
464 932
465 - } while ($posts_count === $chunk_size && $total_processed < $links_per_sitemap);
933 + /**
934 + * Stream <url> entries for a taxonomy's terms, yielding one at a time and
935 + * fetching terms in bounded chunks (number/offset) — so the paginated index
936 + * generator never holds every term of a large taxonomy in memory at once.
937 + *
938 + * @param string $taxonomy Taxonomy name.
939 + * @param array $settings Sitemap settings.
940 + * @return \Generator<string> <url> entry strings.
941 + */
942 + private function collect_taxonomy_entries_iter(string $taxonomy, array $settings): \Generator {
943 + $exclude_term_ids = [];
944 + if (!empty($settings['exclude_terms'])) {
945 + try {
946 + $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
947 + } catch (InvalidArgumentException $e) {
948 + // Continue with an empty exclude list on validation failure.
949 + }
950 + }
466 951
952 + // product_cat may legitimately have empty terms (products added later);
953 + // every other taxonomy hides empties — matching fetch_taxonomies_optimized().
954 + $hide_empty = $taxonomy !== 'product_cat';
955 + $priority = $taxonomy === 'category' ? 0.6 : 0.4;
956 +
957 + // Resolve the ordered term IDs in one query, then hydrate in chunks via
958 + // include. Avoids large OFFSET windows (O(n^2) over a full walk) while
959 + // holding only one chunk of full term objects at a time.
960 + $all_ids = get_terms($this->filter_term_query_args([
961 + 'taxonomy' => $taxonomy,
962 + 'hide_empty' => $hide_empty,
963 + 'exclude' => $exclude_term_ids,
964 + 'orderby' => 'count',
965 + 'order' => 'DESC',
966 + 'fields' => 'ids',
967 + ]));
968 +
969 + if (is_wp_error($all_ids) || empty($all_ids)) {
970 + return;
971 + }
972 +
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 +
979 + $terms = get_terms($this->filter_term_query_args([
980 + 'taxonomy' => $taxonomy,
981 + 'include' => $chunk,
982 + 'orderby' => 'include', // preserve the resolved order
983 + 'hide_empty' => false, // already filtered by the id query
984 + ]));
985 +
986 + if (is_wp_error($terms) || empty($terms)) {
987 + continue;
988 + }
989 +
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 +
998 + $url = get_term_link($term);
999 + if (!is_wp_error($url)) {
1000 + // Omit lastmod for terms — the generation time is not a real
1001 + // modification time and would mislabel every term as just-changed.
1002 + yield $this->generate_url_entry($url, '', $priority, 'weekly');
1003 + }
1004 + }
1005 +
1006 + unset($terms);
1007 + }
1008 + }
1009 +
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 +
467 1042 return $xml;
468 1043 }
469 1044
1045 + /**
1046 + * Wrap a set of <url> entry strings in a complete <urlset> document.
1047 + *
1048 + * @since 1.14.0
1049 + *
1050 + * @param array<string> $entries Entry strings
1051 + * @param array $settings Sitemap settings
1052 + * @param bool $with_image_ns Include the image sitemap namespace
1053 + * @return string Full sitemap XML
1054 + */
1055 + private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
1056 + $xml = $this->xml_prolog($settings, 'sitemap');
470 1057
1058 + if ($with_image_ns && !empty($settings['include_images'])) {
1059 + $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
1060 + } else {
1061 + $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1062 + }
471 1063
1064 + $xml .= implode('', $entries);
1065 + $xml .= '</urlset>';
1066 +
1067 + return $xml;
1068 + }
1069 +
472 1070 /**
1071 + * Derive the filename/URL for a given pagination page.
1072 + *
1073 + * Page 1 keeps the base URL (e.g. /sitemap-posts.xml); pages 2+ insert the
1074 + * page number before the extension (e.g. /sitemap-posts-2.xml).
1075 + *
1076 + * @since 1.14.0
1077 + *
1078 + * @param string $url Base sitemap URL
1079 + * @param int $page 1-based page number
1080 + * @return string Paginated URL
1081 + */
1082 + private function paginate_url(string $url, int $page): string {
1083 + if ($page <= 1) {
1084 + return $url;
1085 + }
1086 +
1087 + return (string) preg_replace('/\.xml$/i', '-' . $page . '.xml', $url);
1088 + }
1089 +
1090 +
1091 +
1092 + /**
473 1093 * Extract images from post for sitemap
474 1094 *
475 1095 * @since 1.0.0
476 1096 *
@@ -501,9 +1121,9 @@
501 1121 // Remove duplicates based on URL
502 1122 $unique_images = [];
503 1123 $seen_urls = [];
504 1124 foreach ($images as $image) {
505 - if (!in_array($image['url'], $seen_urls)) {
1125 + if (!in_array($image['url'], $seen_urls, true)) {
506 1126 $unique_images[] = $image;
507 1127 $seen_urls[] = $image['url'];
508 1128 }
509 1129 }
@@ -587,28 +1207,8 @@
587 1207 return $images;
588 1208 }
589 1209
590 1210 /**
591 - * Generate taxonomy entries for sitemap (OPTIMIZED VERSION)
592 - *
593 - * @since 1.0.0
594 - *
595 - * @param string $taxonomy Taxonomy name
596 - * @param array $settings Sitemap settings
597 - * @return string XML entries
598 - */
599 - private function generate_taxonomy_entries(string $taxonomy, array $settings): string {
600 - // Use optimized batch fetching for single taxonomy
601 - $taxonomy_data = $this->fetch_taxonomies_optimized([$taxonomy], $settings);
602 -
603 - if (empty($taxonomy_data[$taxonomy])) {
604 - return '';
605 - }
606 -
607 - return $this->process_taxonomies_to_xml($taxonomy_data[$taxonomy], $taxonomy, $settings);
608 - }
609 -
610 - /**
611 1211 * Get enabled taxonomies based on settings
612 1212 *
613 1213 * @since 1.0.0
614 1214 * @param array $settings Sitemap settings
@@ -632,9 +1232,16 @@
632 1232 '_builtin' => false
633 1233 ], 'names');
634 1234
635 1235 foreach ($custom_taxonomies as $taxonomy) {
636 - 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)) {
637 1244 $taxonomies[] = $taxonomy;
638 1245 }
639 1246 }
640 1247
@@ -677,20 +1284,28 @@
677 1284 }
678 1285 }
679 1286
680 1287 // OPTIMIZED: Single query for multiple taxonomies
681 - $all_terms = get_terms([
1288 + $all_terms = get_terms($this->filter_term_query_args([
682 1289 'taxonomy' => $taxonomies, // ✅ Multiple taxonomies in single query
683 1290 'hide_empty' => $hide_empty,
684 1291 'exclude' => $exclude_term_ids,
685 1292 'orderby' => 'taxonomy count',
686 1293 'order' => 'ASC DESC' // Order by taxonomy ASC, then count DESC
687 - ]);
1294 + ]));
688 1295
689 1296 if (is_wp_error($all_terms)) {
690 1297 return [];
691 1298 }
692 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 +
693 1308 // Group terms by taxonomy
694 1309 return $this->group_terms_by_taxonomy($all_terms);
695 1310 }
696 1311
@@ -733,9 +1348,11 @@
733 1348
734 1349 foreach ($terms as $term) {
735 1350 $url = get_term_link($term);
736 1351 if (!is_wp_error($url)) {
737 - $lastmod = gmdate('c');
1352 + // Omit lastmod for terms (generation time is not a real
1353 + // modification time).
1354 + $lastmod = '';
738 1355 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
739 1356 $changefreq = 'weekly';
740 1357
741 1358 $xml .= $this->generate_url_entry($url, $lastmod, $priority, $changefreq);
@@ -778,12 +1395,12 @@
778 1395
779 1396 // Special page types get higher priority
780 1397 if ($post_type === 'page') {
781 1398 $page_template = get_page_template_slug($post->ID);
782 - if (in_array($page_template, ['page-home.php', 'front-page.php']) ||
783 - $post->ID == get_option('page_on_front')) {
1399 + if (in_array($page_template, ['page-home.php', 'front-page.php'], true) ||
1400 + (int) $post->ID === (int) get_option('page_on_front')) {
784 1401 $base_priority = 1.0; // Homepage gets maximum priority
785 - } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'])) {
1402 + } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
786 1403 $adjustments += 0.05; // Important pages boost
787 1404 }
788 1405 }
789 1406
@@ -805,11 +1422,11 @@
805 1422 private function calculate_change_frequency(\WP_Post $post, string $post_type): string {
806 1423 // Pages typically change less frequently
807 1424 if ($post_type === 'page') {
808 1425 $page_template = get_page_template_slug($post->ID);
809 - if ($post->ID == get_option('page_on_front')) {
1426 + if ((int) $post->ID === (int) get_option('page_on_front')) {
810 1427 return 'daily'; // Homepage changes frequently
811 - } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'])) {
1428 + } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
812 1429 return 'monthly'; // Static pages change monthly
813 1430 }
814 1431 return 'yearly'; // Other pages change rarely
815 1432 }
@@ -837,8 +1454,17 @@
837 1454 * @param array $settings Sitemap settings
838 1455 * @return bool Whether to include in sitemap
839 1456 */
840 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 +
841 1467 // Respect user setting for password protected content
842 1468 if (!empty($post->post_password) && !empty($settings['exclude_password_protected'])) {
843 1469 return false;
844 1470 }
@@ -847,46 +1473,93 @@
847 1473 if ($post->post_status === 'private' && !empty($settings['exclude_private_posts'])) {
848 1474 return false;
849 1475 }
850 1476
851 - // Skip very short content (likely test/placeholder)
852 - $content_length = strlen(wp_strip_all_tags($post->post_content));
853 - if ($content_length < 100) {
1477 + // Only published (and, per setting, private) content belongs in the
1478 + // sitemap. Content-quality heuristics (length, "demo"/"test"/"sample"
1479 + // in the title, "lorem ipsum" text) were intentionally removed: an XML
1480 + // sitemap should list every indexable published URL. Filtering by
1481 + // description length silently dropped legitimate WooCommerce products
1482 + // with short descriptions, and the substring title match excluded real
1483 + // pages such as "Demo" or "Product Samples". Indexability is governed
1484 + // by noindex directives below, not by heuristics.
1485 + if (!in_array($post->post_status, ['publish', 'private'], true)) {
854 1486 return false;
855 1487 }
856 1488
857 - // Skip posts with test/demo indicators in title (refined filtering)
858 - $title_indicators = ['test', 'demo', 'sample', 'placeholder'];
859 - $title_lower = strtolower($post->post_title);
1489 + // Check if post overrides robots and sets noindex.
1490 + if ((bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true)) {
1491 + $raw = get_post_meta($post->ID, '_thinkrank_robots_meta', true);
1492 + if (is_string($raw) && $raw !== '') {
1493 + $robots = json_decode($raw, true);
1494 + if (is_array($robots) && !empty($robots['noindex'])) {
1495 + return false;
1496 + }
1497 + }
1498 + }
860 1499
861 - foreach ($title_indicators as $indicator) {
862 - if (strpos($title_lower, $indicator) !== false) {
863 - return false;
864 - }
1500 + return true;
1501 + }
1502 +
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;
865 1518 }
866 1519
867 - // Skip content with obvious placeholder text
868 - $content_indicators = ['lorem ipsum'];
869 - $content_lower = strtolower($post->post_content);
1520 + $ids = [];
870 1521
871 - foreach ($content_indicators as $indicator) {
872 - if (strpos($content_lower, $indicator) !== false) {
873 - return false;
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 + }
874 1529 }
875 1530 }
876 1531
877 - // Skip drafts (but allow private posts if user setting permits)
878 - if (!in_array($post->post_status, ['publish', 'private'])) {
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)) {
879 1551 return false;
880 1552 }
881 1553
882 - // Check if post is set to noindex via meta
883 - $noindex = get_post_meta($post->ID, '_thinkrank_noindex', true);
884 - if ($noindex === '1' || $noindex === 'true') {
1554 + $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1555 + if (!is_string($raw) || $raw === '') {
885 1556 return false;
886 1557 }
887 1558
888 - return true;
1559 + $robots = json_decode($raw, true);
1560 +
1561 + return is_array($robots) && !empty($robots['noindex']);
889 1562 }
890 1563
891 1564 /**
892 1565 * Count total URLs in sitemap
@@ -895,9 +1568,9 @@
895 1568 *
896 1569 * @param array $settings Sitemap settings
897 1570 * @return int Total URL count
898 1571 */
899 - private function count_sitemap_urls(array $settings): int {
1572 + public function count_sitemap_urls(array $settings): int {
900 1573 $count = 1; // Homepage
901 1574
902 1575 if (!empty($settings['include_posts'])) {
903 1576 $count += wp_count_posts('post')->publish;
@@ -1052,9 +1725,15 @@
1052 1725 if (!empty($settings['include_pages'])) {
1053 1726 $post_types[] = 'page';
1054 1727 }
1055 1728
1056 - // 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.
1057 1736 $custom_post_types = get_post_types([
1058 1737 'public' => true,
1059 1738 '_builtin' => false
1060 1739 ], 'names');
@@ -1059,9 +1738,13 @@
1059 1738 '_builtin' => false
1060 1739 ], 'names');
1061 1740
1062 1741 foreach ($custom_post_types as $post_type) {
1063 - 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)) {
1064 1747 $post_types[] = $post_type;
1065 1748 }
1066 1749 }
1067 1750
@@ -1075,11 +1758,18 @@
1075 1758 * @param string $post_type Post type
1076 1759 * @return bool True if post type should trigger regeneration
1077 1760 */
1078 1761 private function should_include_post_type(string $post_type): bool {
1079 - // Include all public post types (presets control which sitemaps are created)
1080 - $post_type_obj = get_post_type_object($post_type);
1081 - return $post_type_obj && $post_type_obj->public;
1762 + // Match Rank Math: only publicly viewable post types belong in the
1763 + // sitemap (public && publicly_queryable). Additionally skip post types
1764 + // that opt out of front-end search (exclude_from_search => true) — e.g.
1765 + // Templately's internal `templately_library` store — which are template
1766 + // records, not standalone indexable URLs. BetterDocs `docs` and
1767 + // WooCommerce `product` register exclude_from_search => false, so they
1768 + // remain included.
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);
1082 1772 }
1083 1773
1084 1774 /**
1085 1775 * Check if taxonomy should trigger regeneration
@@ -1088,11 +1778,13 @@
1088 1778 * @param string $taxonomy Taxonomy slug
1089 1779 * @return bool True if taxonomy should trigger regeneration
1090 1780 */
1091 1781 private function should_include_taxonomy(string $taxonomy): bool {
1092 - // Include all public taxonomies (presets control which sitemaps are created)
1093 - $taxonomy_obj = get_taxonomy($taxonomy);
1094 - 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);
1095 1787 }
1096 1788
1097 1789 /**
1098 1790 * Schedule debounced sitemap regeneration
@@ -1100,16 +1792,548 @@
1100 1792 * @since 1.0.0
1101 1793 * @return void
1102 1794 */
1103 1795 private function schedule_debounced_regeneration(): void {
1104 - // Clear any existing scheduled regeneration
1105 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap');
1796 + $this->mark_regeneration_pending('content');
1797 + $this->debounce_event('thinkrank_regenerate_sitemap');
1798 + }
1106 1799
1107 - // Schedule regeneration in 30 seconds to debounce rapid changes
1108 - 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);
1109 1827 }
1110 1828
1111 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 + /**
2144 + * Public entry point to debounce-rebuild the sitemap after a settings change
2145 + * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
2146 + * file reflects the new settings instead of going stale until a content edit.
2147 + *
2148 + * @return void
2149 + */
2150 + public function schedule_regeneration(): void {
2151 + // Debounce against rapid successive saves, but use the settings-specific
2152 + // hook so the rebuild runs regardless of the auto_generate toggle (which
2153 + // only governs content-change-triggered regeneration).
2154 + $this->mark_regeneration_pending('settings');
2155 + $this->debounce_event('thinkrank_regenerate_sitemap_settings');
2156 + }
2157 +
2158 + /**
2159 + * Rebuild the served sitemap after an explicit settings change.
2160 + *
2161 + * Unlike {@see auto_regenerate_sitemap()}, this is NOT gated on the
2162 + * auto_generate setting: the user deliberately changed inclusion rules and
2163 + * expects the served file to reflect them even if content-triggered
2164 + * auto-generation is turned off. Still respects the master `enabled` flag.
2165 + *
2166 + * @since 2.10.0 Reports whether the served sitemap was actually rebuilt, so
2167 + * a caller can say so rather than assume it (#764). Existing
2168 + * callers that ignore the return are unaffected.
2169 + *
2170 + * @return bool True when the served sitemap now reflects the settings.
2171 + */
2172 + public function regenerate_sitemap_from_settings(): bool {
2173 + if (!$this->acquire_generation_lock()) {
2174 + // A manual generation (or another request's takeover) is already
2175 + // writing the files; the pending marker survives so this rebuild is
2176 + // retried rather than lost.
2177 + return false;
2178 + }
2179 +
2180 + try {
2181 + $settings = $this->get_settings('site');
2182 + if (empty($settings['enabled'])) {
2183 + // The sitemap was disabled: remove the previously generated static
2184 + // files so the web server stops serving a stale sitemap that
2185 + // crawlers would otherwise keep fetching.
2186 + //
2187 + // A file that could not be removed is still being served, so
2188 + // this is not a success. Reporting one here would tell a caller
2189 + // the sitemap was gone while the web server kept answering with
2190 + // it, which is the failure this return value exists to prevent
2191 + // (#764).
2192 + $removal = $this->delete_published_sitemaps($settings);
2193 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2194 +
2195 + if (!empty($stuck)) {
2196 + $this->record_regeneration_failure(
2197 + $this->stuck_files_message($stuck, true),
2198 + 'settings'
2199 + );
2200 +
2201 + return false;
2202 + }
2203 +
2204 + $this->mark_regeneration_complete();
2205 +
2206 + return true;
2207 + }
2208 +
2209 + $revision = $this->current_regeneration_revision();
2210 +
2211 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2212 + // Returns false when a static file is stuck in the web root:
2213 + // the server keeps serving that file in preference to WordPress,
2214 + // so the switch has not taken effect (#764).
2215 + return $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2216 + }
2217 +
2218 + if ($this->generate_and_save($settings)) {
2219 + $this->mark_regeneration_complete($revision);
2220 +
2221 + return true;
2222 + }
2223 +
2224 + $this->record_regeneration_failure(
2225 + $this->write_failure_message(),
2226 + 'settings'
2227 + );
2228 +
2229 + return false;
2230 + } catch (\Throwable $e) {
2231 + $this->record_regeneration_failure($e->getMessage(), 'settings');
2232 +
2233 + return false;
2234 + } finally {
2235 + $this->release_generation_lock();
2236 + }
2237 + }
2238 +
2239 + /**
2240 + * When a rebuild has been outstanding since, or 0 when none is.
2241 + *
2242 + * Lets a caller report an honest "saved, but the served file has not caught
2243 + * up yet" instead of a bare success (#764).
2244 + *
2245 + * @since 2.10.0
2246 + * @return int Unix timestamp, or 0 when nothing is pending.
2247 + */
2248 + public static function regeneration_pending_since(): int {
2249 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2250 +
2251 + if (!is_array($pending) || empty($pending['since'])) {
2252 + return 0;
2253 + }
2254 +
2255 + return (int) $pending['since'];
2256 + }
2257 +
2258 + /**
2259 + * Remove every static sitemap file ThinkRank publishes to the web root.
2260 + *
2261 + * Called when the sitemap feature is disabled, by the cleanup route, and by
2262 + * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
2263 + * children (incl. paginated -N pages), and /local-sitemap.xml stop being
2264 + * served. Only ThinkRank's own filenames are targeted; WordPress core's
2265 + * wp-sitemap.xml and any other plugin's sitemap in the web root are left
2266 + * untouched.
2267 + *
2268 + * @since 1.31.0 Returns the filenames removed, and accepts the settings to
2269 + * derive them from, so a caller that already read them (and
2270 + * needs to report what went) does not have to re-read or
2271 + * re-derive the name list.
2272 + * @since 2.1.0 Delegates to thinkrank_webroot_delete_sitemaps(). Uninstall
2273 + * needs the same removal but has no autoloader to reach this
2274 + * class, so the logic moved to includes/cleanup-webroot.php
2275 + * and this stays as the in-plugin entry point.
2276 + *
2277 + * @param array|null $settings Optional. Sitemap settings; defaults to the
2278 + * saved site settings.
2279 + * @return array{deleted: string[], failed: string[]} Basenames removed, and
2280 + * those that existed but could not be removed.
2281 + */
2282 + public function delete_published_sitemaps(?array $settings = null): array {
2283 + return thinkrank_webroot_delete_sitemaps($settings ?? $this->get_settings('site'));
2284 + }
2285 +
2286 + /**
2287 + * Every child-sitemap filename this site could have published.
2288 + *
2289 + * @since 1.31.0
2290 + * @since 2.1.0 Delegates to thinkrank_webroot_segment_filenames().
2291 + *
2292 + * @param array $settings Sitemap settings (read for `custom_url_pattern`).
2293 + * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
2294 + */
2295 + private function publishable_segment_filenames(array $settings): array {
2296 + return thinkrank_webroot_segment_filenames($settings);
2297 + }
2298 +
2299 + /**
2300 + * Can this web-root file be shown to be a sitemap ThinkRank wrote?
2301 + *
2302 + * The generator deletes as often as cleanup does — a segment that dropped
2303 + * out of the set, a pagination page beyond the new count, the local sitemap
2304 + * after the business identity was cleared — and until 2.1.1 it did all
2305 + * three by filename alone. That is the #515 bug on a far more frequent
2306 + * trigger: our names are the canonical ones, so an ordinary regeneration
2307 + * (post save, term change, settings save) destroyed RankMath's
2308 + * `sitemap-tags.xml` and `local-sitemap.xml` with no deactivation involved.
2309 + *
2310 + * Every name the generator derives comes from the current settings — the
2311 + * url pattern, the configured `sitemap_urls`, `local-sitemap.xml` — but the
2312 + * ownership test is still asked with `$name_derived = false`, which switches
2313 + * off the legacy fallback for the whole generator side.
2314 + *
2315 + * The fallback exists to recover a pre-2.1.1 file written with
2316 + * `enable_styling` off, which carries neither marker. That recovery belongs
2317 + * to the once-off cleanup paths. Here it can only do harm: this method runs
2318 + * on every post save, and everything this version writes carries
2319 + * THINKRANK_SITEMAP_MARKER, so after the site's first regeneration an
2320 + * unmarked file at one of our names is by definition somebody else's — and
2321 + * deleting it on an ordinary regeneration is #515 through the more common
2322 + * door. The cost is a stale unmarked segment left on disk until deactivation
2323 + * picks it up, which is the safe direction to fail in.
2324 + *
2325 + * @since 2.1.1
2326 + *
2327 + * @param string $path Absolute path to a file in the web root.
2328 + * @param array $settings Sitemap settings.
2329 + * @return bool True when the file may be deleted.
2330 + */
2331 + private function webroot_sitemap_is_ours(string $path, array $settings): bool {
2332 + return thinkrank_webroot_sitemap_is_ours($path, $settings, false);
2333 + }
2334 +
2335 + /**
1112 2336 * Auto-regenerate sitemap (called by scheduled action)
1113 2337 *
1114 2338 * @since 1.0.0
1115 2339 * @return void
@@ -1114,37 +2338,728 @@
1114 2338 * @since 1.0.0
1115 2339 * @return void
1116 2340 */
1117 2341 public function auto_regenerate_sitemap(): void {
2342 + if (!$this->acquire_generation_lock()) {
2343 + // A manual generation (or another request's takeover) is already
2344 + // writing the files; the pending marker survives so this rebuild is
2345 + // retried rather than lost.
2346 + return;
2347 + }
2348 +
1118 2349 try {
1119 2350 // Double-check that auto-generation is still enabled
1120 2351 if (!$this->should_auto_generate()) {
2352 + // Nothing outstanding can be delivered while the feature is off,
2353 + // so drop the marker rather than let the takeover retry forever.
2354 + $this->mark_regeneration_complete();
1121 2355 return;
1122 2356 }
1123 2357
1124 - // Generate sitemap with current settings
2358 + $revision = $this->current_regeneration_revision();
1125 2359 $settings = $this->get_settings('site');
1126 - $sitemap_xml = $this->generate_sitemap($settings);
1127 2360
1128 - // Save sitemap to file
1129 - $this->save_sitemap_to_file($sitemap_xml);
2361 + // See regenerate_sitemap_from_settings(): nothing to write.
2362 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2363 + $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2364 + return;
2365 + }
1130 2366
1131 - // Update last generated timestamp
1132 - $this->update_setting('last_generated', gmdate('c'), 'site');
2367 + if ($this->generate_and_save($settings)) {
2368 + $this->mark_regeneration_complete($revision);
2369 + } else {
2370 + // Previously this returned quietly and last_generated simply
2371 + // stopped advancing, leaving the site owner with no way to learn
2372 + // the sitemap had stopped updating (#629).
2373 + $this->record_regeneration_failure(
2374 + $this->write_failure_message(),
2375 + 'content'
2376 + );
2377 + }
2378 + } catch (\Throwable $e) {
2379 + $this->record_regeneration_failure($e->getMessage(), 'content');
2380 + } finally {
2381 + $this->release_generation_lock();
2382 + }
2383 + }
1133 2384
1134 - // Ping search engines if enabled
1135 - if (!empty($settings['ping_search_engines'])) {
1136 - $this->ping_search_engines();
2385 + /**
2386 + * Build and write the sitemap files for the given settings.
2387 + *
2388 + * Segmented files plus an index when the settings configure one, a single
2389 + * file otherwise, and the standalone local business sitemap either way.
2390 + * Records `last_generated` so the UI's "View Generated Sitemaps" links
2391 + * unlock, mirroring the manual generate endpoint.
2392 + *
2393 + * @since 1.17.0
2394 + * @param array $settings Sitemap settings.
2395 + * @return bool True when the sitemap files were written.
2396 + */
2397 + public function generate_and_save(array $settings): bool {
2398 + // Dynamic delivery publishes no files, so writing them here would put a
2399 + // static copy back in the web root for the server to serve in place of
2400 + // the dynamic route. Guarding at each call site left gaps — the
2401 + // snapshot migrator's post-import regeneration had none — so the rule
2402 + // lives with the writing instead.
2403 + //
2404 + // `is_collecting()` is the exception that makes dynamic delivery work
2405 + // at all: render_document() and collect_documents() reach this same
2406 + // method with the writer swapped for a collector, and that is precisely
2407 + // the dynamic build. Only a real write is skipped.
2408 + if (!$this->is_collecting() && 'dynamic' === $this->resolve_delivery_mode($settings)) {
2409 + // Whatever prompted this call changed the sitemap's content, so the
2410 + // rendered copies must not outlive it.
2411 + $this->flush_dynamic_cache();
2412 +
2413 + return true;
2414 + }
2415 +
2416 + // Index mode is driven by the use_sitemap_index toggle (not merely by how
2417 + // many sitemap_urls happen to be configured). When the toggle is on but
2418 + // no child sitemaps are set up yet, synthesize the per-type segmented set
2419 + // so we emit a real <sitemapindex> with paginated children instead of a
2420 + // flat urlset misnamed sitemap_index.xml.
2421 + $settings = $this->maybe_promote_to_index($settings);
2422 +
2423 + if (!empty($settings['use_sitemap_index']) || count((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : [])) > 1) {
2424 + $results = $this->generate_multiple_sitemaps($settings);
2425 + $written = !empty($results['success']);
2426 + } else {
2427 + $xml = $this->generate_sitemap($settings);
2428 + $primary = $this->get_primary_sitemap_filename($settings);
2429 + $written = $this->save_sitemap_to_file($xml, $primary);
2430 +
2431 + // Local business sitemap is a standalone file, regenerated on the
2432 + // single-sitemap path too (this is the default mode).
2433 + $this->regenerate_local_sitemap($settings);
2434 +
2435 + // Switching out of index mode leaves sitemap_index.xml and every
2436 + // child on disk, still served and never refreshed again. The index
2437 + // path already prunes what it no longer owns; this path never did,
2438 + // so the site kept serving two sitemap trees (#563). Ownership is
2439 + // still tested per file, so another plugin's sitemap at one of our
2440 + // names is never touched (#515).
2441 + $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
2442 + }
2443 +
2444 + // last_generated describes what is on disk. A dynamic render publishes
2445 + // nothing, so advancing it would report a static publication that never
2446 + // happened and would let primary_sitemap_file_exists() callers believe
2447 + // there is a file to serve.
2448 + if ($written && !$this->is_collecting()) {
2449 + $settings['last_generated'] = gmdate('c');
2450 + $this->save_settings('site', null, $settings);
2451 + }
2452 +
2453 + return $written;
2454 + }
2455 +
2456 + /**
2457 + * Whether this instance is rendering documents rather than publishing them.
2458 + *
2459 + * @since 2.9.0
2460 + *
2461 + * @return bool
2462 + */
2463 + private function is_collecting(): bool {
2464 + return $this->document_sink !== null;
2465 + }
2466 +
2467 + /**
2468 + * Complete a regeneration that delivers dynamically, retiring stale files.
2469 + *
2470 + * Dynamic delivery renders nothing to disk, but that is only half the job.
2471 + * A web server hands back an existing `/sitemap.xml` without ever loading
2472 + * WordPress, so any file left over from a previous static generation goes on
2473 + * being served forever and {@see \ThinkRank\Frontend\SEO_Manager
2474 + * ::maybe_serve_sitemap()} is never reached. Switching to dynamic while
2475 + * leaving those files in place would therefore appear to do nothing at all.
2476 + *
2477 + * Both transitions matter and they differ:
2478 + *
2479 + * - An explicit switch to `dynamic` happens on a site whose root is usually
2480 + * still writable, so the files can simply be removed.
2481 + * - An `auto` site that becomes read-only cannot remove them, because
2482 + * deleting an entry needs write permission on the directory that holds
2483 + * it. There the stale sitemap really is stuck in front of us, and the
2484 + * honest outcome is a recorded failure naming it rather than a rebuild
2485 + * reported as complete (#754 review).
2486 + *
2487 + * Ownership is tested per file by the shared helper, so another plugin's
2488 + * sitemap at one of our names is never deleted (#515).
2489 + *
2490 + * @since 2.9.0
2491 + *
2492 + * @param array $settings Sitemap settings.
2493 + * @param int $revision Revision this rebuild is completing.
2494 + * @param string $source 'settings' or 'content', for the failure record.
2495 + * @return void
2496 + */
2497 + private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): bool {
2498 + $this->flush_dynamic_cache();
2499 +
2500 + $removal = $this->delete_published_sitemaps($settings);
2501 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2502 +
2503 + if (!empty($stuck)) {
2504 + $this->record_regeneration_failure($this->stuck_files_message($stuck), $source);
2505 +
2506 + return false;
2507 + }
2508 +
2509 + $this->mark_regeneration_complete($revision);
2510 +
2511 + return true;
2512 + }
2513 +
2514 + /**
2515 + * Why a stale file left in the web root means the change has not landed.
2516 + *
2517 + * Shared by every path that removes published files, so they cannot
2518 + * describe the same situation differently (#764).
2519 + *
2520 + * The two situations that reach it differ in what WordPress is doing, and
2521 + * the message has to say which. After a switch to dynamic delivery
2522 + * WordPress IS serving the sitemap and the files shadow it. After the
2523 + * sitemap is switched off WordPress serves nothing, so the one message
2524 + * used to tell a site owner who had just disabled the sitemap that it was
2525 + * "being served from WordPress", which is the opposite of what they did.
2526 + *
2527 + * @since 2.10.0
2528 + * @since 2.10.0 Public, so the REST endpoint uses it rather than a copy;
2529 + * takes $sitemap_disabled for the disabled path.
2530 + *
2531 + * @param string[] $stuck Basenames that could not be removed.
2532 + * @param bool $sitemap_disabled True when the files outlived disabling
2533 + * the sitemap rather than a switch to
2534 + * dynamic delivery.
2535 + * @return string
2536 + */
2537 + public function stuck_files_message(array $stuck, bool $sitemap_disabled = false): string {
2538 + if ($sitemap_disabled) {
2539 + return sprintf(
2540 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2541 + __('The sitemap is disabled, but these files are still in the site root and your web server is still serving them: %1$s. They could not be removed because %2$s is not writable. Delete them, or ask your host to make the WordPress root writable.', 'thinkrank'),
2542 + implode(', ', $stuck),
2543 + untrailingslashit(ABSPATH)
2544 + );
2545 + }
2546 +
2547 + return sprintf(
2548 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2549 + __('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'),
2550 + implode(', ', $stuck),
2551 + untrailingslashit(ABSPATH)
2552 + );
2553 + }
2554 +
2555 + /**
2556 + * What to tell the site owner when publishing the files failed.
2557 + *
2558 + * The old wording stated the symptom and stopped there, so the reported
2559 + * cause was a guess and this reached support as a plugin fault rather than
2560 + * a folder permission (#752, #753). When the root is demonstrably
2561 + * unwritable, say that, and say what to do about it.
2562 + *
2563 + * @since 2.9.0
2564 + *
2565 + * @return string
2566 + */
2567 + private function write_failure_message(): string {
2568 + if (!wp_is_writable(ABSPATH)) {
2569 + return sprintf(
2570 + /* translators: %s: absolute path to the WordPress root. */
2571 + __('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'),
2572 + untrailingslashit(ABSPATH)
2573 + );
2574 + }
2575 +
2576 + return __('The sitemap files could not be written to the site root.', 'thinkrank');
2577 + }
2578 +
2579 + /**
2580 + * How this site delivers its sitemap.
2581 + *
2582 + * `auto` is resolved on whether the web root can be written. That is the
2583 + * right signal here (unlike llms.txt, where the question is whether the
2584 + * server applies the .htaccess charset block): a site whose root is
2585 + * read-only cannot publish a sitemap file at all, and before this existed
2586 + * the feature simply failed with "The sitemap files could not be written to
2587 + * the site root." and served nothing (#752).
2588 + *
2589 + * @since 2.9.0
2590 + *
2591 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2592 + * @return string One of 'static' or 'dynamic'. Never 'auto'.
2593 + */
2594 + public function resolve_delivery_mode(?array $settings = null): string {
2595 + $settings = $settings ?? $this->get_settings('site');
2596 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
2597 +
2598 + if ('static' === $mode || 'dynamic' === $mode) {
2599 + return $mode;
2600 + }
2601 +
2602 + return wp_is_writable(ABSPATH) ? 'static' : 'dynamic';
2603 + }
2604 +
2605 + /**
2606 + * Render one published sitemap document without touching the filesystem.
2607 + *
2608 + * Runs the ordinary build pipeline with the writer swapped for a collector,
2609 + * so the bytes returned here are the bytes the static path would have
2610 + * written. `SitemapDeliveryParityTest` asserts that equivalence rather than
2611 + * trusting it.
2612 + *
2613 + * The whole set is built to answer for one file, because the index can only
2614 + * be assembled from the children that were actually produced. The result is
2615 + * cached per document, so that cost is paid once per change and not once
2616 + * per crawler request.
2617 + *
2618 + * @since 2.9.0
2619 + *
2620 + * @param string $filename Published file name, e.g. 'sitemap.xml'.
2621 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2622 + * @return string|null XML, or null when this site does not publish that name.
2623 + */
2624 + public function render_document(string $filename, ?array $settings = null): ?string {
2625 + $filename = basename($filename);
2626 + $settings = $settings ?? $this->get_settings('site');
2627 +
2628 + if (empty($settings['enabled'])) {
2629 + return null;
2630 + }
2631 +
2632 + $cached = get_transient($this->dynamic_cache_key($filename));
2633 + if (self::ABSENT_MARKER === $cached) {
2634 + return null;
2635 + }
2636 + if (is_string($cached) && '' !== $cached) {
2637 + return $cached;
2638 + }
2639 +
2640 + // A miss builds the whole set, because the index can only be assembled
2641 + // from the children that were actually produced. Caching only the
2642 + // requested document therefore made a crawler walking the index and its
2643 + // children rebuild the entire site's sitemap once per file — every post
2644 + // and taxonomy query repeated N times on a public endpoint (#754
2645 + // review). The set is built once and stored in full.
2646 + return $this->stream_documents($settings, $filename);
2647 + }
2648 +
2649 + /**
2650 + * Build every document, caching each as it is produced, keeping one.
2651 + *
2652 + * A miss has to build the whole set, because the index can only be
2653 + * assembled from the children that were actually produced. It does not have
2654 + * to *hold* the whole set: the static path never keeps more than one page
2655 + * in memory, writing each to disk as it goes, and buffering every
2656 + * document's XML to return one of them undid that on the request path,
2657 + * where a large site's entire sitemap corpus would sit in a single PHP
2658 + * process (#754 review).
2659 + *
2660 + * So the sink writes each document straight to its cache entry and lets it
2661 + * go, retaining only the one this request is answering. Peak retention is
2662 + * one document, whatever the site's size.
2663 + *
2664 + * Concurrency: the first request through takes a short lock and does the
2665 + * work. One that finds the lock held waits a bounded moment for the winner
2666 + * to publish, then builds anyway, because serving a correct sitemap late
2667 + * beats serving none.
2668 + *
2669 + * @since 2.9.0
2670 + *
2671 + * @param array $settings Sitemap settings.
2672 + * @param string $wanted Document this request is answering.
2673 + * @return string|null XML for $wanted, or null when the site does not publish it.
2674 + */
2675 + private function stream_documents(array $settings, string $wanted): ?string {
2676 + $lock = self::DYNAMIC_CACHE_PREFIX . 'lock';
2677 +
2678 + if (!$this->acquire_render_lock($lock)) {
2679 + for ($attempt = 0; $attempt < self::RENDER_LOCK_WAIT_ATTEMPTS; $attempt++) {
2680 + usleep(self::RENDER_LOCK_WAIT_MICROSECONDS);
2681 +
2682 + $cached = get_transient($this->dynamic_cache_key($wanted));
2683 + if (self::ABSENT_MARKER === $cached) {
2684 + return null;
2685 + }
2686 + if (is_string($cached) && '' !== $cached) {
2687 + return $cached;
2688 + }
1137 2689 }
2690 + }
1138 2691
1139 - // Sitemap auto-regenerated successfully
2692 + $kept = null;
2693 + // Names only. Keeping the bodies here would be the very retention this
2694 + // method exists to avoid.
2695 + $produced = [];
1140 2696
1141 - } catch (\Exception $e) {
1142 - // Sitemap auto-regeneration failed - error details available in exception
2697 + $previous = $this->document_sink;
2698 + $this->document_sink = function (string $name, string $xml) use (&$kept, &$produced, $wanted): void {
2699 + $produced[$name] = true;
2700 + set_transient($this->dynamic_cache_key($name), $xml, self::DYNAMIC_CACHE_TTL);
2701 +
2702 + if ($name === $wanted) {
2703 + $kept = $xml;
2704 + }
2705 + };
2706 +
2707 + try {
2708 + $this->generate_and_save($settings);
2709 +
2710 + // Names the configuration lists but this build did not produce get
2711 + // a negative entry, so asking for one again is a cache hit rather
2712 + // than another full rebuild.
2713 + $absent = $this->published_document_names($settings);
2714 +
2715 + // Also the exact name this request asked for: a paginated page past
2716 + // the end of a stem is a legitimate request shape that the base
2717 + // list cannot enumerate, and without an entry it would rebuild on
2718 + // every hit.
2719 + $absent[] = $wanted;
2720 +
2721 + foreach (array_unique($absent) as $name) {
2722 + if (!isset($produced[$name])) {
2723 + set_transient($this->dynamic_cache_key($name), self::ABSENT_MARKER, self::DYNAMIC_CACHE_TTL);
2724 + }
2725 + }
2726 + } finally {
2727 + $this->document_sink = $previous;
2728 + delete_transient($lock);
1143 2729 }
2730 +
2731 + return $kept;
1144 2732 }
1145 2733
1146 2734 /**
2735 + * Take the render lock, if it is free.
2736 + *
2737 + * Not atomic across processes, and deliberately so: the fallback for losing
2738 + * a race is duplicated work, never a wrong or missing sitemap, so a
2739 + * heavier primitive would buy nothing here.
2740 + *
2741 + * @since 2.9.0
2742 + *
2743 + * @param string $lock Lock transient name.
2744 + * @return bool True when this request holds the lock.
2745 + */
2746 + private function acquire_render_lock(string $lock): bool {
2747 + if (false !== get_transient($lock)) {
2748 + return false;
2749 + }
2750 +
2751 + set_transient($lock, time(), self::RENDER_LOCK_TTL);
2752 +
2753 + return true;
2754 + }
2755 +
2756 + /**
2757 + * Build every document this site publishes and return them all.
2758 + *
2759 + * Verification and tooling only. This retains the whole set in memory, so
2760 + * it must never be used to answer a request: {@see self::stream_documents()}
2761 + * is the serving path and keeps one document at a time regardless of site
2762 + * size (#754 review). `SitemapDeliveryParityTest` enforces that separation
2763 + * by failing if the request path routes back through here.
2764 + *
2765 + * @since 2.9.0
2766 + *
2767 + * @param array $settings Sitemap settings.
2768 + * @return array<string,string> Filename => XML.
2769 + */
2770 + public function collect_documents(array $settings): array {
2771 + $documents = [];
2772 +
2773 + $previous = $this->document_sink;
2774 + $this->document_sink = static function (string $name, string $xml) use (&$documents): void {
2775 + $documents[$name] = $xml;
2776 + };
2777 +
2778 + try {
2779 + $this->generate_and_save($settings);
2780 + } finally {
2781 + $this->document_sink = $previous;
2782 + }
2783 +
2784 + return $documents;
2785 + }
2786 +
2787 + /**
2788 + * The file names this site publishes, without building their contents.
2789 + *
2790 + * Used by the request router to decide whether a URL is ours before doing
2791 + * any work. Cheap: it reads the configured child list rather than querying
2792 + * for entries.
2793 + *
2794 + * @since 2.9.0
2795 + *
2796 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2797 + * @return string[] File names, including paginated pages that may exist.
2798 + */
2799 + public function published_document_names(?array $settings = null): array {
2800 + $settings = $settings ?? $this->get_settings('site');
2801 + $resolved = $this->maybe_promote_to_index($settings);
2802 +
2803 + $names = [$this->get_primary_sitemap_filename($settings), 'local-sitemap.xml'];
2804 +
2805 + foreach ((array) ($resolved['sitemap_urls'] ?? []) as $child) {
2806 + if (!is_array($child) || empty($child['enabled'])) {
2807 + continue;
2808 + }
2809 +
2810 + $path = (string) wp_parse_url((string) ($child['url'] ?? ''), PHP_URL_PATH);
2811 + if ('' !== $path) {
2812 + $names[] = basename($path);
2813 + }
2814 + }
2815 +
2816 + return array_values(array_unique(array_filter($names)));
2817 + }
2818 +
2819 + /**
2820 + * Does this site publish a document under that name?
2821 + *
2822 + * Not a plain membership test against {@see self::published_document_names()}:
2823 + * that lists the configured children, and a child over the per-file URL cap
2824 + * is split into `<stem>-2.xml`, `<stem>-3.xml` and so on, with every page
2825 + * listed in the index. Gating the request router on the base list alone
2826 + * therefore 404'd exactly the pages the index points at, which is worse than
2827 + * not serving them at all.
2828 + *
2829 + * Page counts are not knowable without building, so the stem is what is
2830 + * matched; a page that does not exist is answered by the build finding
2831 + * nothing for it, and is then cached as absent.
2832 + *
2833 + * @since 2.9.0
2834 + *
2835 + * @param string $name Requested file name.
2836 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2837 + * @return bool
2838 + */
2839 + public function publishes_document_name(string $name, ?array $settings = null): bool {
2840 + $names = $this->published_document_names($settings);
2841 +
2842 + if (in_array($name, $names, true)) {
2843 + return true;
2844 + }
2845 +
2846 + if (!preg_match('/^(.*)-\d+\.xml$/i', $name, $m)) {
2847 + return false;
2848 + }
2849 +
2850 + return in_array($m[1] . '.xml', $names, true);
2851 + }
2852 +
2853 + /**
2854 + * Transient key for a rendered document.
2855 + *
2856 + * @since 2.9.0
2857 + *
2858 + * @param string $filename Published file name.
2859 + * @return string
2860 + */
2861 + private function dynamic_cache_key(string $filename): string {
2862 + return self::DYNAMIC_CACHE_PREFIX . md5($filename);
2863 + }
2864 +
2865 + /**
2866 + * Drop every cached dynamic document.
2867 + *
2868 + * Called from the same places that mark the static files stale, so the two
2869 + * delivery modes invalidate on identical triggers.
2870 + *
2871 + * @since 2.9.0
2872 + *
2873 + * @return void
2874 + */
2875 + public function flush_dynamic_cache(): void {
2876 + foreach ($this->published_document_names() as $name) {
2877 + delete_transient($this->dynamic_cache_key($name));
2878 + }
2879 + }
2880 +
2881 + /**
2882 + * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
2883 + *
2884 + * - When use_sitemap_index is on but no child sitemaps are configured, build
2885 + * the per-type segmented set so a real <sitemapindex> is produced (#127).
2886 + * - When the toggle is off but a single flat file would exceed the per-file
2887 + * URL cap, auto-promote to a paginated index instead of one oversized file
2888 + * that can cross Google's 50k-URL/50MB limits (#129).
2889 + *
2890 + * @param array $settings Sitemap settings.
2891 + * @return array Possibly-updated settings.
2892 + */
2893 + public function maybe_promote_to_index(array $settings): array {
2894 + $saved = $this->get_settings('site');
2895 +
2896 + // Read from the *payload*, before the merge below folds the saved values
2897 + // in: "the caller named this" and "this has a value" are different
2898 + // questions, and the mode resolution turns on the former.
2899 + $mode_supplied = array_key_exists('use_sitemap_index', $settings);
2900 + $urls_supplied = is_array($settings['sitemap_urls'] ?? null);
2901 + $has_children = count($urls_supplied ? $settings['sitemap_urls'] : []) > 1;
2902 +
2903 + // Which inclusion flags did the caller actually name? The child list is
2904 + // the only thing that reads them, and inheriting a saved one skipped
2905 + // that — so on an index-mode site the include_* flags were enforced
2906 + // nowhere but in the browser, where SitemapGeneration.js recomputes
2907 + // sitemap_urls itself. Every non-UI client, the shipped
2908 + // `update-sitemap-settings` ability included, saved the flag and changed
2909 + // nothing (#398). Read from the payload for the same reason as above:
2910 + // after the merge every saved flag would look like one the caller named.
2911 + $named_inclusions = array_intersect(
2912 + array_keys(self::INCLUSION_CHILD_TYPES),
2913 + array_keys($settings)
2914 + );
2915 + $inclusions_supplied = (bool) $named_inclusions;
2916 +
2917 + // Inclusion flags may be absent from a partial payload (e.g. the manual
2918 + // generate endpoint) — fall back to saved settings so synthesized child
2919 + // sitemaps reflect the real include_posts/pages/categories choices.
2920 + $inclusions = array_merge($saved, $settings);
2921 +
2922 + // Hand the generators a *complete* settings array. Only the mode was
2923 + // resolved before, so every other unnamed key reached them missing: a
2924 + // bare `{}` from a REST/MCP client republished the sitemap with
2925 + // enable_styling and include_images read as off, overwriting the live
2926 + // files with output that had lost its XSL stylesheet, its image
2927 + // namespace and its image entries. Presentation and inclusion settings
2928 + // are not something a generate call opts into — they are the site's
2929 + // configuration, and only a value actually present in the payload
2930 + // overrides them.
2931 + $settings = $inclusions;
2932 +
2933 + // The two keys that drive mode keep their own resolution rules below,
2934 + // so they must go back to "not specified" when the caller omitted them.
2935 + if (!$mode_supplied) {
2936 + unset($settings['use_sitemap_index']);
2937 + }
2938 + if (!$urls_supplied) {
2939 + unset($settings['sitemap_urls']);
2940 + }
2941 +
2942 + // An absent use_sitemap_index means "not specified", which is not the
2943 + // same as "single file". Reading it as the latter meant a partial payload
2944 + // — `{}` from a REST/MCP client, or anything short of the full settings
2945 + // object the admin bundle sends — republished one flat sitemap.xml on an
2946 + // index-mode site and left sitemap_index.xml and its children stale or
2947 + // missing. Inherit the saved mode instead; only a value actually present
2948 + // in the payload decides the mode.
2949 + if (!array_key_exists('use_sitemap_index', $settings)) {
2950 + $settings['use_sitemap_index'] = $saved['use_sitemap_index'] ?? '';
2951 +
2952 + // Inheriting the mode means inheriting its children too, unless the
2953 + // caller named its own set.
2954 + $saved_children = (is_array($saved['sitemap_urls'] ?? null) ? $saved['sitemap_urls'] : []);
2955 + if (!empty($settings['use_sitemap_index'])
2956 + && !$urls_supplied
2957 + && count($saved_children) > 1) {
2958 + // A named inclusion flag is applied *to* the inherited list, not
2959 + // used to regenerate it. build_segmented_sitemap_urls() also adds
2960 + // a child for every public custom post type, so rebuilding here
2961 + // would make `{include_pages: false}` — one thing off — silently
2962 + // switch on children the saved list never had (an Elementor
2963 + // internal CPT, a WooCommerce product feed). Only the flags the
2964 + // caller actually named change anything.
2965 + $settings['sitemap_urls'] = $inclusions_supplied
2966 + ? $this->apply_inclusion_flags_to_children($saved_children, $named_inclusions, $inclusions)
2967 + : $saved_children;
2968 +
2969 + // The children are resolved either way — including when the
2970 + // caller switched the last one off, which leaves a bare index and
2971 + // is what they asked for.
2972 + $has_children = true;
2973 + }
2974 + }
2975 +
2976 + if (!empty($settings['use_sitemap_index'])) {
2977 + if (!$has_children) {
2978 + $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
2979 + }
2980 + return $settings;
2981 + }
2982 +
2983 + if (!$has_children) {
2984 + $limit = $this->get_links_per_sitemap($settings);
2985 + if ($limit > 0 && $this->estimate_total_sitemap_urls($inclusions) > $limit) {
2986 + $settings['use_sitemap_index'] = true;
2987 + $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
2988 + }
2989 + }
2990 +
2991 + return $settings;
2992 + }
2993 +
2994 + /**
2995 + * Rough count of URLs a single-file sitemap would contain, used only to
2996 + * decide whether to auto-promote to a paginated index. Cheap COUNT queries;
2997 + * intentionally approximate (homepage + published posts of enabled types +
2998 + * terms of enabled taxonomies).
2999 + *
3000 + * @param array $settings Sitemap settings.
3001 + * @return int
3002 + */
3003 + private function estimate_total_sitemap_urls(array $settings): int {
3004 + $total = 1; // homepage
3005 +
3006 + foreach ($this->get_enabled_post_types($settings) as $post_type) {
3007 + $counts = wp_count_posts($post_type);
3008 + $total += isset($counts->publish) ? (int) $counts->publish : 0;
3009 + }
3010 +
3011 + foreach ($this->get_enabled_taxonomies($settings) as $taxonomy) {
3012 + $count = wp_count_terms(['taxonomy' => $taxonomy, 'hide_empty' => true]);
3013 + if (!is_wp_error($count)) {
3014 + $total += (int) $count;
3015 + }
3016 + }
3017 +
3018 + return $total;
3019 + }
3020 +
3021 + /**
3022 + * Resolve the sitemap file the site publishes for the given settings.
3023 + *
3024 + * Index mode serves the index (`sitemap_index.xml`); the default single-file
3025 + * mode serves `sitemap.xml`. Callers use this to link to — or check for —
3026 + * the file the site actually serves, since ThinkRank's sitemap is a static
3027 + * file in the web root rather than a route.
3028 + *
3029 + * @since 1.17.0
3030 + * @param array $settings Sitemap settings.
3031 + * @return string Sitemap filename.
3032 + */
3033 + public function get_primary_sitemap_filename(array $settings): string {
3034 + return thinkrank_webroot_primary_sitemap_filename($settings);
3035 + }
3036 +
3037 + /**
3038 + * Public URL of the sitemap the site serves.
3039 + *
3040 + * @since 1.17.0
3041 + * @param array|null $settings Sitemap settings (falls back to saved site settings).
3042 + * @return string Absolute sitemap URL.
3043 + */
3044 + public function get_primary_sitemap_url(?array $settings = null): string {
3045 + $settings = $settings ?? $this->get_settings('site');
3046 +
3047 + return home_url('/' . $this->get_primary_sitemap_filename($settings));
3048 + }
3049 +
3050 + /**
3051 + * Whether the sitemap file this site publishes exists on disk.
3052 + *
3053 + * @since 1.17.0
3054 + * @param array $settings Sitemap settings.
3055 + * @return bool True when the file is present.
3056 + */
3057 + public function primary_sitemap_file_exists(array $settings): bool {
3058 + return file_exists(ABSPATH . $this->get_primary_sitemap_filename($settings));
3059 + }
3060 +
3061 + /**
1147 3062 * Save sitemap XML to file
1148 3063 *
1149 3064 * @since 1.0.0
1150 3065 * @param string $sitemap_xml Sitemap XML content
@@ -1159,8 +3074,18 @@
1159 3074 // File validation failed - error details available in exception
1160 3075 return false;
1161 3076 }
1162 3077
3078 + // Dynamic delivery: hand the document to the collector instead of the
3079 + // filesystem. Reported as published, because for this run it is — the
3080 + // caller's success/failure bookkeeping and the index assembly both key
3081 + // off this return value.
3082 + if ($this->document_sink !== null) {
3083 + ($this->document_sink)($filename, $sitemap_xml);
3084 +
3085 + return true;
3086 + }
3087 +
1163 3088 $sitemap_path = ABSPATH . $filename;
1164 3089
1165 3090 // Use WordPress filesystem API for better security
1166 3091 global $wp_filesystem;
@@ -1169,16 +3094,202 @@
1169 3094 WP_Filesystem();
1170 3095 }
1171 3096
1172 3097 if ($wp_filesystem) {
1173 - return $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3098 + $written = $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
3099 +
3100 + if ($written) {
3101 + // Every sitemap this version writes carries the ownership
3102 + // marker, so once one has been written an unmarked file at one
3103 + // of our names cannot be ours. Recording that retires the
3104 + // legacy fallback for this install — see
3105 + // thinkrank_webroot_sitemap_is_ours().
3106 + if (get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1') {
3107 + update_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION, '1', false);
3108 + }
3109 + }
3110 +
3111 + return $written;
1174 3112 }
1175 3113
1176 - // Fallback to file_put_contents if WP_Filesystem fails
1177 - return file_put_contents($sitemap_path, $sitemap_xml) !== false;
3114 + // WP_Filesystem initialization failed
3115 + return false;
1178 3116 }
1179 3117
1180 3118 /**
3119 + * Build a segmented, index-based sitemap_urls list from inclusion settings.
3120 + *
3121 + * Produces the index entry plus one child sitemap per enabled content type
3122 + * (posts/pages/categories) and one per public custom post type — the shape
3123 + * the "complete" preset creates and that generate_multiple_sitemaps() expects.
3124 + * Used when enabling the index during import so the index has real children
3125 + * instead of being empty.
3126 + *
3127 + * @since 1.14.0
3128 + *
3129 + * @param array $inclusions Inclusion flags (include_posts/pages/categories)
3130 + * and optionally custom_url_pattern.
3131 + * @return array Sitemap URL configs
3132 + */
3133 + public function build_segmented_sitemap_urls(array $inclusions): array {
3134 + $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
3135 +
3136 + $urls = [$this->sitemap_child_entry('/sitemap_index.xml', 'index')];
3137 +
3138 + foreach (self::INCLUSION_CHILD_TYPES as $flag => $type) {
3139 + if (!empty($inclusions[$flag])) {
3140 + $urls[] = $this->build_child_sitemap_entry($type, $pattern);
3141 + }
3142 + }
3143 +
3144 + // Public custom post types each get a child sitemap (parity with the
3145 + // "complete" preset and with Rank Math, which lists every public CPT).
3146 + foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
3147 + if (!$this->should_include_post_type($cpt)) {
3148 + continue;
3149 + }
3150 +
3151 + // ...and the per-content-type sitemap switch (#660).
3152 + // get_enabled_post_types() already honours it, but this list is what
3153 + // index mode builds its children from — so without the same test a
3154 + // CPT the user had switched off still got its own child sitemap,
3155 + // created and streamed in full. The flags live in $inclusions, which
3156 + // is the settings array these children are derived from.
3157 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $cpt, $inclusions)) {
3158 + continue;
3159 + }
3160 +
3161 + $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
3162 + }
3163 +
3164 + // Public custom taxonomies get the same treatment (#690). Flat mode has
3165 + // always walked them through get_enabled_taxonomies(); index mode built
3166 + // its children from the list above and never consulted a taxonomy at
3167 + // all, so every custom-taxonomy archive silently vanished from the
3168 + // sitemap the moment a site switched modes — and the per-taxonomy switch
3169 + // the matrix writes had nothing to act on. Same two tests the post-type
3170 + // walk applies, in the same order.
3171 + $taken = array_column($urls, 'type');
3172 +
3173 + foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
3174 + if (!$this->should_include_taxonomy($taxonomy)) {
3175 + continue;
3176 + }
3177 +
3178 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $inclusions)) {
3179 + continue;
3180 + }
3181 +
3182 + // Post types and taxonomies are separate registries, so a site can
3183 + // hold both a `foo` post type and a `foo` taxonomy. They would
3184 + // resolve to one filename, and stream_type_entries() answers post
3185 + // types first, so the second child would list the first one's file
3186 + // twice in the index rather than adding anything.
3187 + if (in_array($taxonomy, $taken, true)) {
3188 + continue;
3189 + }
3190 +
3191 + $urls[] = $this->build_child_sitemap_entry($taxonomy, $pattern);
3192 + }
3193 +
3194 + return $urls;
3195 + }
3196 +
3197 + /**
3198 + * Apply only the inclusion flags the caller named to an existing child list.
3199 + *
3200 + * The narrow counterpart to build_segmented_sitemap_urls(): that one
3201 + * regenerates the whole set from scratch, which is right when there is no set
3202 + * yet and wrong when there is. Rebuilding an existing list would add a child
3203 + * for every public custom post type it had never contained, so a payload that
3204 + * switches one thing off would switch others on. Here a flag adds or removes
3205 + * exactly its own child and leaves every other entry — custom post types,
3206 + * hand-added URLs, per-child enabled/status state — untouched (#398).
3207 + *
3208 + * @since 1.31.0
3209 + *
3210 + * @param array $children Existing child sitemap entries.
3211 + * @param string[] $named_inclusions Inclusion flag keys present in the payload.
3212 + * @param array $inclusions Merged settings, for the resolved flag
3213 + * values and custom_url_pattern.
3214 + * @return array Updated child sitemap entries.
3215 + */
3216 + private function apply_inclusion_flags_to_children(
3217 + array $children,
3218 + array $named_inclusions,
3219 + array $inclusions
3220 + ): array {
3221 + $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
3222 +
3223 + foreach ($named_inclusions as $flag) {
3224 + $type = self::INCLUSION_CHILD_TYPES[$flag];
3225 +
3226 + $present = false;
3227 + foreach ($children as $entry) {
3228 + if (($entry['type'] ?? '') === $type) {
3229 + $present = true;
3230 + break;
3231 + }
3232 + }
3233 +
3234 + if (empty($inclusions[$flag])) {
3235 + if ($present) {
3236 + $children = array_values(array_filter(
3237 + $children,
3238 + static function ($entry) use ($type): bool {
3239 + return (is_array($entry) ? ($entry['type'] ?? '') : '') !== $type;
3240 + }
3241 + ));
3242 + }
3243 + continue;
3244 + }
3245 +
3246 + if (!$present) {
3247 + $children[] = $this->build_child_sitemap_entry($type, $pattern);
3248 + }
3249 + }
3250 +
3251 + return $children;
3252 + }
3253 +
3254 + /**
3255 + * Build one child sitemap entry, resolving its filename from the url pattern.
3256 + *
3257 + * @since 1.31.0
3258 + *
3259 + * @param string $type Child sitemap type (posts, pages, a post type name).
3260 + * @param string $pattern Filename pattern containing {type}.
3261 + * @return array Sitemap URL config.
3262 + */
3263 + private function build_child_sitemap_entry(string $type, string $pattern): array {
3264 + $file = str_replace('{type}', $type, $pattern);
3265 + if (strpos($file, '/') !== 0) {
3266 + $file = '/' . $file;
3267 + }
3268 +
3269 + return $this->sitemap_child_entry($file, $type);
3270 + }
3271 +
3272 + /**
3273 + * The shape generate_multiple_sitemaps() expects of a sitemap_urls entry.
3274 + *
3275 + * @since 1.31.0
3276 + *
3277 + * @param string $url Sitemap path.
3278 + * @param string $type Entry type.
3279 + * @return array Sitemap URL config.
3280 + */
3281 + private function sitemap_child_entry(string $url, string $type): array {
3282 + return [
3283 + 'url' => $url,
3284 + 'type' => $type,
3285 + 'enabled' => true,
3286 + 'last_checked' => null,
3287 + 'status' => 'unknown',
3288 + ];
3289 + }
3290 +
3291 + /**
1181 3292 * Generate multiple sitemaps based on settings
1182 3293 *
1183 3294 * @since 1.0.0
1184 3295 * @param array $settings Sitemap settings
@@ -1191,9 +3302,9 @@
1191 3302 'errors' => [],
1192 3303 'total_urls' => 0
1193 3304 ];
1194 3305
1195 - $sitemap_urls = $settings['sitemap_urls'] ?? [];
3306 + $sitemap_urls = (is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []);
1196 3307
1197 3308 if (empty($sitemap_urls)) {
1198 3309 $results['success'] = false;
1199 3310 $results['errors'][] = 'No sitemap URLs configured';
@@ -1199,215 +3310,663 @@
1199 3310 $results['errors'][] = 'No sitemap URLs configured';
1200 3311 return $results;
1201 3312 }
1202 3313
3314 + $limit = $this->get_links_per_sitemap($settings);
3315 +
3316 + // Defer the index until every child sitemap has been generated, so it can
3317 + // list the actual files produced (including pagination pages).
3318 + $index_config = null;
3319 + $index_children = [];
3320 +
1203 3321 foreach ($sitemap_urls as $sitemap_config) {
1204 - if (!$sitemap_config['enabled']) {
3322 + if (empty($sitemap_config['enabled'])) {
1205 3323 continue;
1206 3324 }
1207 3325
3326 + $type = $sitemap_config['type'];
3327 +
3328 + if ($type === 'index') {
3329 + $index_config = $sitemap_config;
3330 + continue;
3331 + }
3332 +
3333 + // Skip CPT children that no longer qualify (e.g. an internal store
3334 + // like Templately's `templately_library`) even when a previously
3335 + // saved config still lists them. Built-in aggregate types ('posts',
3336 + // 'pages', 'general', etc.) are not post type names, so this only
3337 + // affects real custom post types.
3338 + if (post_type_exists($type) && !$this->should_include_post_type($type)) {
3339 + continue;
3340 + }
3341 +
3342 + // Same for the matrix switch: a child list saved before the user
3343 + // excluded this content type still names it, and regenerating from
3344 + // that list would rewrite the file they asked not to have. Built-in
3345 + // aggregates ('posts', 'pages', ...) are not post type names, so
3346 + // post_type_exists() keeps this to real custom post types.
3347 + if (post_type_exists($type)
3348 + && !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $type, $settings)) {
3349 + continue;
3350 + }
3351 +
3352 + // The taxonomy counterpart of the two guards above (#690). A child
3353 + // list saved while a taxonomy was still included keeps naming it, so
3354 + // without this, excluding one in the matrix would still rewrite and
3355 + // re-list the file the user asked not to have. The built-in
3356 + // aggregates are named 'categories'/'tags' rather than
3357 + // 'category'/'post_tag', so taxonomy_exists() leaves them to the
3358 + // inclusion-flag check below.
3359 + $child_taxonomy = self::CHILD_TYPE_ALIASES[$type] ?? $type;
3360 + if (taxonomy_exists($child_taxonomy)
3361 + && (!$this->should_include_taxonomy($child_taxonomy)
3362 + || !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $child_taxonomy, $settings))) {
3363 + continue;
3364 + }
3365 +
3366 + // The built-in aggregates carry their switch in the inclusion flag
3367 + // rather than under a post type name, and they are the four most
3368 + // people actually use. build_segmented_sitemap_urls() drops a child
3369 + // whose flag is empty; regenerating from a list saved while it was
3370 + // still on has to make the same decision, or turning Posts, Pages,
3371 + // Categories or Tags off in the matrix rewrites and re-lists the
3372 + // very file it was asked to remove.
3373 + // An absent flag means "not configured", which every other reader
3374 + // treats as included; only a flag that is present and off excludes.
3375 + $aggregate_flag = array_search($type, self::INCLUSION_CHILD_TYPES, true);
3376 + if ($aggregate_flag !== false
3377 + && array_key_exists($aggregate_flag, $settings)
3378 + && empty($settings[$aggregate_flag])) {
3379 + continue;
3380 + }
3381 +
1208 3382 try {
1209 - $sitemap_xml = '';
1210 - $url_count = 0;
3383 + // The single "general"/"WordPress" sitemap is one un-paginated file
3384 + // (there is no index to reference extra pages); it no longer drops
3385 + // overflow URLs.
3386 + if ($type === 'general' || $type === 'wordpress') { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- lowercase on purpose: this is the stored type slug.
3387 + $xml = $this->generate_sitemap($settings);
3388 + $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
3389 + continue;
3390 + }
1211 3391
1212 - // Generate sitemap based on type
1213 - switch ($sitemap_config['type']) {
1214 - case 'index':
1215 - $sitemap_xml = $this->generate_sitemap_index($sitemap_urls);
1216 - $url_count = count(array_filter($sitemap_urls, fn($s) => $s['enabled'] && $s['type'] !== 'index'));
1217 - break;
1218 - case 'general':
1219 - case 'wordpress':
1220 - $sitemap_xml = $this->generate_sitemap($settings);
1221 - $url_count = $this->count_urls_in_xml($sitemap_xml);
1222 - break;
1223 - case 'posts':
1224 - $sitemap_xml = $this->generate_post_sitemap('post', $settings);
1225 - $url_count = $this->count_urls_in_xml($sitemap_xml);
1226 - break;
1227 - case 'pages':
1228 - $sitemap_xml = $this->generate_post_sitemap('page', $settings);
1229 - $url_count = $this->count_urls_in_xml($sitemap_xml);
1230 - break;
1231 - case 'categories':
1232 - $sitemap_xml = $this->generate_taxonomy_sitemap('category', $settings);
1233 - $url_count = $this->count_urls_in_xml($sitemap_xml);
1234 - break;
1235 - case 'products':
1236 - // Generate product-specific sitemap
1237 - if (post_type_exists('product')) {
1238 - $sitemap_xml = $this->generate_post_sitemap('product', $settings);
1239 - } else {
1240 - // Create empty sitemap if WooCommerce not installed
1241 - $sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
3392 + $source = $this->stream_type_entries($type, $settings);
1242 3393
1243 - // Add XSL stylesheet only if styling is enabled
1244 - if (!empty($settings['enable_styling'])) {
1245 - $sitemap_xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap.xsl') . '"?>' . "\n";
1246 - }
3394 + // Unknown type — fall back to a single general sitemap file.
3395 + if ($source === null) {
3396 + $xml = $this->generate_sitemap($settings);
3397 + $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
3398 + continue;
3399 + }
1247 3400
1248 - $sitemap_xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1249 - $sitemap_xml .= '</urlset>';
1250 - }
1251 - $url_count = $this->count_urls_in_xml($sitemap_xml);
1252 - break;
1253 - case 'product_categories':
1254 - // Generate product category sitemap
1255 - if (taxonomy_exists('product_cat')) {
1256 - $sitemap_xml = $this->generate_taxonomy_sitemap('product_cat', $settings);
1257 - } else {
1258 - // Create empty sitemap if WooCommerce not installed
1259 - $sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
3401 + // Stream the entries into files of at most $limit URLs, matching
3402 + // Rank Math: page 1 keeps the base filename, pages 2+ get a -N
3403 + // suffix, and every page is listed in the index. Buffering only one
3404 + // page at a time keeps peak memory bounded to $limit entries rather
3405 + // than every URL of the type.
3406 + $image_ns = $source['image_ns'];
3407 + $buffer = [];
3408 + $page = 0;
1260 3409
1261 - // Add XSL stylesheet only if styling is enabled
1262 - if (!empty($settings['enable_styling'])) {
1263 - $sitemap_xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap.xsl') . '"?>' . "\n";
1264 - }
3410 + foreach ($source['entries'] as $entry) {
3411 + $buffer[] = $entry;
3412 + if (count($buffer) >= $limit) {
3413 + $page++;
3414 + $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
3415 + $buffer = [];
3416 + }
3417 + }
1265 3418
1266 - $sitemap_xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1267 - $sitemap_xml .= '</urlset>';
1268 - }
1269 - $url_count = $this->count_urls_in_xml($sitemap_xml);
1270 - break;
1271 - default:
1272 - // Check if it's a custom post type
1273 - $post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
1274 - if (in_array($sitemap_config['type'], $post_types)) {
1275 - $sitemap_xml = $this->generate_post_sitemap($sitemap_config['type'], $settings);
1276 - $url_count = $this->count_urls_in_xml($sitemap_xml);
1277 - } else {
1278 - // Fallback to general sitemap
1279 - $sitemap_xml = $this->generate_sitemap($settings);
1280 - $url_count = $this->count_urls_in_xml($sitemap_xml);
1281 - }
1282 - break;
3419 + // Flush the trailing partial page, or a single empty page when the
3420 + // type had no entries at all (parity with the previous behavior of
3421 + // always writing at least one page per configured child).
3422 + if (!empty($buffer) || $page === 0) {
3423 + $page++;
3424 + $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
1283 3425 }
1284 3426
1285 - // Extract filename from URL
1286 - $filename = basename(wp_parse_url($sitemap_config['url'], PHP_URL_PATH));
3427 + // Remove pages left over from a previous, larger generation.
3428 + $this->cleanup_stale_pages($sitemap_config['url'], $page, $settings);
1287 3429
1288 - // Save sitemap to file
1289 - if ($this->save_sitemap_to_file($sitemap_xml, $filename)) {
3430 + } catch (\Exception $e) {
3431 + $results['errors'][] = "Error generating {$type} sitemap: " . $e->getMessage();
3432 + $results['success'] = false;
3433 + }
3434 + }
3435 +
3436 + // Local business sitemap (parity with Rank Math's local-sitemap.xml).
3437 + // The physical file is written whenever a business identity is
3438 + // configured — independent of segmented vs single mode — and is also
3439 + // listed in the sitemap index when one exists.
3440 + try {
3441 + if ($this->regenerate_local_sitemap($settings) && $index_config !== null) {
3442 + $index_children[] = ['url' => '/local-sitemap.xml'];
3443 + }
3444 + } catch (\Exception $e) {
3445 + $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
3446 + $results['success'] = false;
3447 + }
3448 +
3449 + // Build the index last, from the child files actually generated, plus the
3450 + // sitemaps other plugins own: those serve their own URLs and write no
3451 + // file here, so they are appended to the index only (#104).
3452 + if ($index_config !== null) {
3453 + foreach (self::additional_sitemaps() as $extra) {
3454 + $index_children[] = ['url' => $extra];
3455 + }
3456 +
3457 + try {
3458 + $index_xml = $this->generate_sitemap_index($index_children, $settings);
3459 + $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
3460 +
3461 + if ($this->save_sitemap_to_file($index_xml, $filename)) {
1290 3462 $results['sitemaps_generated'][] = [
1291 - 'url' => $sitemap_config['url'],
1292 - 'type' => $sitemap_config['type'],
1293 - 'filename' => $filename,
1294 - 'url_count' => $url_count
3463 + 'url' => $index_config['url'],
3464 + 'type' => 'index',
3465 + 'filename' => $filename,
3466 + 'url_count' => count($index_children),
1295 3467 ];
1296 - $results['total_urls'] += $url_count;
1297 3468 } else {
1298 - $error_message = "Failed to save sitemap: {$filename}";
1299 - $results['errors'][] = $error_message;
3469 + $results['errors'][] = "Failed to save sitemap: {$filename}";
1300 3470 $results['success'] = false;
1301 -
1302 - // File save error details available in results array
1303 3471 }
1304 -
1305 3472 } catch (\Exception $e) {
1306 - $results['errors'][] = "Error generating {$sitemap_config['type']} sitemap: " . $e->getMessage();
3473 + $results['errors'][] = 'Error generating index sitemap: ' . $e->getMessage();
1307 3474 $results['success'] = false;
1308 3475 }
1309 3476 }
1310 3477
3478 + // Remove segments that are no longer part of the set. Publishing was
3479 + // purely additive: a type that dropped out (Categories unticked, a CPT
3480 + // that stopped qualifying) simply stopped being overwritten, so its file
3481 + // kept serving and — until the index happened to be rebuilt — kept being
3482 + // listed in it. The index above is built from the children actually
3483 + // generated, so pruning here leaves disk and index agreeing.
3484 + $this->prune_orphaned_segments($settings, $results['sitemaps_generated']);
3485 +
1311 3486 return $results;
1312 3487 }
1313 3488
1314 3489 /**
1315 - * Generate sitemap index XML
3490 + * Delete published segment files that this run did not write.
1316 3491 *
1317 - * @since 1.0.0
1318 - * @param array $sitemap_urls Array of sitemap configurations
1319 - * @return string Sitemap index XML
3492 + * Only filenames this site could have published under its own url pattern
3493 + * are considered, so another plugin's or core's sitemap in the web root is
3494 + * never a candidate — the same reason cleanup does not glob 'sitemap-*.xml'.
3495 + *
3496 + * @since 1.31.0
3497 + *
3498 + * @param array $settings Sitemap settings (read for `custom_url_pattern`).
3499 + * @param array $generated Entries from $results['sitemaps_generated'].
3500 + * @return string[] Basenames removed.
1320 3501 */
1321 - private function generate_sitemap_index(array $sitemap_urls): string {
1322 - $settings = $this->get_settings('site');
1323 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
3502 + private function prune_orphaned_segments(array $settings, array $generated): array {
3503 + // Rendering for a request, not publishing: there is nothing on disk
3504 + // this run owns, and a dynamic render must never delete the files a
3505 + // site's previous static mode left behind.
3506 + if ($this->is_collecting()) {
3507 + return [];
3508 + }
1324 3509
1325 - // Add XSL stylesheet only if styling is enabled
1326 - if (!empty($settings['enable_styling'])) {
1327 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap-index.xsl') . '"?>' . "\n";
3510 + $kept = [];
3511 + foreach ($generated as $entry) {
3512 + if (!empty($entry['filename'])) {
3513 + $kept[strtolower((string) $entry['filename'])] = true;
3514 + }
1328 3515 }
1329 - $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1330 3516
1331 - $site_url = home_url();
3517 + // The current mode's primary and the local business sitemap are written
3518 + // by their own paths and are never orphans here.
3519 + $primary = strtolower(basename($this->get_primary_sitemap_filename($settings)));
3520 + $kept[$primary] = true;
3521 + $kept['local-sitemap.xml'] = true;
1332 3522
1333 - foreach ($sitemap_urls as $sitemap_config) {
1334 - if (!$sitemap_config['enabled'] || $sitemap_config['type'] === 'index') {
3523 + // The OTHER mode's primary is an orphan the moment the mode changes:
3524 + // index mode leaves sitemap.xml behind, flat mode leaves
3525 + // sitemap_index.xml and its children. Both used to be kept
3526 + // unconditionally, so the site served two sitemap trees and only ever
3527 + // refreshed one (#563). The children are already covered by the segment
3528 + // sweep below, which now sees them because the index is no longer kept.
3529 + $stale_primaries = array_diff(['sitemap.xml', 'sitemap_index.xml'], [$primary]);
3530 +
3531 + $removed = [];
3532 +
3533 + global $wp_filesystem;
3534 + if (!$wp_filesystem) {
3535 + require_once ABSPATH . 'wp-admin/includes/file.php';
3536 + WP_Filesystem();
3537 + }
3538 + if (!$wp_filesystem) {
3539 + return $removed;
3540 + }
3541 +
3542 + $candidates = array_merge($this->publishable_segment_filenames($settings), $stale_primaries);
3543 +
3544 + foreach ($candidates as $candidate) {
3545 + if (isset($kept[strtolower($candidate)])) {
1335 3546 continue;
1336 3547 }
1337 3548
1338 - $sitemap_url = $sitemap_config['url'];
1339 - if (!str_starts_with($sitemap_url, 'http')) {
1340 - $sitemap_url = $site_url . $sitemap_url;
3549 + if (!preg_match('/^(.*)\.xml$/i', $candidate, $m)) {
3550 + continue;
1341 3551 }
1342 3552
1343 - $xml .= " <sitemap>\n";
1344 - $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
1345 - $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
1346 - $xml .= " </sitemap>\n";
3553 + // The base file plus its numeric pagination pages.
3554 + $paths = [ABSPATH . $candidate];
3555 + foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
3556 + if (preg_match('/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i', basename($paged))) {
3557 + $paths[] = $paged;
3558 + }
3559 + }
3560 +
3561 + foreach ($paths as $path) {
3562 + if (!file_exists($path)) {
3563 + continue;
3564 + }
3565 + // A name we could have published is not proof we published
3566 + // this file: RankMath and core write at the same paths (#515).
3567 + if (!$this->webroot_sitemap_is_ours($path, $settings)) {
3568 + continue;
3569 + }
3570 + if ($wp_filesystem->delete($path)) {
3571 + $removed[] = basename($path);
3572 + }
3573 + }
1347 3574 }
1348 3575
1349 - $xml .= '</sitemapindex>';
3576 + return $removed;
3577 + }
1350 3578
1351 - return $xml;
3579 +
3580 + /**
3581 + * Collect the full (un-paginated) entry list for a content sitemap type.
3582 + *
3583 + * @since 1.14.0
3584 + *
3585 + * @param string $type Sitemap config type (posts, pages, categories, …)
3586 + * @param array $settings Sitemap settings
3587 + * @return array{entries: array<string>, image_ns: bool}|null Null for an
3588 + * unknown type (caller falls back to a general sitemap).
3589 + */
3590 + /**
3591 + * Write (or remove) the physical local-sitemap.xml file.
3592 + *
3593 + * Called from every sitemap regeneration path — segmented generation, the
3594 + * single-sitemap auto-regeneration, and the manual generate endpoint — so
3595 + * the local sitemap works regardless of whether the site uses a sitemap
3596 + * index. When no business identity is configured, any stale file is removed.
3597 + *
3598 + * @since 1.15.x
3599 + * @param array|null $settings Sitemap settings (falls back to saved site settings).
3600 + * @return bool True when the file was written, false when nothing was written.
3601 + */
3602 + public function regenerate_local_sitemap(?array $settings = null): bool {
3603 + $settings = $settings ?? $this->get_settings('site');
3604 + $entries = $this->collect_local_entries();
3605 +
3606 + if (empty($entries)) {
3607 + // Business identity was cleared — drop the file we left from
3608 + // before, but only ours. `local-sitemap.xml` is the name Rank Math
3609 + // publishes under too (this method mirrors it deliberately), so on
3610 + // a migrated site the file at that path may never have been ours
3611 + // to delete (#515).
3612 + $path = ABSPATH . 'local-sitemap.xml';
3613 + if (!$this->is_collecting() && file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
3614 + wp_delete_file($path);
3615 + }
3616 + return false;
3617 + }
3618 +
3619 + $xml = $this->wrap_urlset($entries, $settings, false);
3620 + return $this->save_sitemap_to_file($xml, 'local-sitemap.xml');
1352 3621 }
1353 3622
1354 3623 /**
1355 - * Generate sitemap for specific post type
3624 + * Build the local business sitemap entries.
1356 3625 *
1357 - * @since 1.0.0
1358 - * @param string $post_type Post type to generate sitemap for
1359 - * @param array $settings Sitemap settings
1360 - * @return string Sitemap XML
3626 + * Returns a single URL entry pointing at the on-site page that carries the
3627 + * business's LocalBusiness/Organization schema (the configured business URL
3628 + * when it's on this site, otherwise the homepage). Gated — like Rank Math's
3629 + * `local-sitemap.xml` — on a business (non-person) type being configured
3630 + * with an actual location (address or geo coordinates). Returns an empty
3631 + * array when no business location is set, so no empty local sitemap is
3632 + * written or added to the index.
3633 + *
3634 + * @since 1.15.x
3635 + * @return array Zero or one URL entry
1361 3636 */
1362 - private function generate_post_sitemap(string $post_type, array $settings): string {
1363 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
3637 + /**
3638 + * Does this site publish a local business sitemap right now?
3639 + *
3640 + * The same gate {@see self::regenerate_local_sitemap()} applies, asked
3641 + * without writing anything. Callers that need to know whether the document
3642 + * exists must not test the filesystem: under dynamic delivery it is served
3643 + * from PHP and there is no file, which is how `local-sitemap.xml` came to be
3644 + * dropped from robots.txt on exactly those sites (#752).
3645 + *
3646 + * @since 2.9.0
3647 + *
3648 + * @return bool True when the local sitemap has content to publish.
3649 + */
3650 + public function publishes_local_sitemap(): bool {
3651 + return !empty($this->collect_local_entries());
3652 + }
1364 3653
1365 - // Add XSL stylesheet only if styling is enabled
1366 - if (!empty($settings['enable_styling'])) {
1367 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap.xsl') . '"?>' . "\n";
3654 + private function collect_local_entries(): array {
3655 + if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
3656 + require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
1368 3657 }
1369 3658
1370 - // Add image namespace if images are enabled
1371 - if (!empty($settings['include_images'])) {
1372 - $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
3659 + $identity = (new \ThinkRank\SEO\Site_Identity_Manager())->get_settings('site');
3660 +
3661 + // Gate like Rank Math's local sitemap: a business (non-person) identity
3662 + // is configured. We only emit a single URL (the location page), so a
3663 + // full postal address is NOT required — requiring one wrongly excluded
3664 + // migrated sites, since the Rank Math importer carries over business
3665 + // type/name/phone but not the address. Personal sites, and sites with no
3666 + // business identity at all, get no local sitemap.
3667 + $business_type = strtolower((string) ($identity['business_type'] ?? ''));
3668 + if ($business_type === 'person') {
3669 + return [];
3670 + }
3671 + if ($business_type === '' && empty($identity['business_name'])) {
3672 + return [];
3673 + }
3674 +
3675 + // Prefer the configured business URL when it points at this site;
3676 + // otherwise fall back to the homepage (which outputs the schema).
3677 + $location_url = home_url('/');
3678 + if (!empty($identity['business_url'])) {
3679 + $candidate = esc_url_raw((string) $identity['business_url']);
3680 + if ($candidate && wp_parse_url($candidate, PHP_URL_HOST) === wp_parse_url(home_url(), PHP_URL_HOST)) {
3681 + $location_url = $candidate;
3682 + }
3683 + }
3684 +
3685 + // Omit lastmod — no real modification source for the local business URL.
3686 + return [$this->generate_url_entry($location_url, '', 0.8, 'weekly')];
3687 + }
3688 +
3689 + /**
3690 + * Resolve a streaming entry source for a sitemap type.
3691 + *
3692 + * Returns an iterable that yields the type's <url> entries one at a time
3693 + * (so the paginator never materializes the whole type), the image-namespace
3694 + * flag, or null when the type isn't one we build (caller falls back to a
3695 + * single general sitemap). Entry order matches the previous array-based
3696 + * collect_type_entries() exactly.
3697 + *
3698 + * @param string $type Sitemap type.
3699 + * @param array $settings Sitemap settings.
3700 + * @return array{entries: iterable<string>, image_ns: bool}|null
3701 + */
3702 + private function stream_type_entries(string $type, array $settings): ?array {
3703 + switch ($type) {
3704 + case 'posts':
3705 + return ['entries' => $this->collect_post_entries_iter(['post'], $settings), 'image_ns' => true];
3706 +
3707 + case 'pages':
3708 + // The homepage lives in the pages sitemap (parity with prior
3709 + // output) and is emitted first; collect_post_entries_iter()
3710 + // already excludes the static front page, so there's no duplicate.
3711 + $entries = (function () use ($settings) {
3712 + yield $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
3713 + yield from $this->collect_post_entries_iter(['page'], $settings);
3714 + })();
3715 + return ['entries' => $entries, 'image_ns' => true];
3716 +
3717 + case 'categories':
3718 + return ['entries' => $this->collect_taxonomy_entries_iter('category', $settings), 'image_ns' => false];
3719 +
3720 + case 'tags':
3721 + return ['entries' => $this->collect_taxonomy_entries_iter('post_tag', $settings), 'image_ns' => false];
3722 +
3723 + case 'products':
3724 + $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
3725 + return ['entries' => $entries, 'image_ns' => true];
3726 +
3727 + default:
3728 + // Resolve a preset's display name to the object it streams, so
3729 + // 'product_categories' is an ordinary taxonomy child rather than
3730 + // a case of its own (#690).
3731 + $alias = self::CHILD_TYPE_ALIASES[$type] ?? null;
3732 + $object = $alias ?? $type;
3733 +
3734 + $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
3735 + if (in_array($object, $custom_post_types, true)) {
3736 + return ['entries' => $this->collect_post_entries_iter([$object], $settings), 'image_ns' => true];
3737 + }
3738 +
3739 + // Custom taxonomies reach index mode here, streamed through the
3740 + // same iterator flat mode uses so the two modes emit identical
3741 + // URLs for the same settings.
3742 + if (taxonomy_exists($object) && $this->should_include_taxonomy($object)) {
3743 + return ['entries' => $this->collect_taxonomy_entries_iter($object, $settings), 'image_ns' => false];
3744 + }
3745 +
3746 + // An aliased child whose object is gone (WooCommerce deactivated)
3747 + // keeps writing the empty file it always wrote. Returning null
3748 + // here would hand it the whole-site fallback instead, dumping
3749 + // every URL on the site into a file named for products.
3750 + //
3751 + // A registered taxonomy this generator will not emit
3752 + // (`post_format`, `nav_menu`, a non-public one) needs the same
3753 + // answer for the same reason. generate_multiple_sitemaps()
3754 + // skips those before they reach here, so nothing takes this
3755 + // path today — but it is the one branch where falling through
3756 + // to null is silently catastrophic rather than merely wrong,
3757 + // and the guard keeping it unreachable lives in another method.
3758 + if ($alias !== null || taxonomy_exists($object)) {
3759 + return ['entries' => [], 'image_ns' => false];
3760 + }
3761 +
3762 + return null;
3763 + }
3764 + }
3765 +
3766 + /**
3767 + * Write one sitemap page file and record it in the results + index child list.
3768 + *
3769 + * @since 1.14.0
3770 + *
3771 + * @param string $url Sitemap page URL
3772 + * @param string $xml Sitemap XML
3773 + * @param string $type Sitemap type (for reporting)
3774 + * @param int $url_count Number of URLs in this page
3775 + * @param array $results Results accumulator (by reference)
3776 + * @param array $index_children Index child list (by reference)
3777 + * @return void
3778 + */
3779 + private function write_sitemap_page(string $url, string $xml, string $type, int $url_count, array &$results, array &$index_children): void {
3780 + $filename = basename(wp_parse_url($url, PHP_URL_PATH));
3781 +
3782 + if ($this->save_sitemap_to_file($xml, $filename)) {
3783 + $results['sitemaps_generated'][] = [
3784 + 'url' => $url,
3785 + 'type' => $type,
3786 + 'filename' => $filename,
3787 + 'url_count' => $url_count,
3788 + ];
3789 + $results['total_urls'] += $url_count;
3790 + $index_children[] = ['url' => $url];
1373 3791 } else {
1374 - $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
3792 + $results['errors'][] = "Failed to save sitemap: {$filename}";
3793 + $results['success'] = false;
1375 3794 }
3795 + }
1376 3796
1377 - // Add homepage only for general sitemap
1378 - if ($post_type === 'page') {
1379 - $xml .= $this->generate_url_entry(home_url('/'), gmdate('c'), 1.0, 'daily');
3797 + /**
3798 + * Delete pagination files left over when a type shrinks to fewer pages.
3799 + *
3800 + * For a base URL like /sitemap-posts.xml, removes sitemap-posts-N.xml files
3801 + * whose page number N exceeds the current page count. Page 1 (the base file,
3802 + * which has no -N suffix) is never touched.
3803 + *
3804 + * @since 1.14.0
3805 + *
3806 + * @since 2.1.1 Each candidate must pass the content ownership test — a
3807 + * `-N.xml` page of another plugin's sitemap paginates our
3808 + * stem exactly as ours does (#515).
3809 + *
3810 + * @param string $base_url Base (page 1) sitemap URL
3811 + * @param int $current_pages Number of pages generated this run
3812 + * @param array $settings Sitemap settings, for the ownership test.
3813 + * @return void
3814 + */
3815 + private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
3816 + // See prune_orphaned_segments(): a dynamic render deletes nothing.
3817 + if ($this->is_collecting()) {
3818 + return;
1380 3819 }
1381 3820
1382 - // Add posts/pages of specific type
1383 - $xml .= $this->generate_post_entries($post_type, $settings);
3821 + $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
3822 + if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
3823 + return;
3824 + }
3825 + $stem = $m[1];
1384 3826
1385 - $xml .= '</urlset>';
1386 - return $xml;
3827 + global $wp_filesystem;
3828 + if (!$wp_filesystem) {
3829 + require_once ABSPATH . 'wp-admin/includes/file.php';
3830 + WP_Filesystem();
3831 + }
3832 + if (!$wp_filesystem) {
3833 + return;
3834 + }
3835 +
3836 + $candidates = glob(ABSPATH . $stem . '-*.xml') ?: [];
3837 + foreach ($candidates as $path) {
3838 + // Only delete numeric-suffixed pages beyond the current count.
3839 + if (preg_match('/-(\d+)\.xml$/', basename($path), $mm)
3840 + && (int) $mm[1] > $current_pages
3841 + && $this->webroot_sitemap_is_ours($path, $settings)) {
3842 + $wp_filesystem->delete($path);
3843 + }
3844 + }
1387 3845 }
1388 3846
1389 3847 /**
1390 - * Generate sitemap for specific taxonomy
3848 + * Sitemap URLs contributed by other plugins.
1391 3849 *
1392 - * @since 1.0.0
1393 - * @param string $taxonomy Taxonomy to generate sitemap for
3850 + * ThinkRank owns the sitemap index and the robots.txt `Sitemap:` lines, so a
3851 + * companion plugin that serves its own sitemap — Pro's news and video
3852 + * sitemaps, for instance — had no way to be discovered: it appeared in
3853 + * neither, leaving manual Search Console submission as the only route in
3854 + * (#104). Registering here puts a sitemap in the index when one exists, and
3855 + * in robots.txt when it does not.
3856 + *
3857 + * Callers get root-relative paths. Entries are normalised to a leading
3858 + * slash, de-duplicated, and anything that is not a non-empty string is
3859 + * dropped, so one badly-behaved callback cannot produce a malformed index.
3860 + *
3861 + * @since 2.3.1
3862 + *
3863 + * @return string[] Root-relative sitemap paths, e.g. ['/news-sitemap.xml'].
3864 + */
3865 + public static function additional_sitemaps(): array {
3866 + /**
3867 + * Filters the sitemaps contributed by other plugins.
3868 + *
3869 + * @since 2.3.1
3870 + *
3871 + * @param string[] $sitemaps Root-relative sitemap paths.
3872 + */
3873 + $sitemaps = apply_filters('thinkrank_additional_sitemaps', []);
3874 +
3875 + if (!is_array($sitemaps)) {
3876 + return [];
3877 + }
3878 +
3879 + // Both consumers resolve an entry with home_url(), which prefixes the
3880 + // install's own directory. Everything below is measured against that so
3881 + // an absolute URL is reduced to what home_url() will put back.
3882 + $home = wp_parse_url(home_url('/'));
3883 + $home_host = strtolower((string) ($home['host'] ?? ''));
3884 + $home_path = '/' . trim((string) ($home['path'] ?? ''), '/');
3885 +
3886 + $clean = [];
3887 + foreach ($sitemaps as $sitemap) {
3888 + if (!is_string($sitemap)) {
3889 + continue;
3890 + }
3891 +
3892 + $sitemap = trim($sitemap);
3893 + if ('' === $sitemap) {
3894 + continue;
3895 + }
3896 +
3897 + // A full URL on this site is accepted and reduced to the part
3898 + // home_url() does not already supply, so a caller that reached for
3899 + // home_url() still lands in the right place — including on a
3900 + // subdirectory install, where keeping the whole path would repeat
3901 + // the directory. A URL on another host is dropped rather than
3902 + // rewritten: the sitemaps protocol will not accept a cross-host
3903 + // child anyway, and reusing its path would advertise a URL on this
3904 + // site that does not exist.
3905 + if (preg_match('#^(https?:)?//#i', $sitemap)) {
3906 + $parts = wp_parse_url('//' === substr($sitemap, 0, 2) ? 'https:' . $sitemap : $sitemap);
3907 + if (!is_array($parts)) {
3908 + continue;
3909 + }
3910 +
3911 + if (strtolower((string) ($parts['host'] ?? '')) !== $home_host) {
3912 + continue;
3913 + }
3914 +
3915 + $path = (string) ($parts['path'] ?? '');
3916 + if ('' === $path) {
3917 + continue;
3918 + }
3919 +
3920 + if ('/' !== $home_path && ($path === $home_path || 0 === strpos($path, $home_path . '/'))) {
3921 + $path = substr($path, strlen($home_path));
3922 + }
3923 +
3924 + // A sitemap served from a query string keeps it; dropping the
3925 + // query would point at a different document.
3926 + $query = (string) ($parts['query'] ?? '');
3927 + $sitemap = $path . ('' !== $query ? '?' . $query : '');
3928 + }
3929 +
3930 + $clean[] = '/' . ltrim($sitemap, '/');
3931 + }
3932 +
3933 + return array_values(array_unique($clean));
3934 + }
3935 +
3936 + /**
3937 + * Generate sitemap index XML from the list of child sitemap files produced
3938 + * during generation (each already resolved to its final, possibly paginated,
3939 + * URL).
3940 + *
3941 + * @since 1.0.0 (signature updated 1.14.0)
3942 + * @param array $children Array of ['url' => string] child sitemap entries
1394 3943 * @param array $settings Sitemap settings
1395 - * @return string Sitemap XML
3944 + * @return string Sitemap index XML
1396 3945 */
1397 - private function generate_taxonomy_sitemap(string $taxonomy, array $settings): string {
1398 - $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
3946 + private function generate_sitemap_index(array $children, array $settings): string {
3947 + $xml = $this->xml_prolog($settings, 'index');
3948 + $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1399 3949
1400 - // Add XSL stylesheet only if styling is enabled
1401 - if (!empty($settings['enable_styling'])) {
1402 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap.xsl') . '"?>' . "\n";
3950 + $site_url = home_url();
3951 +
3952 + foreach ($children as $child) {
3953 + $sitemap_url = $child['url'] ?? '';
3954 + if ($sitemap_url === '') {
3955 + continue;
3956 + }
3957 + if (!str_starts_with($sitemap_url, 'http')) {
3958 + $sitemap_url = $site_url . $sitemap_url;
3959 + }
3960 +
3961 + $xml .= " <sitemap>\n";
3962 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($sitemap_url)) . "</loc>\n";
3963 + $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
3964 + $xml .= " </sitemap>\n";
1403 3965 }
1404 - $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1405 3966
1406 - // Add taxonomy terms
1407 - $xml .= $this->generate_taxonomy_entries($taxonomy, $settings);
3967 + $xml .= '</sitemapindex>';
1408 3968
1409 - $xml .= '</urlset>';
1410 3969 return $xml;
1411 3970 }
1412 3971
1413 3972 /**
@@ -1418,35 +3977,8 @@
1418 3977 * @return int Number of URLs
1419 3978 */
1420 3979 private function count_urls_in_xml(string $sitemap_xml): int {
1421 3980 return substr_count($sitemap_xml, '<url>');
1422 - }
1423 -
1424 - /**
1425 - * Ping search engines about sitemap update
1426 - *
1427 - * @since 1.0.0
1428 - * @return void
1429 - */
1430 - private function ping_search_engines(): void {
1431 - $sitemap_url = home_url('/sitemap.xml');
1432 -
1433 - $ping_urls = [
1434 - 'google' => 'https://www.google.com/ping?sitemap=' . urlencode($sitemap_url),
1435 - 'bing' => 'https://www.bing.com/ping?sitemap=' . urlencode($sitemap_url)
1436 - ];
1437 -
1438 - foreach ($ping_urls as $engine => $ping_url) {
1439 - // Use wp_remote_get with timeout to prevent hanging
1440 - $response = wp_remote_get($ping_url, [
1441 - 'timeout' => 10,
1442 - 'blocking' => false // Non-blocking to prevent delays
1443 - ]);
1444 -
1445 - if (is_wp_error($response)) {
1446 - // Search engine ping failed - error details available in WP_Error response
1447 - }
1448 - }
1449 3981 }
1450 3982
1451 3983 /**
1452 3984 * Validate and sanitize exclude posts input