PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.4.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.4.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-sitemap-generator.php

class-sitemap-generator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.4.0, at includes/seo/class-sitemap-generator.php

3,328 lines 124.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sitemap Generator Class
4 *
5 * XML sitemap generation and management for search engine optimization.
6 * Implements 2025 SEO best practices with real sitemap specifications,
7 * dynamic content inclusion, and performance optimization.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
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
30 /**
31 * Sitemap Generator Class
32 *
33 * Generates and manages XML sitemaps for search engine optimization.
34 * Provides dynamic sitemap generation with content filtering, priority
35 * calculation, and change frequency optimization.
36 *
37 * @since 1.0.0
38 */
39 class Sitemap_Generator extends Abstract_SEO_Manager {
40
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 * Supported sitemap types
67 *
68 * @since 1.0.0
69 * @var array
70 */
71 /**
72 * How many post IDs to hydrate at a time while walking the sitemap set.
73 *
74 * @since 2.0.1
75 * @var int
76 */
77 private const ID_WALK_CHUNK = 500;
78
79 /**
80 * How long a content or settings change is debounced before the sitemap is
81 * rebuilt, in seconds. Coalesces bulk edits into a single regeneration.
82 *
83 * @since 2.2.1
84 * @var int
85 */
86 private const REGENERATION_DEBOUNCE = 30;
87
88 /**
89 * How far past its due time the scheduled rebuild may sit before a request
90 * takes it over, in seconds.
91 *
92 * WP-Cron is request-driven, so on a site running DISABLE_WP_CRON, blocking
93 * loopback requests, or seeing very little traffic the event never fires and
94 * the sitemap silently stops updating (#629). The grace keeps the fast path
95 * (cron) in charge under normal conditions.
96 *
97 * @since 2.2.1
98 * @var int
99 */
100 private const REGENERATION_TAKEOVER_GRACE = 120;
101
102 /**
103 * Longest backoff between takeover attempts after a failed rebuild, so a
104 * persistently failing generation cannot run on every admin request.
105 *
106 * @since 2.2.1
107 * @var int
108 */
109 private const REGENERATION_MAX_BACKOFF = 3600;
110
111 /**
112 * Option holding the rebuild that content/settings changes are still waiting
113 * on: `since`, `source` ('content'|'settings'), `attempts`, `next_attempt`.
114 * Absent means the served sitemap is up to date with what triggered it.
115 *
116 * @since 2.2.1
117 * @var string
118 */
119 public const REGENERATION_PENDING_OPTION = 'thinkrank_sitemap_regeneration_pending';
120
121 /**
122 * Option holding the last automatic-regeneration failure (`message`,
123 * `source`, `time`), so the failure is visible instead of swallowed.
124 *
125 * @since 2.2.1
126 * @var string
127 */
128 public const REGENERATION_ERROR_OPTION = 'thinkrank_sitemap_regeneration_error';
129
130 /**
131 * Transient guarding against two generations running at once. Shared with
132 * Sitemap_Endpoint's manual generate route so an automatic rebuild and a
133 * manual one cannot write the same files concurrently.
134 *
135 * @since 2.2.1
136 * @var string
137 */
138 public const GENERATION_LOCK_TRANSIENT = 'thinkrank_sitemap_generation_lock';
139
140 /**
141 * How many term IDs to hydrate at a time while walking a taxonomy.
142 *
143 * @since 2.0.1
144 * @var int
145 */
146 private const TERM_WALK_CHUNK = 1000;
147
148 private array $sitemap_types = [
149 'posts' => [
150 'name' => 'Posts',
151 'post_types' => ['post'],
152 'priority' => 0.8,
153 'changefreq' => 'weekly'
154 ],
155 'pages' => [
156 'name' => 'Pages',
157 'post_types' => ['page'],
158 'priority' => 0.9,
159 'changefreq' => 'monthly'
160 ],
161 'categories' => [
162 'name' => 'Categories',
163 'taxonomy' => 'category',
164 'priority' => 0.6,
165 'changefreq' => 'weekly'
166 ],
167 'tags' => [
168 'name' => 'Tags',
169 'taxonomy' => 'post_tag',
170 'priority' => 0.4,
171 'changefreq' => 'monthly'
172 ]
173 ];
174
175 /**
176 * Constructor
177 *
178 * @since 1.0.0
179 *
180 * @param bool $register_hooks Optional. Whether to register the auto-generation
181 * hooks. Pass false for a read-only instance built
182 * solely to query settings — the hooks are bound to
183 * `$this`, so a second hook-registering instance
184 * would run `handle_content_change()` twice per save.
185 */
186 public function __construct(bool $register_hooks = true) {
187 parent::__construct('sitemap');
188
189 // Initialize auto-generation hooks
190 if ($register_hooks) {
191 $this->init_auto_generation_hooks();
192 }
193 }
194
195 /**
196 * Filter the args of a sitemap post query.
197 *
198 * Exists so integrations can widen what the sitemap sees — the multilingual
199 * manager uses it to include every language, since these queries otherwise
200 * run in whichever language happened to be active at generation time.
201 *
202 * @since 1.23.0
203 *
204 * @param array $args get_posts() arguments.
205 * @return array Filtered arguments.
206 */
207 private function filter_query_args(array $args): array {
208 /**
209 * Filter the arguments of a sitemap post query.
210 *
211 * @since 1.23.0
212 *
213 * @param array $args get_posts() arguments.
214 */
215 return (array) apply_filters('thinkrank_sitemap_query_args', $args);
216 }
217
218 /**
219 * Filter the args of a sitemap term query.
220 *
221 * @since 1.23.0
222 *
223 * @param array $args get_terms() arguments.
224 * @return array Filtered arguments.
225 */
226 private function filter_term_query_args(array $args): array {
227 /**
228 * Filter the arguments of a sitemap term query.
229 *
230 * @since 1.23.0
231 *
232 * @param array $args get_terms() arguments.
233 */
234 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
235 }
236
237 /**
238 * Initialize WordPress hooks for auto-generation
239 *
240 * @since 1.0.0
241 * @return void
242 */
243 private function init_auto_generation_hooks(): void {
244 // Content change hooks - use priority 20 to run after other plugins
245 add_action('save_post', [$this, 'handle_content_change'], 20, 2);
246 add_action('delete_post', [$this, 'handle_content_deletion'], 20);
247 add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
248 add_action('untrash_post', [$this, 'handle_content_change_by_id'], 20);
249
250 // Taxonomy change hooks
251 add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
252 add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
253 add_action('delete_term', [$this, 'handle_taxonomy_change'], 20, 3);
254
255 // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
256 // and thinkrank_regenerate_sitemap_settings) are registered at plugin
257 // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
258 // rather than here. A cron run never builds this class via the REST
259 // endpoint (no rest_api_init), so registering them in the constructor
260 // would leave the scheduled events with no listener at cron time.
261 }
262
263 /**
264 * Generate XML sitemap
265 *
266 * @since 1.0.0
267 *
268 * @param array $options Sitemap generation options
269 * @return string XML sitemap content
270 */
271 public function generate_sitemap(array $options = []): string {
272 $settings = $this->get_settings('site');
273
274 $xml = $this->xml_prolog($settings, 'sitemap.xsl');
275
276 // Add image namespace if images are enabled
277 if (!empty($settings['include_images'])) {
278 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
279 } else {
280 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
281 }
282
283 // Add homepage. Use the static front page's real modified time when set,
284 // so lastmod reflects real changes rather than the generation time.
285 $xml .= $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
286
287 // MEMORY-EFFICIENT: Generate posts using chunked processing.
288 // Note: the single "general" sitemap includes every URL (no per-file cap);
289 // per-file chunking applies to the segmented/index model where each type
290 // gets its own paginated file (see generate_multiple_sitemaps).
291 $enabled_post_types = $this->get_enabled_post_types($settings);
292 if (!empty($enabled_post_types)) {
293 $xml .= implode('', $this->collect_post_entries($enabled_post_types, $settings));
294 }
295
296 // OPTIMIZED: Get all enabled taxonomies and fetch in single query
297 $enabled_taxonomies = $this->get_enabled_taxonomies($settings);
298 if (!empty($enabled_taxonomies)) {
299 $all_taxonomy_data = $this->fetch_taxonomies_optimized($enabled_taxonomies, $settings);
300
301 // Process each taxonomy's data
302 foreach ($enabled_taxonomies as $taxonomy) {
303 if (!empty($all_taxonomy_data[$taxonomy])) {
304 $xml .= $this->process_taxonomies_to_xml($all_taxonomy_data[$taxonomy], $taxonomy, $settings);
305 }
306 }
307 }
308
309 $xml .= '</urlset>';
310
311 return $xml;
312 }
313
314 /**
315 * Validate SEO settings (implements interface)
316 *
317 * @since 1.0.0
318 *
319 * @param array $settings Settings array to validate
320 * @return array Validation results
321 */
322 public function validate_settings(array $settings): array {
323 $validation = [
324 'valid' => true,
325 'errors' => [],
326 'warnings' => [],
327 'suggestions' => []
328 ];
329
330 try {
331 // Validate exclude_posts
332 if (!empty($settings['exclude_posts'])) {
333 $this->validate_exclude_posts($settings['exclude_posts']);
334 }
335
336 // Validate exclude_terms
337 if (!empty($settings['exclude_terms'])) {
338 $this->validate_exclude_terms($settings['exclude_terms']);
339 }
340
341 // Validate custom_url_pattern
342 if (!empty($settings['custom_url_pattern'])) {
343 $this->validate_custom_url_pattern($settings['custom_url_pattern']);
344 }
345
346 // Validate sitemap_urls
347 if (!empty($settings['sitemap_urls']) && is_array($settings['sitemap_urls'])) {
348 foreach ($settings['sitemap_urls'] as $sitemap) {
349 if (!empty($sitemap['url'])) {
350 $this->validate_sitemap_url($sitemap['url']);
351 }
352 }
353 }
354
355 // Validate numeric settings
356 if (isset($settings['links_per_sitemap'])) {
357 $this->validate_links_per_sitemap($settings['links_per_sitemap']);
358 }
359
360 } catch (InvalidArgumentException $e) {
361 $validation['valid'] = false;
362 $validation['errors'][] = $e->getMessage();
363 }
364
365 return $validation;
366 }
367
368 /**
369 * Get output data for frontend rendering (implements interface)
370 *
371 * @since 1.0.0
372 *
373 * @param string $context_type The context type
374 * @param int|null $context_id Optional. Context ID
375 * @return array Output data ready for frontend rendering
376 */
377 public function get_output_data(string $context_type, ?int $context_id): array {
378 $settings = $this->get_settings($context_type, $context_id);
379
380 return [
381 'sitemap_url' => home_url('/sitemap.xml'),
382 'enabled' => $settings['enabled'] ?? true,
383 'last_generated' => $settings['last_generated'] ?? '',
384 'total_urls' => $this->count_sitemap_urls($settings)
385 ];
386 }
387
388 /**
389 * Sitemap keys outside the defaults.
390 *
391 * @since 2.0.1
392 *
393 * @return string[]
394 */
395 protected function additional_setting_keys(): array {
396 return ['selected_preset'];
397 }
398
399 /**
400 * Inclusion flags are per post type and per taxonomy.
401 *
402 * A site registering a `product` post type stores `include_product`; an
403 * enumerated list would go stale on the next registration, so the family
404 * is matched instead.
405 *
406 * @since 2.0.1
407 *
408 * @return string[]
409 */
410 protected function dynamic_setting_key_patterns(): array {
411 return ['/^include_[a-z0-9_]+$/', '/^exclude_[a-z0-9_]+$/'];
412 }
413
414 /**
415 * Get default settings for a context type (implements interface)
416 *
417 * @since 1.0.0
418 *
419 * @param string $context_type The context type to get defaults for
420 * @return array Default settings array
421 */
422 public function get_default_settings(string $context_type): array {
423 return [
424 // Core settings
425 'enabled' => true,
426
427 // Multiple Sitemap URLs
428 'sitemap_urls' => [
429 [
430 'url' => '/sitemap.xml',
431 'type' => 'general',
432 'enabled' => true,
433 'last_checked' => null,
434 'status' => 'unknown'
435 ]
436 ],
437 'use_sitemap_index' => false,
438
439 // General Settings
440 'links_per_sitemap' => 1000,
441 'include_images' => true,
442 'include_featured_images' => false,
443 'auto_generate' => true,
444 'ping_search_engines' => true,
445
446 // Content Inclusion (backward compatibility)
447 'include_posts' => true,
448 'include_pages' => true,
449 'include_categories' => true,
450 'include_tags' => false,
451
452 // Content Filtering
453 'exclude_posts' => '',
454 'exclude_terms' => '',
455 'exclude_password_protected' => true,
456 'exclude_private_posts' => true,
457
458 // Advanced Options
459 'enable_styling' => true,
460 'custom_url_pattern' => 'sitemap-{type}.xml',
461
462 // Generation tracking
463 'last_generated' => ''
464 ];
465 }
466
467 /**
468 * Get settings schema definition (implements interface)
469 *
470 * @since 1.0.0
471 *
472 * @param string $context_type The context type to get schema for
473 * @return array Settings schema definition
474 */
475 public function get_settings_schema(string $context_type): array {
476 return [
477 'enabled' => [
478 'type' => 'boolean',
479 'title' => 'Enable Sitemap',
480 'description' => 'Generate XML sitemap for search engines',
481 'default' => true
482 ],
483 'include_posts' => [
484 'type' => 'boolean',
485 'title' => 'Include Posts',
486 'description' => 'Include blog posts in sitemap',
487 'default' => true
488 ],
489 'include_pages' => [
490 'type' => 'boolean',
491 'title' => 'Include Pages',
492 'description' => 'Include static pages in sitemap',
493 'default' => true
494 ],
495 'include_categories' => [
496 'type' => 'boolean',
497 'title' => 'Include Categories',
498 'description' => 'Include category pages in sitemap',
499 'default' => true
500 ],
501 'include_tags' => [
502 'type' => 'boolean',
503 'title' => 'Include Tags',
504 'description' => 'Include tag pages in sitemap',
505 'default' => false
506 ],
507
508 'auto_generate' => [
509 'type' => 'boolean',
510 'title' => 'Auto Generate',
511 'description' => 'Automatically regenerate sitemap when content changes',
512 'default' => true
513 ],
514 'ping_search_engines' => [
515 'type' => 'boolean',
516 'title' => 'Ping Search Engines',
517 'description' => 'Notify Google and Bing when sitemap is updated',
518 'default' => true
519 ],
520 'last_generated' => [
521 'type' => 'string',
522 'title' => 'Last Generated',
523 'description' => 'Timestamp of last sitemap generation',
524 'default' => ''
525 ]
526 ];
527 }
528
529 /**
530 * Generate URL entry for sitemap
531 *
532 * @since 1.0.0
533 *
534 * @param string $url URL
535 * @param string $lastmod Last modification date
536 * @param float $priority Priority (0.0 to 1.0)
537 * @param string $changefreq Change frequency
538 * @param array $images Optional array of image data
539 * @return string XML URL entry
540 */
541 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
542 $xml = " <url>\n";
543 $xml .= " <loc>" . esc_url($url) . "</loc>\n";
544 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
545 // than no timestamp, and an absent lastmod is valid per the spec.
546 if (!empty($lastmod)) {
547 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
548 }
549 $xml .= " <priority>" . number_format($priority, 1) . "</priority>\n";
550 $xml .= " <changefreq>" . esc_html($changefreq) . "</changefreq>\n";
551
552 // Add image entries if provided
553 foreach ($images as $image) {
554 $xml .= " <image:image>\n";
555 $xml .= " <image:loc>" . esc_url($image['url']) . "</image:loc>\n";
556
557 if (!empty($image['title'])) {
558 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
559 }
560
561 if (!empty($image['alt'])) {
562 $xml .= " <image:caption>" . esc_html($image['alt']) . "</image:caption>\n";
563 }
564
565 $xml .= " </image:image>\n";
566 }
567
568 $xml .= " </url>\n";
569
570 return $xml;
571 }
572
573 /**
574 * Collect every post <url> entry for the given post type(s) as an array of
575 * XML strings, using memory-efficient chunked fetching.
576 *
577 * Unlike the old capped generator, this returns ALL matching entries — the
578 * links-per-sitemap limit is applied later by paginating this array into
579 * separate files (see generate_multiple_sitemaps), matching how Rank Math
580 * splits large sitemaps instead of truncating them.
581 *
582 * @since 1.14.0
583 *
584 * @param array $post_types Array of post types to fetch
585 * @param array $settings Sitemap settings
586 * @return array<string> Individual <url>…</url> entry strings
587 */
588 /**
589 * lastmod for the homepage <url> entry: the static front page's real
590 * modification time when one is set, otherwise the generation time.
591 *
592 * @return string ISO-8601 date.
593 */
594 private function get_homepage_lastmod(): string {
595 if (get_option('show_on_front') === 'page') {
596 $front_id = (int) get_option('page_on_front');
597 if ($front_id) {
598 $modified = get_post_field('post_modified_gmt', $front_id);
599 if (!empty($modified) && $modified !== '0000-00-00 00:00:00') {
600 return gmdate('c', strtotime($modified));
601 }
602 }
603 }
604 return gmdate('c');
605 }
606
607 private function collect_post_entries(array $post_types, array $settings): array {
608 // Materialize the streaming source for callers that need the full set
609 // (e.g. the single un-paginated "general" sitemap). The paginated index
610 // path streams collect_post_entries_iter() directly to bound memory.
611 return iterator_to_array($this->collect_post_entries_iter($post_types, $settings), false);
612 }
613
614 /**
615 * Stream <url> entries for the given post types, yielding one at a time.
616 *
617 * Same chunked query, filtering, and ordering as before, but yields each
618 * entry instead of accumulating the whole set — so the paginated index
619 * generator never holds every URL of a large post type in memory at once.
620 *
621 * @param array $post_types Post types to include.
622 * @param array $settings Sitemap settings.
623 * @return \Generator<string> <url> entry strings.
624 */
625 private function collect_post_entries_iter(array $post_types, array $settings): \Generator {
626 if (empty($post_types)) {
627 return;
628 }
629
630 // Parse and validate exclude_posts setting
631 $exclude_ids = [];
632 if (!empty($settings['exclude_posts'])) {
633 try {
634 $exclude_ids = $this->validate_exclude_posts($settings['exclude_posts']);
635 } catch (InvalidArgumentException $e) {
636 // Continue with empty array on validation failure - error details available in exception
637 }
638 }
639
640 // The static front page is emitted once as the explicit homepage entry,
641 // so exclude it here to avoid a duplicate <loc> (its permalink equals
642 // home_url('/')).
643 if (get_option('show_on_front') === 'page') {
644 $front_id = (int) get_option('page_on_front');
645 if ($front_id) {
646 $exclude_ids[] = $front_id;
647 }
648 }
649
650 // Resolve the ordered ID list in one indexed query, then hydrate in
651 // chunks via post__in. This avoids large OFFSET windows (which MySQL
652 // must scan-and-discard, making a full walk O(n^2)) while still loading
653 // only one chunk of full post objects into memory at a time. The
654 // original order is preserved (the id query and post__in hydration both
655 // use it).
656 $all_ids = get_posts($this->filter_query_args([
657 'post_type' => $post_types,
658 'post_status' => 'publish',
659 'numberposts' => -1,
660 'exclude' => $exclude_ids,
661 'orderby' => 'post_type post_date',
662 'order' => 'ASC DESC',
663 'fields' => 'ids',
664 ]));
665
666 if (empty($all_ids)) {
667 return;
668 }
669
670 // Walk the ID list with a moving window rather than array_chunk().
671 // array_chunk() builds a second array holding every element again, so
672 // peak memory was twice the ID list — on a 100k-post site that is ~16MB
673 // where ~8MB is needed, and this walk is the one part of an otherwise
674 // well-bounded routine with no ceiling (#402).
675 $total = count($all_ids);
676
677 for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
678 $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
679
680 $posts = get_posts($this->filter_query_args([
681 'post_type' => $post_types,
682 'post_status' => 'publish',
683 'numberposts' => count($chunk),
684 'post__in' => $chunk,
685 'orderby' => 'post__in', // preserve the resolved order
686 ]));
687
688 foreach ($posts as $post) {
689 if ($this->should_include_in_sitemap($post, $settings)) {
690 /**
691 * Filter a sitemap entry's permalink.
692 *
693 * The multilingual manager uses this to generate each
694 * translation's URL in its OWN language: the sitemap query
695 * deliberately runs with suppress_filters, and the cron
696 * rebuild runs with no language context at all, so a bare
697 * get_permalink() resolved every translation to the
698 * default-language URL — N entries sharing one <loc> (#409).
699 *
700 * @since 2.0.1
701 * @param string $url Permalink as WordPress resolved it.
702 * @param \WP_Post $post Post the entry describes.
703 */
704 $url = apply_filters('thinkrank_sitemap_post_permalink', get_permalink($post), $post);
705 $lastmod = gmdate('c', strtotime($post->post_modified_gmt));
706 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
707 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
708 $images = $this->extract_post_images($post, $settings);
709
710 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
711 }
712 }
713
714 // Free the hydrated chunk before loading the next one.
715 unset($posts);
716 }
717 }
718
719 /**
720 * Resolve and bound the configured links-per-sitemap limit.
721 *
722 * @since 1.14.0
723 *
724 * @param array $settings Sitemap settings
725 * @return int Links per sitemap file (1–50000)
726 */
727 private function get_links_per_sitemap(array $settings): int {
728 $limit = !empty($settings['links_per_sitemap']) ? intval($settings['links_per_sitemap']) : 1000;
729
730 return max(1, min(50000, $limit));
731 }
732
733 /**
734 * Stream <url> entries for a taxonomy's terms, yielding one at a time and
735 * fetching terms in bounded chunks (number/offset) — so the paginated index
736 * generator never holds every term of a large taxonomy in memory at once.
737 *
738 * @param string $taxonomy Taxonomy name.
739 * @param array $settings Sitemap settings.
740 * @return \Generator<string> <url> entry strings.
741 */
742 private function collect_taxonomy_entries_iter(string $taxonomy, array $settings): \Generator {
743 $exclude_term_ids = [];
744 if (!empty($settings['exclude_terms'])) {
745 try {
746 $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
747 } catch (InvalidArgumentException $e) {
748 // Continue with an empty exclude list on validation failure.
749 }
750 }
751
752 // product_cat may legitimately have empty terms (products added later);
753 // every other taxonomy hides empties — matching fetch_taxonomies_optimized().
754 $hide_empty = $taxonomy !== 'product_cat';
755 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
756
757 // Resolve the ordered term IDs in one query, then hydrate in chunks via
758 // include. Avoids large OFFSET windows (O(n^2) over a full walk) while
759 // holding only one chunk of full term objects at a time.
760 $all_ids = get_terms($this->filter_term_query_args([
761 'taxonomy' => $taxonomy,
762 'hide_empty' => $hide_empty,
763 'exclude' => $exclude_term_ids,
764 'orderby' => 'count',
765 'order' => 'DESC',
766 'fields' => 'ids',
767 ]));
768
769 if (is_wp_error($all_ids) || empty($all_ids)) {
770 return;
771 }
772
773 // Same moving window as the post walk above, for the same reason.
774 $total = count($all_ids);
775
776 for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
777 $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
778
779 $terms = get_terms($this->filter_term_query_args([
780 'taxonomy' => $taxonomy,
781 'include' => $chunk,
782 'orderby' => 'include', // preserve the resolved order
783 'hide_empty' => false, // already filtered by the id query
784 ]));
785
786 if (is_wp_error($terms) || empty($terms)) {
787 continue;
788 }
789
790 foreach ($terms as $term) {
791 // A term the user marked noindex must not be advertised in the
792 // sitemap: the robots tag now honours term meta, so listing it
793 // here would have the sitemap contradict the page's own tag.
794 if ($this->term_is_noindexed((int) $term->term_id)) {
795 continue;
796 }
797
798 $url = get_term_link($term);
799 if (!is_wp_error($url)) {
800 // Omit lastmod for terms — the generation time is not a real
801 // modification time and would mislabel every term as just-changed.
802 yield $this->generate_url_entry($url, '', $priority, 'weekly');
803 }
804 }
805
806 unset($terms);
807 }
808 }
809
810 /**
811 * The XML declaration, ownership marker and optional stylesheet every
812 * sitemap document opens with.
813 *
814 * The marker is written unconditionally, and that is the point: removal on
815 * deactivate and uninstall deletes a web-root sitemap only when the file
816 * says it is ours, and our filenames are the canonical ones another SEO
817 * plugin writes too (#515). Tying the proof to `enable_styling` — the one
818 * marker older versions left — would mean a site with styling off either
819 * kept a shadowing file behind (#510) or had a competitor's deleted.
820 *
821 * @since 2.1.1
822 *
823 * @param array $settings Sitemap settings (read for `enable_styling`).
824 * @param string $stylesheet Stylesheet basename in static/xsl/.
825 * @return string Prolog lines, newline-terminated.
826 */
827 private function xml_prolog(array $settings, string $stylesheet): string {
828 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
829 $xml .= THINKRANK_SITEMAP_MARKER . "\n";
830
831 // The stylesheet is presentation only, so it stays opt-in.
832 if (!empty($settings['enable_styling'])) {
833 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/' . $stylesheet) . '"?>' . "\n";
834 }
835
836 return $xml;
837 }
838
839 /**
840 * Wrap a set of <url> entry strings in a complete <urlset> document.
841 *
842 * @since 1.14.0
843 *
844 * @param array<string> $entries Entry strings
845 * @param array $settings Sitemap settings
846 * @param bool $with_image_ns Include the image sitemap namespace
847 * @return string Full sitemap XML
848 */
849 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
850 $xml = $this->xml_prolog($settings, 'sitemap.xsl');
851
852 if ($with_image_ns && !empty($settings['include_images'])) {
853 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
854 } else {
855 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
856 }
857
858 $xml .= implode('', $entries);
859 $xml .= '</urlset>';
860
861 return $xml;
862 }
863
864 /**
865 * Derive the filename/URL for a given pagination page.
866 *
867 * Page 1 keeps the base URL (e.g. /sitemap-posts.xml); pages 2+ insert the
868 * page number before the extension (e.g. /sitemap-posts-2.xml).
869 *
870 * @since 1.14.0
871 *
872 * @param string $url Base sitemap URL
873 * @param int $page 1-based page number
874 * @return string Paginated URL
875 */
876 private function paginate_url(string $url, int $page): string {
877 if ($page <= 1) {
878 return $url;
879 }
880
881 return (string) preg_replace('/\.xml$/i', '-' . $page . '.xml', $url);
882 }
883
884
885
886 /**
887 * Extract images from post for sitemap
888 *
889 * @since 1.0.0
890 *
891 * @param \WP_Post $post Post object
892 * @param array $settings Sitemap settings
893 * @return array Array of image data
894 */
895 private function extract_post_images(\WP_Post $post, array $settings): array {
896 $images = [];
897
898 // Skip if images are disabled
899 if (empty($settings['include_images'])) {
900 return $images;
901 }
902
903 // Get featured image if enabled
904 if (!empty($settings['include_featured_images'])) {
905 $featured_image = $this->get_featured_image($post->ID);
906 if ($featured_image) {
907 $images[] = $featured_image;
908 }
909 }
910
911 // Extract images from content
912 $content_images = $this->extract_content_images($post->post_content);
913 $images = array_merge($images, $content_images);
914
915 // Remove duplicates based on URL
916 $unique_images = [];
917 $seen_urls = [];
918 foreach ($images as $image) {
919 if (!in_array($image['url'], $seen_urls, true)) {
920 $unique_images[] = $image;
921 $seen_urls[] = $image['url'];
922 }
923 }
924
925 return $unique_images;
926 }
927
928 /**
929 * Get featured image data
930 *
931 * @since 1.0.0
932 *
933 * @param int $post_id Post ID
934 * @return array|null Featured image data or null
935 */
936 private function get_featured_image(int $post_id): ?array {
937 $thumbnail_id = get_post_thumbnail_id($post_id);
938 if (!$thumbnail_id) {
939 return null;
940 }
941
942 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
943 if (!$image_url) {
944 return null;
945 }
946
947 $image_title = get_the_title($thumbnail_id);
948 $image_alt = get_post_meta($thumbnail_id, '_wp_attachment_image_alt', true);
949
950 return [
951 'url' => $image_url,
952 'title' => $image_title ?: '',
953 'alt' => $image_alt ?: ''
954 ];
955 }
956
957 /**
958 * Extract images from post content
959 *
960 * @since 1.0.0
961 *
962 * @param string $content Post content
963 * @return array Array of image data
964 */
965 private function extract_content_images(string $content): array {
966 $images = [];
967
968 // Find all img tags in content
969 preg_match_all('/<img[^>]+>/i', $content, $img_tags);
970
971 foreach ($img_tags[0] as $img_tag) {
972 // Extract src attribute
973 if (preg_match('/src=["\']([^"\']+)["\']/', $img_tag, $src_match)) {
974 $image_url = $src_match[1];
975
976 // Skip if not a valid URL or external image
977 if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
978 continue;
979 }
980
981 // Extract title and alt attributes
982 $title = '';
983 $alt = '';
984
985 if (preg_match('/title=["\']([^"\']*)["\']/', $img_tag, $title_match)) {
986 $title = $title_match[1];
987 }
988
989 if (preg_match('/alt=["\']([^"\']*)["\']/', $img_tag, $alt_match)) {
990 $alt = $alt_match[1];
991 }
992
993 $images[] = [
994 'url' => $image_url,
995 'title' => $title,
996 'alt' => $alt
997 ];
998 }
999 }
1000
1001 return $images;
1002 }
1003
1004 /**
1005 * Get enabled taxonomies based on settings
1006 *
1007 * @since 1.0.0
1008 * @param array $settings Sitemap settings
1009 * @return array Array of enabled taxonomies
1010 */
1011 private function get_enabled_taxonomies(array $settings): array {
1012 $taxonomies = [];
1013
1014 // Core taxonomies based on settings
1015 if (!empty($settings['include_categories'])) { // �
1016 Evidence-based field name
1017 $taxonomies[] = 'category';
1018 }
1019
1020 if (!empty($settings['include_tags'])) { // �
1021 Evidence-based field name
1022 $taxonomies[] = 'post_tag';
1023 }
1024
1025 // Auto-detect public custom taxonomies
1026 $custom_taxonomies = get_taxonomies([
1027 'public' => true,
1028 '_builtin' => false
1029 ], 'names');
1030
1031 foreach ($custom_taxonomies as $taxonomy) {
1032 if ($this->should_include_taxonomy($taxonomy)) {
1033 $taxonomies[] = $taxonomy;
1034 }
1035 }
1036
1037 return array_unique($taxonomies);
1038 }
1039
1040
1041
1042 /**
1043 * Fetch taxonomies using optimized combined query
1044 *
1045 * @since 1.0.0
1046 *
1047 * @param array $taxonomies Array of taxonomy names to fetch
1048 * @param array $settings Sitemap settings
1049 * @return array Grouped terms by taxonomy
1050 */
1051 private function fetch_taxonomies_optimized(array $taxonomies, array $settings): array {
1052 if (empty($taxonomies)) {
1053 return [];
1054 }
1055
1056 // Parse and validate exclude_terms setting
1057 $exclude_term_ids = [];
1058 if (!empty($settings['exclude_terms'])) {
1059 try {
1060 $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
1061 } catch (InvalidArgumentException $e) {
1062 // Continue with empty array on validation failure - error details available in exception
1063 }
1064 }
1065
1066 // Determine hide_empty setting based on taxonomies
1067 $hide_empty = true;
1068 foreach ($taxonomies as $taxonomy) {
1069 // For product categories, don't hide empty categories since they might not have products yet
1070 if ($taxonomy === 'product_cat') {
1071 $hide_empty = false;
1072 break;
1073 }
1074 }
1075
1076 // OPTIMIZED: Single query for multiple taxonomies
1077 $all_terms = get_terms($this->filter_term_query_args([
1078 'taxonomy' => $taxonomies, // �
1079 Multiple taxonomies in single query
1080 'hide_empty' => $hide_empty,
1081 'exclude' => $exclude_term_ids,
1082 'orderby' => 'taxonomy count',
1083 'order' => 'ASC DESC' // Order by taxonomy ASC, then count DESC
1084 ]));
1085
1086 if (is_wp_error($all_terms)) {
1087 return [];
1088 }
1089
1090 // Drop terms the user marked noindex. This path feeds the single general
1091 // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
1092 // so both need the filter or the two disagree about the same term.
1093 $all_terms = array_values(array_filter(
1094 $all_terms,
1095 fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1096 ));
1097
1098 // Group terms by taxonomy
1099 return $this->group_terms_by_taxonomy($all_terms);
1100 }
1101
1102 /**
1103 * Group terms by taxonomy
1104 *
1105 * @since 1.0.0
1106 *
1107 * @param array $terms Array of term objects
1108 * @return array Grouped terms by taxonomy
1109 */
1110 private function group_terms_by_taxonomy(array $terms): array {
1111 $grouped = [];
1112
1113 foreach ($terms as $term) {
1114 $taxonomy = $term->taxonomy; // �
1115 Evidence-based property name
1116
1117 if (!isset($grouped[$taxonomy])) {
1118 $grouped[$taxonomy] = [];
1119 }
1120
1121 $grouped[$taxonomy][] = $term;
1122 }
1123
1124 return $grouped;
1125 }
1126
1127 /**
1128 * Process taxonomy terms to XML entries
1129 *
1130 * @since 1.0.0
1131 *
1132 * @param array $terms Array of term objects
1133 * @param string $taxonomy Taxonomy name
1134 * @param array $settings Sitemap settings
1135 * @return string XML entries
1136 */
1137 private function process_taxonomies_to_xml(array $terms, string $taxonomy, array $settings): string {
1138 $xml = '';
1139
1140 foreach ($terms as $term) {
1141 $url = get_term_link($term);
1142 if (!is_wp_error($url)) {
1143 // Omit lastmod for terms (generation time is not a real
1144 // modification time).
1145 $lastmod = '';
1146 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
1147 $changefreq = 'weekly';
1148
1149 $xml .= $this->generate_url_entry($url, $lastmod, $priority, $changefreq);
1150 }
1151 }
1152
1153 return $xml;
1154 }
1155
1156 /**
1157 * Calculate intelligent priority based on content factors
1158 *
1159 * @since 1.0.0
1160 *
1161 * @param \WP_Post $post Post object
1162 * @param string $post_type Post type
1163 * @return float Priority value between 0.1 and 1.0
1164 */
1165 private function calculate_intelligent_priority(\WP_Post $post, string $post_type): float {
1166 $base_priority = $post_type === 'page' ? 0.9 : 0.8;
1167
1168 // Factors that can adjust priority
1169 $adjustments = 0;
1170
1171 // Recent content gets higher priority
1172 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
1173 if ($days_old < 30) {
1174 $adjustments += 0.1; // Recent content boost
1175 } elseif ($days_old > 365) {
1176 $adjustments -= 0.1; // Older content penalty
1177 }
1178
1179 // Content length factor
1180 $content_length = strlen(wp_strip_all_tags($post->post_content));
1181 if ($content_length > 2000) {
1182 $adjustments += 0.05; // Comprehensive content boost
1183 } elseif ($content_length < 500) {
1184 $adjustments -= 0.1; // Thin content penalty
1185 }
1186
1187 // Special page types get higher priority
1188 if ($post_type === 'page') {
1189 $page_template = get_page_template_slug($post->ID);
1190 if (in_array($page_template, ['page-home.php', 'front-page.php'], true) ||
1191 (int) $post->ID === (int) get_option('page_on_front')) {
1192 $base_priority = 1.0; // Homepage gets maximum priority
1193 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1194 $adjustments += 0.05; // Important pages boost
1195 }
1196 }
1197
1198 // Ensure priority stays within valid range
1199 $final_priority = max(0.1, min(1.0, $base_priority + $adjustments));
1200
1201 return round($final_priority, 1);
1202 }
1203
1204 /**
1205 * Calculate change frequency based on content type and age
1206 *
1207 * @since 1.0.0
1208 *
1209 * @param \WP_Post $post Post object
1210 * @param string $post_type Post type
1211 * @return string Change frequency
1212 */
1213 private function calculate_change_frequency(\WP_Post $post, string $post_type): string {
1214 // Pages typically change less frequently
1215 if ($post_type === 'page') {
1216 $page_template = get_page_template_slug($post->ID);
1217 if ((int) $post->ID === (int) get_option('page_on_front')) {
1218 return 'daily'; // Homepage changes frequently
1219 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1220 return 'monthly'; // Static pages change monthly
1221 }
1222 return 'yearly'; // Other pages change rarely
1223 }
1224
1225 // Posts frequency based on age and type
1226 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
1227
1228 if ($days_old < 7) {
1229 return 'daily'; // Very recent posts
1230 } elseif ($days_old < 30) {
1231 return 'weekly'; // Recent posts
1232 } elseif ($days_old < 365) {
1233 return 'monthly'; // Older posts
1234 }
1235
1236 return 'yearly'; // Very old posts
1237 }
1238
1239 /**
1240 * Determine if content should be included in sitemap
1241 *
1242 * @since 1.0.0
1243 *
1244 * @param \WP_Post $post Post object
1245 * @param array $settings Sitemap settings
1246 * @return bool Whether to include in sitemap
1247 */
1248 private function should_include_in_sitemap(\WP_Post $post, array $settings): bool {
1249 // The WooCommerce cart, checkout and account pages are transactional,
1250 // never indexable, and generate_default_robots_rules() already emits a
1251 // Disallow for each of them. Listing them here submitted URLs our own
1252 // robots.txt blocks, which Search Console reports as "Submitted URL
1253 // blocked by robots.txt". Yoast and Rank Math exclude the same three.
1254 if (in_array($post->ID, $this->woocommerce_excluded_page_ids(), true)) {
1255 return false;
1256 }
1257
1258 // Respect user setting for password protected content
1259 if (!empty($post->post_password) && !empty($settings['exclude_password_protected'])) {
1260 return false;
1261 }
1262
1263 // Respect user setting for private posts
1264 if ($post->post_status === 'private' && !empty($settings['exclude_private_posts'])) {
1265 return false;
1266 }
1267
1268 // Only published (and, per setting, private) content belongs in the
1269 // sitemap. Content-quality heuristics (length, "demo"/"test"/"sample"
1270 // in the title, "lorem ipsum" text) were intentionally removed: an XML
1271 // sitemap should list every indexable published URL. Filtering by
1272 // description length silently dropped legitimate WooCommerce products
1273 // with short descriptions, and the substring title match excluded real
1274 // pages such as "Demo" or "Product Samples". Indexability is governed
1275 // by noindex directives below, not by heuristics.
1276 if (!in_array($post->post_status, ['publish', 'private'], true)) {
1277 return false;
1278 }
1279
1280 // Check if post overrides robots and sets noindex.
1281 if ((bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true)) {
1282 $raw = get_post_meta($post->ID, '_thinkrank_robots_meta', true);
1283 if (is_string($raw) && $raw !== '') {
1284 $robots = json_decode($raw, true);
1285 if (is_array($robots) && !empty($robots['noindex'])) {
1286 return false;
1287 }
1288 }
1289 }
1290
1291 return true;
1292 }
1293
1294 /**
1295 * WooCommerce pages that must never reach the sitemap.
1296 *
1297 * Resolved through wc_get_page_id() so a store that moved or renamed its
1298 * cart/checkout/account pages is still matched. Returns an empty list when
1299 * WooCommerce is not active. Memoised — should_include_in_sitemap() runs
1300 * once per post.
1301 *
1302 * @since 2.0.1
1303 *
1304 * @return int[] Page IDs to exclude.
1305 */
1306 private function woocommerce_excluded_page_ids(): array {
1307 if ($this->woocommerce_excluded_page_ids !== null) {
1308 return $this->woocommerce_excluded_page_ids;
1309 }
1310
1311 $ids = [];
1312
1313 if (function_exists('wc_get_page_id')) {
1314 foreach (['cart', 'checkout', 'myaccount'] as $page) {
1315 $id = (int) wc_get_page_id($page);
1316 // wc_get_page_id() returns -1 when the page is not configured.
1317 if ($id > 0) {
1318 $ids[] = $id;
1319 }
1320 }
1321 }
1322
1323 $this->woocommerce_excluded_page_ids = $ids;
1324
1325 return $ids;
1326 }
1327
1328 /**
1329 * Whether a term carries an explicit noindex override.
1330 *
1331 * Mirrors the post-side check in should_include_post(); terms store the same
1332 * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1333 * by the update-term-seo ability and by the SEO importer.
1334 *
1335 * @since 1.31.0
1336 *
1337 * @param int $term_id Term to test.
1338 * @return bool True when the term is marked noindex.
1339 */
1340 private function term_is_noindexed(int $term_id): bool {
1341 if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1342 return false;
1343 }
1344
1345 $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1346 if (!is_string($raw) || $raw === '') {
1347 return false;
1348 }
1349
1350 $robots = json_decode($raw, true);
1351
1352 return is_array($robots) && !empty($robots['noindex']);
1353 }
1354
1355 /**
1356 * Count total URLs in sitemap
1357 *
1358 * @since 1.0.0
1359 *
1360 * @param array $settings Sitemap settings
1361 * @return int Total URL count
1362 */
1363 public function count_sitemap_urls(array $settings): int {
1364 $count = 1; // Homepage
1365
1366 if (!empty($settings['include_posts'])) {
1367 $count += wp_count_posts('post')->publish;
1368 }
1369
1370 if (!empty($settings['include_pages'])) {
1371 $count += wp_count_posts('page')->publish;
1372 }
1373
1374 if (!empty($settings['include_categories'])) {
1375 $count += wp_count_terms(['taxonomy' => 'category', 'hide_empty' => true]);
1376 }
1377
1378 if (!empty($settings['include_tags'])) {
1379 $count += wp_count_terms(['taxonomy' => 'post_tag', 'hide_empty' => true]);
1380 }
1381
1382 return $count;
1383 }
1384
1385 /**
1386 * Handle content changes for auto-generation
1387 *
1388 * @since 1.0.0
1389 * @param int $post_id Post ID
1390 * @param \WP_Post $post Post object
1391 * @return void
1392 */
1393 public function handle_content_change(int $post_id, \WP_Post $post): void {
1394 // Skip if auto-generation is disabled
1395 if (!$this->should_auto_generate()) {
1396 return;
1397 }
1398
1399 // Skip autosaves and revisions
1400 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
1401 return;
1402 }
1403
1404 // Only process published content
1405 if ($post->post_status !== 'publish') {
1406 return;
1407 }
1408
1409 // Check if this post type should trigger regeneration
1410 if (!$this->should_include_post_type($post->post_type)) {
1411 return;
1412 }
1413
1414 // Schedule debounced regeneration
1415 $this->schedule_debounced_regeneration();
1416 }
1417
1418 /**
1419 * Handle content deletion for auto-generation
1420 *
1421 * @since 1.0.0
1422 * @param int $post_id Post ID
1423 * @return void
1424 */
1425 public function handle_content_deletion(int $post_id): void {
1426 // Skip if auto-generation is disabled
1427 if (!$this->should_auto_generate()) {
1428 return;
1429 }
1430
1431 $post = get_post($post_id);
1432 if (!$post) {
1433 return;
1434 }
1435
1436 // Check if this post type should trigger regeneration
1437 if (!$this->should_include_post_type($post->post_type)) {
1438 return;
1439 }
1440
1441 // Schedule debounced regeneration
1442 $this->schedule_debounced_regeneration();
1443 }
1444
1445 /**
1446 * Handle content change by ID (for untrash, etc.)
1447 *
1448 * @since 1.0.0
1449 * @param int $post_id Post ID
1450 * @return void
1451 */
1452 public function handle_content_change_by_id(int $post_id): void {
1453 $post = get_post($post_id);
1454 if ($post) {
1455 $this->handle_content_change($post_id, $post);
1456 }
1457 }
1458
1459 /**
1460 * Handle taxonomy changes for auto-generation
1461 *
1462 * @since 1.0.0
1463 * @param int $term_id Term ID
1464 * @param int $tt_id Term taxonomy ID
1465 * @param string $taxonomy Taxonomy slug
1466 * @return void
1467 */
1468 public function handle_taxonomy_change(int $term_id, int $tt_id, string $taxonomy): void {
1469 // Skip if auto-generation is disabled
1470 if (!$this->should_auto_generate()) {
1471 return;
1472 }
1473
1474 // Check if this taxonomy should trigger regeneration
1475 if (!$this->should_include_taxonomy($taxonomy)) {
1476 return;
1477 }
1478
1479 // Schedule debounced regeneration
1480 $this->schedule_debounced_regeneration();
1481 }
1482
1483 /**
1484 * Check if auto-generation is enabled
1485 *
1486 * @since 1.0.0
1487 * @return bool True if auto-generation is enabled
1488 */
1489 private function should_auto_generate(): bool {
1490 $settings = $this->get_settings('site');
1491
1492 // Check if sitemap is enabled
1493 if (empty($settings['enabled'])) {
1494 return false;
1495 }
1496
1497 // Check if auto-generation is enabled
1498 return !empty($settings['auto_generate']);
1499 }
1500
1501 /**
1502 * Get enabled post types based on settings
1503 *
1504 * @since 1.0.0
1505 * @param array $settings Sitemap settings
1506 * @return array Array of enabled post types
1507 */
1508 private function get_enabled_post_types(array $settings): array {
1509 $post_types = [];
1510
1511 // Core post types based on settings
1512 if (!empty($settings['include_posts'])) {
1513 $post_types[] = 'post';
1514 }
1515
1516 if (!empty($settings['include_pages'])) {
1517 $post_types[] = 'page';
1518 }
1519
1520 // Auto-detect public custom post types that should be included
1521 $custom_post_types = get_post_types([
1522 'public' => true,
1523 '_builtin' => false
1524 ], 'names');
1525
1526 foreach ($custom_post_types as $post_type) {
1527 if ($this->should_include_post_type($post_type)) {
1528 $post_types[] = $post_type;
1529 }
1530 }
1531
1532 return array_unique($post_types);
1533 }
1534
1535 /**
1536 * Check if post type should trigger regeneration
1537 *
1538 * @since 1.0.0
1539 * @param string $post_type Post type
1540 * @return bool True if post type should trigger regeneration
1541 */
1542 private function should_include_post_type(string $post_type): bool {
1543 // Match Rank Math: only publicly viewable post types belong in the
1544 // sitemap (public && publicly_queryable). Additionally skip post types
1545 // that opt out of front-end search (exclude_from_search => true) — e.g.
1546 // Templately's internal `templately_library` store — which are template
1547 // records, not standalone indexable URLs. BetterDocs `docs` and
1548 // WooCommerce `product` register exclude_from_search => false, so they
1549 // remain included.
1550 if (!is_post_type_viewable($post_type)) {
1551 return false;
1552 }
1553
1554 $post_type_obj = get_post_type_object($post_type);
1555 if (!$post_type_obj || !empty($post_type_obj->exclude_from_search)) {
1556 return false;
1557 }
1558
1559 return true;
1560 }
1561
1562 /**
1563 * Check if taxonomy should trigger regeneration
1564 *
1565 * @since 1.0.0
1566 * @param string $taxonomy Taxonomy slug
1567 * @return bool True if taxonomy should trigger regeneration
1568 */
1569 private function should_include_taxonomy(string $taxonomy): bool {
1570 // Include all public taxonomies (presets control which sitemaps are created)
1571 $taxonomy_obj = get_taxonomy($taxonomy);
1572 return $taxonomy_obj && $taxonomy_obj->public;
1573 }
1574
1575 /**
1576 * Schedule debounced sitemap regeneration
1577 *
1578 * @since 1.0.0
1579 * @return void
1580 */
1581 private function schedule_debounced_regeneration(): void {
1582 $this->mark_regeneration_pending('content');
1583 $this->debounce_event('thinkrank_regenerate_sitemap');
1584 }
1585
1586 /**
1587 * Schedule (or keep) the debounced single event behind a regeneration hook.
1588 *
1589 * An event that is already due is left alone. WP-Cron only runs when a
1590 * request arrives, so on a site with DISABLE_WP_CRON, a blocked loopback or
1591 * little traffic an overdue event can sit in the queue for a long time —
1592 * clearing and re-scheduling it on every save pushed the rebuild
1593 * permanently 30 seconds into the future and the sitemap never updated
1594 * (#629). Debouncing only against an event that has not come due yet keeps
1595 * the bulk-edit coalescing without starving the rebuild.
1596 *
1597 * @since 2.2.1
1598 * @param string $hook Regeneration hook to debounce.
1599 * @return void
1600 */
1601 private function debounce_event(string $hook): void {
1602 $next = wp_next_scheduled($hook);
1603
1604 if ($next !== false) {
1605 if ($next <= time()) {
1606 return;
1607 }
1608
1609 wp_clear_scheduled_hook($hook);
1610 }
1611
1612 wp_schedule_single_event(time() + self::REGENERATION_DEBOUNCE, $hook);
1613 }
1614
1615 /**
1616 * Record that a rebuild is outstanding, so an overdue one can be taken over
1617 * by a later request and its staleness surfaced in the UI.
1618 *
1619 * `since` is the *oldest* outstanding change: it is what the takeover grace
1620 * and the admin staleness warning are measured from, so successive edits
1621 * must not push it forward. A settings change outranks a content change —
1622 * it rebuilds regardless of the auto_generate toggle and handles a sitemap
1623 * that has just been disabled — so once one is outstanding it stays the
1624 * recorded source until the rebuild lands.
1625 *
1626 * @since 2.2.1
1627 * @param string $source Either 'content' or 'settings'.
1628 * @return void
1629 */
1630 private function mark_regeneration_pending(string $source): void {
1631 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1632 $pending = is_array($pending) ? $pending : [];
1633
1634 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1635 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1636 $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1637
1638 update_option(
1639 self::REGENERATION_PENDING_OPTION,
1640 [
1641 'since' => $since,
1642 'source' => $source,
1643 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
1644 'next_attempt' => !empty($pending['next_attempt'])
1645 ? (int) $pending['next_attempt']
1646 : time() + self::REGENERATION_TAKEOVER_GRACE,
1647 // Bumped on every change so a rebuild can tell whether the edit
1648 // it started for is still the newest one outstanding.
1649 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
1650 ],
1651 true
1652 );
1653 }
1654
1655 /**
1656 * The revision of the outstanding rebuild, for
1657 * {@see mark_regeneration_complete()} to compare against once it is done.
1658 *
1659 * @since 2.2.1
1660 * @return int Current revision, 0 when nothing is outstanding.
1661 */
1662 private function current_regeneration_revision(): int {
1663 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1664
1665 return (is_array($pending) && !empty($pending['revision'])) ? (int) $pending['revision'] : 0;
1666 }
1667
1668 /**
1669 * Clear the outstanding-rebuild marker and any recorded failure.
1670 *
1671 * Public because a manual generation satisfies whatever the automatic path
1672 * was still waiting to write.
1673 *
1674 * @since 2.2.1
1675 * @return void
1676 */
1677 public function mark_regeneration_complete(?int $revision = null): void {
1678 // The write succeeded, so whatever failure was on record is history.
1679 if (get_option(self::REGENERATION_ERROR_OPTION, null) !== null) {
1680 delete_option(self::REGENERATION_ERROR_OPTION);
1681 }
1682
1683 $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
1684
1685 if ($pending === null) {
1686 return;
1687 }
1688
1689 // A change that landed while this rebuild was running is not covered by
1690 // the files it just wrote, so it has to stay outstanding — otherwise, on
1691 // a site where WP-Cron never fires, clearing the marker would strand it
1692 // exactly the way #629 stranded everything.
1693 if (
1694 $revision !== null
1695 && is_array($pending)
1696 && (int) ($pending['revision'] ?? 0) !== $revision
1697 ) {
1698 return;
1699 }
1700
1701 delete_option(self::REGENERATION_PENDING_OPTION);
1702 }
1703
1704 /**
1705 * Record a failed regeneration instead of discarding it.
1706 *
1707 * Keeps the pending marker in place so the rebuild is retried, but backs the
1708 * next attempt off exponentially (capped) so a persistently failing
1709 * generation cannot run on every admin request.
1710 *
1711 * @since 2.2.1
1712 * @param string $message Failure detail.
1713 * @param string $source Either 'content' or 'settings'.
1714 * @return void
1715 */
1716 private function record_regeneration_failure(string $message, string $source): void {
1717 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1718 $pending = is_array($pending) ? $pending : [];
1719 $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
1720
1721 $backoff = min(
1722 self::REGENERATION_TAKEOVER_GRACE * (2 ** min($attempts, 10)),
1723 self::REGENERATION_MAX_BACKOFF
1724 );
1725
1726 // Same precedence mark_regeneration_pending() enforces: a settings
1727 // rebuild outranks a content one and must not be downgraded by a failed
1728 // attempt. Overwriting it routed the retry back through the content
1729 // path, where should_auto_generate() can be false and the completion
1730 // marker then discards the settings rebuild entirely. Only the
1731 // outstanding rebuild is upgraded — the recorded error keeps reporting
1732 // whichever attempt actually failed.
1733 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1734 $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1735
1736 update_option(
1737 self::REGENERATION_PENDING_OPTION,
1738 [
1739 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
1740 'source' => $pending_source,
1741 'attempts' => $attempts,
1742 'next_attempt' => time() + $backoff,
1743 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
1744 ],
1745 true
1746 );
1747
1748 update_option(
1749 self::REGENERATION_ERROR_OPTION,
1750 [
1751 'message' => $message,
1752 'source' => $source,
1753 'attempts' => $attempts,
1754 'time' => time(),
1755 ],
1756 false
1757 );
1758
1759 if (defined('WP_DEBUG') && WP_DEBUG) {
1760 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1761 error_log(sprintf('ThinkRank: sitemap %s regeneration failed — %s', $source, $message));
1762 }
1763 }
1764
1765 /**
1766 * Is a rebuild outstanding and past the point where WP-Cron should have run
1767 * it?
1768 *
1769 * Deliberately cheap — one autoloaded option read — because it is consulted
1770 * on every admin request to decide whether the takeover is needed.
1771 *
1772 * @since 2.2.1
1773 * @return bool True when a request should rebuild the sitemap itself.
1774 */
1775 public static function has_overdue_regeneration(): bool {
1776 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1777
1778 if (!is_array($pending) || empty($pending['since'])) {
1779 return false;
1780 }
1781
1782 $due = !empty($pending['next_attempt'])
1783 ? (int) $pending['next_attempt']
1784 : (int) $pending['since'] + self::REGENERATION_TAKEOVER_GRACE;
1785
1786 return time() >= $due;
1787 }
1788
1789 /**
1790 * Rebuild the sitemap in-request when WP-Cron has not delivered.
1791 *
1792 * Hooked on `shutdown` for admin, REST and CLI requests only (see
1793 * Plugin::register_sitemap_cron_listeners()), so the work happens after the
1794 * response has been sent and never adds latency to a visitor page view.
1795 *
1796 * @since 2.2.1
1797 * @return void
1798 */
1799 public function run_overdue_regeneration(): void {
1800 if (!self::has_overdue_regeneration()) {
1801 return;
1802 }
1803
1804 // Cron is running: it is about to do exactly this work.
1805 if (wp_doing_cron()) {
1806 return;
1807 }
1808
1809 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1810 $source = (is_array($pending) && isset($pending['source'])) ? (string) $pending['source'] : 'content';
1811
1812 if ($source === 'settings') {
1813 $this->regenerate_sitemap_from_settings();
1814 return;
1815 }
1816
1817 $this->auto_regenerate_sitemap();
1818 }
1819
1820 /**
1821 * Acquire the shared generation lock.
1822 *
1823 * @since 2.2.1
1824 * @return bool True when this process may generate.
1825 */
1826 private function acquire_generation_lock(): bool {
1827 if (get_transient(self::GENERATION_LOCK_TRANSIENT)) {
1828 return false;
1829 }
1830
1831 set_transient(self::GENERATION_LOCK_TRANSIENT, time(), 5 * MINUTE_IN_SECONDS);
1832
1833 return true;
1834 }
1835
1836 /**
1837 * Release the shared generation lock.
1838 *
1839 * @since 2.2.1
1840 * @return void
1841 */
1842 private function release_generation_lock(): void {
1843 delete_transient(self::GENERATION_LOCK_TRANSIENT);
1844 }
1845
1846 /**
1847 * Report how automatic regeneration is faring, for the admin UI.
1848 *
1849 * The feature used to fail invisibly: `last_generated` simply stopped
1850 * advancing and nothing drew attention to it (#629).
1851 *
1852 * @since 2.2.1
1853 * @return array Health payload.
1854 */
1855 public function get_regeneration_health(): array {
1856 $settings = $this->get_settings('site');
1857 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1858 $pending = is_array($pending) ? $pending : [];
1859 $error = get_option(self::REGENERATION_ERROR_OPTION, []);
1860 $error = is_array($error) ? $error : [];
1861
1862 $since = !empty($pending['since']) ? (int) $pending['since'] : 0;
1863
1864 $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap');
1865 if ($next_scheduled === false) {
1866 $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap_settings');
1867 }
1868
1869 return [
1870 'auto_generate' => !empty($settings['auto_generate']),
1871 'last_generated' => $settings['last_generated'] ?? '',
1872 'pending_since' => $since ? gmdate('c', $since) : null,
1873 'pending_seconds' => $since ? max(0, time() - $since) : 0,
1874 'next_scheduled' => $next_scheduled ? gmdate('c', (int) $next_scheduled) : null,
1875 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON,
1876 'stale' => $this->is_sitemap_stale($settings),
1877 'last_error' => !empty($error['message'])
1878 ? [
1879 'message' => (string) $error['message'],
1880 'source' => isset($error['source']) ? (string) $error['source'] : 'content',
1881 'time' => !empty($error['time']) ? gmdate('c', (int) $error['time']) : null,
1882 ]
1883 : null,
1884 ];
1885 }
1886
1887 /**
1888 * Has published content changed since the served sitemap was last written?
1889 *
1890 * Uses core's cached last-modified lookup, which only considers published
1891 * posts — the same content the sitemap covers.
1892 *
1893 * @since 2.2.1
1894 * @param array $settings Sitemap settings.
1895 * @return bool True when the sitemap is behind the content.
1896 */
1897 private function is_sitemap_stale(array $settings): bool {
1898 if (empty($settings['enabled']) || empty($settings['last_generated'])) {
1899 // Never generated is already reported separately by the UI.
1900 return false;
1901 }
1902
1903 $generated = strtotime((string) $settings['last_generated']);
1904 if (!$generated) {
1905 return false;
1906 }
1907
1908 $modified = get_lastpostmodified('gmt');
1909 if (!$modified) {
1910 return false;
1911 }
1912
1913 $modified = strtotime($modified . ' UTC');
1914 if (!$modified) {
1915 return false;
1916 }
1917
1918 // A minute of slack keeps a rebuild that ran alongside the edit from
1919 // reporting itself as stale.
1920 return $modified > ($generated + MINUTE_IN_SECONDS);
1921 }
1922
1923 /**
1924 * Public entry point to debounce-rebuild the sitemap after a settings change
1925 * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
1926 * file reflects the new settings instead of going stale until a content edit.
1927 *
1928 * @return void
1929 */
1930 public function schedule_regeneration(): void {
1931 // Debounce against rapid successive saves, but use the settings-specific
1932 // hook so the rebuild runs regardless of the auto_generate toggle (which
1933 // only governs content-change-triggered regeneration).
1934 $this->mark_regeneration_pending('settings');
1935 $this->debounce_event('thinkrank_regenerate_sitemap_settings');
1936 }
1937
1938 /**
1939 * Rebuild the served sitemap after an explicit settings change.
1940 *
1941 * Unlike {@see auto_regenerate_sitemap()}, this is NOT gated on the
1942 * auto_generate setting: the user deliberately changed inclusion rules and
1943 * expects the served file to reflect them even if content-triggered
1944 * auto-generation is turned off. Still respects the master `enabled` flag.
1945 *
1946 * @return void
1947 */
1948 public function regenerate_sitemap_from_settings(): void {
1949 if (!$this->acquire_generation_lock()) {
1950 // A manual generation (or another request's takeover) is already
1951 // writing the files; the pending marker survives so this rebuild is
1952 // retried rather than lost.
1953 return;
1954 }
1955
1956 try {
1957 $settings = $this->get_settings('site');
1958 if (empty($settings['enabled'])) {
1959 // The sitemap was disabled: remove the previously generated static
1960 // files so the web server stops serving a stale sitemap that
1961 // crawlers would otherwise keep fetching.
1962 $this->delete_published_sitemaps();
1963 $this->mark_regeneration_complete();
1964 return;
1965 }
1966
1967 $revision = $this->current_regeneration_revision();
1968
1969 if ($this->generate_and_save($settings)) {
1970 $this->mark_regeneration_complete($revision);
1971 } else {
1972 $this->record_regeneration_failure(
1973 __('The sitemap files could not be written to the site root.', 'thinkrank'),
1974 'settings'
1975 );
1976 }
1977 } catch (\Throwable $e) {
1978 $this->record_regeneration_failure($e->getMessage(), 'settings');
1979 } finally {
1980 $this->release_generation_lock();
1981 }
1982 }
1983
1984 /**
1985 * Remove every static sitemap file ThinkRank publishes to the web root.
1986 *
1987 * Called when the sitemap feature is disabled, by the cleanup route, and by
1988 * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
1989 * children (incl. paginated -N pages), and /local-sitemap.xml stop being
1990 * served. Only ThinkRank's own filenames are targeted; WordPress core's
1991 * wp-sitemap.xml and any other plugin's sitemap in the web root are left
1992 * untouched.
1993 *
1994 * @since 1.31.0 Returns the filenames removed, and accepts the settings to
1995 * derive them from, so a caller that already read them (and
1996 * needs to report what went) does not have to re-read or
1997 * re-derive the name list.
1998 * @since 2.1.0 Delegates to thinkrank_webroot_delete_sitemaps(). Uninstall
1999 * needs the same removal but has no autoloader to reach this
2000 * class, so the logic moved to includes/cleanup-webroot.php
2001 * and this stays as the in-plugin entry point.
2002 *
2003 * @param array|null $settings Optional. Sitemap settings; defaults to the
2004 * saved site settings.
2005 * @return array{deleted: string[], failed: string[]} Basenames removed, and
2006 * those that existed but could not be removed.
2007 */
2008 public function delete_published_sitemaps(?array $settings = null): array {
2009 return thinkrank_webroot_delete_sitemaps($settings ?? $this->get_settings('site'));
2010 }
2011
2012 /**
2013 * Every child-sitemap filename this site could have published.
2014 *
2015 * @since 1.31.0
2016 * @since 2.1.0 Delegates to thinkrank_webroot_segment_filenames().
2017 *
2018 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
2019 * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
2020 */
2021 private function publishable_segment_filenames(array $settings): array {
2022 return thinkrank_webroot_segment_filenames($settings);
2023 }
2024
2025 /**
2026 * Can this web-root file be shown to be a sitemap ThinkRank wrote?
2027 *
2028 * The generator deletes as often as cleanup does — a segment that dropped
2029 * out of the set, a pagination page beyond the new count, the local sitemap
2030 * after the business identity was cleared — and until 2.1.1 it did all
2031 * three by filename alone. That is the #515 bug on a far more frequent
2032 * trigger: our names are the canonical ones, so an ordinary regeneration
2033 * (post save, term change, settings save) destroyed RankMath's
2034 * `sitemap-tags.xml` and `local-sitemap.xml` with no deactivation involved.
2035 *
2036 * Every name the generator derives comes from the current settings — the
2037 * url pattern, the configured `sitemap_urls`, `local-sitemap.xml` — but the
2038 * ownership test is still asked with `$name_derived = false`, which switches
2039 * off the legacy fallback for the whole generator side.
2040 *
2041 * The fallback exists to recover a pre-2.1.1 file written with
2042 * `enable_styling` off, which carries neither marker. That recovery belongs
2043 * to the once-off cleanup paths. Here it can only do harm: this method runs
2044 * on every post save, and everything this version writes carries
2045 * THINKRANK_SITEMAP_MARKER, so after the site's first regeneration an
2046 * unmarked file at one of our names is by definition somebody else's — and
2047 * deleting it on an ordinary regeneration is #515 through the more common
2048 * door. The cost is a stale unmarked segment left on disk until deactivation
2049 * picks it up, which is the safe direction to fail in.
2050 *
2051 * @since 2.1.1
2052 *
2053 * @param string $path Absolute path to a file in the web root.
2054 * @param array $settings Sitemap settings.
2055 * @return bool True when the file may be deleted.
2056 */
2057 private function webroot_sitemap_is_ours(string $path, array $settings): bool {
2058 return thinkrank_webroot_sitemap_is_ours($path, $settings, false);
2059 }
2060
2061 /**
2062 * Auto-regenerate sitemap (called by scheduled action)
2063 *
2064 * @since 1.0.0
2065 * @return void
2066 */
2067 public function auto_regenerate_sitemap(): void {
2068 if (!$this->acquire_generation_lock()) {
2069 // A manual generation (or another request's takeover) is already
2070 // writing the files; the pending marker survives so this rebuild is
2071 // retried rather than lost.
2072 return;
2073 }
2074
2075 try {
2076 // Double-check that auto-generation is still enabled
2077 if (!$this->should_auto_generate()) {
2078 // Nothing outstanding can be delivered while the feature is off,
2079 // so drop the marker rather than let the takeover retry forever.
2080 $this->mark_regeneration_complete();
2081 return;
2082 }
2083
2084 $revision = $this->current_regeneration_revision();
2085
2086 if ($this->generate_and_save($this->get_settings('site'))) {
2087 $this->mark_regeneration_complete($revision);
2088 } else {
2089 // Previously this returned quietly and last_generated simply
2090 // stopped advancing, leaving the site owner with no way to learn
2091 // the sitemap had stopped updating (#629).
2092 $this->record_regeneration_failure(
2093 __('The sitemap files could not be written to the site root.', 'thinkrank'),
2094 'content'
2095 );
2096 }
2097 } catch (\Throwable $e) {
2098 $this->record_regeneration_failure($e->getMessage(), 'content');
2099 } finally {
2100 $this->release_generation_lock();
2101 }
2102 }
2103
2104 /**
2105 * Build and write the sitemap files for the given settings.
2106 *
2107 * Segmented files plus an index when the settings configure one, a single
2108 * file otherwise, and the standalone local business sitemap either way.
2109 * Records `last_generated` so the UI's "View Generated Sitemaps" links
2110 * unlock, mirroring the manual generate endpoint.
2111 *
2112 * @since 1.17.0
2113 * @param array $settings Sitemap settings.
2114 * @return bool True when the sitemap files were written.
2115 */
2116 public function generate_and_save(array $settings): bool {
2117 // Index mode is driven by the use_sitemap_index toggle (not merely by how
2118 // many sitemap_urls happen to be configured). When the toggle is on but
2119 // no child sitemaps are set up yet, synthesize the per-type segmented set
2120 // so we emit a real <sitemapindex> with paginated children instead of a
2121 // flat urlset misnamed sitemap_index.xml.
2122 $settings = $this->maybe_promote_to_index($settings);
2123
2124 if (!empty($settings['use_sitemap_index']) || count((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : [])) > 1) {
2125 $results = $this->generate_multiple_sitemaps($settings);
2126 $written = !empty($results['success']);
2127 } else {
2128 $xml = $this->generate_sitemap($settings);
2129 $primary = $this->get_primary_sitemap_filename($settings);
2130 $written = $this->save_sitemap_to_file($xml, $primary);
2131
2132 // Local business sitemap is a standalone file, regenerated on the
2133 // single-sitemap path too (this is the default mode).
2134 $this->regenerate_local_sitemap($settings);
2135
2136 // Switching out of index mode leaves sitemap_index.xml and every
2137 // child on disk, still served and never refreshed again. The index
2138 // path already prunes what it no longer owns; this path never did,
2139 // so the site kept serving two sitemap trees (#563). Ownership is
2140 // still tested per file, so another plugin's sitemap at one of our
2141 // names is never touched (#515).
2142 $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
2143 }
2144
2145 if ($written) {
2146 $settings['last_generated'] = gmdate('c');
2147 $this->save_settings('site', null, $settings);
2148 }
2149
2150 return $written;
2151 }
2152
2153 /**
2154 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
2155 *
2156 * - When use_sitemap_index is on but no child sitemaps are configured, build
2157 * the per-type segmented set so a real <sitemapindex> is produced (#127).
2158 * - When the toggle is off but a single flat file would exceed the per-file
2159 * URL cap, auto-promote to a paginated index instead of one oversized file
2160 * that can cross Google's 50k-URL/50MB limits (#129).
2161 *
2162 * @param array $settings Sitemap settings.
2163 * @return array Possibly-updated settings.
2164 */
2165 public function maybe_promote_to_index(array $settings): array {
2166 $saved = $this->get_settings('site');
2167
2168 // Read from the *payload*, before the merge below folds the saved values
2169 // in: "the caller named this" and "this has a value" are different
2170 // questions, and the mode resolution turns on the former.
2171 $mode_supplied = array_key_exists('use_sitemap_index', $settings);
2172 $urls_supplied = is_array($settings['sitemap_urls'] ?? null);
2173 $has_children = count($urls_supplied ? $settings['sitemap_urls'] : []) > 1;
2174
2175 // Which inclusion flags did the caller actually name? The child list is
2176 // the only thing that reads them, and inheriting a saved one skipped
2177 // that — so on an index-mode site the include_* flags were enforced
2178 // nowhere but in the browser, where SitemapGeneration.js recomputes
2179 // sitemap_urls itself. Every non-UI client, the shipped
2180 // `update-sitemap-settings` ability included, saved the flag and changed
2181 // nothing (#398). Read from the payload for the same reason as above:
2182 // after the merge every saved flag would look like one the caller named.
2183 $named_inclusions = array_intersect(
2184 array_keys(self::INCLUSION_CHILD_TYPES),
2185 array_keys($settings)
2186 );
2187 $inclusions_supplied = (bool) $named_inclusions;
2188
2189 // Inclusion flags may be absent from a partial payload (e.g. the manual
2190 // generate endpoint) — fall back to saved settings so synthesized child
2191 // sitemaps reflect the real include_posts/pages/categories choices.
2192 $inclusions = array_merge($saved, $settings);
2193
2194 // Hand the generators a *complete* settings array. Only the mode was
2195 // resolved before, so every other unnamed key reached them missing: a
2196 // bare `{}` from a REST/MCP client republished the sitemap with
2197 // enable_styling and include_images read as off, overwriting the live
2198 // files with output that had lost its XSL stylesheet, its image
2199 // namespace and its image entries. Presentation and inclusion settings
2200 // are not something a generate call opts into — they are the site's
2201 // configuration, and only a value actually present in the payload
2202 // overrides them.
2203 $settings = $inclusions;
2204
2205 // The two keys that drive mode keep their own resolution rules below,
2206 // so they must go back to "not specified" when the caller omitted them.
2207 if (!$mode_supplied) {
2208 unset($settings['use_sitemap_index']);
2209 }
2210 if (!$urls_supplied) {
2211 unset($settings['sitemap_urls']);
2212 }
2213
2214 // An absent use_sitemap_index means "not specified", which is not the
2215 // same as "single file". Reading it as the latter meant a partial payload
2216 // — `{}` from a REST/MCP client, or anything short of the full settings
2217 // object the admin bundle sends — republished one flat sitemap.xml on an
2218 // index-mode site and left sitemap_index.xml and its children stale or
2219 // missing. Inherit the saved mode instead; only a value actually present
2220 // in the payload decides the mode.
2221 if (!array_key_exists('use_sitemap_index', $settings)) {
2222 $settings['use_sitemap_index'] = $saved['use_sitemap_index'] ?? '';
2223
2224 // Inheriting the mode means inheriting its children too, unless the
2225 // caller named its own set.
2226 $saved_children = (is_array($saved['sitemap_urls'] ?? null) ? $saved['sitemap_urls'] : []);
2227 if (!empty($settings['use_sitemap_index'])
2228 && !$urls_supplied
2229 && count($saved_children) > 1) {
2230 // A named inclusion flag is applied *to* the inherited list, not
2231 // used to regenerate it. build_segmented_sitemap_urls() also adds
2232 // a child for every public custom post type, so rebuilding here
2233 // would make `{include_pages: false}` — one thing off — silently
2234 // switch on children the saved list never had (an Elementor
2235 // internal CPT, a WooCommerce product feed). Only the flags the
2236 // caller actually named change anything.
2237 $settings['sitemap_urls'] = $inclusions_supplied
2238 ? $this->apply_inclusion_flags_to_children($saved_children, $named_inclusions, $inclusions)
2239 : $saved_children;
2240
2241 // The children are resolved either way — including when the
2242 // caller switched the last one off, which leaves a bare index and
2243 // is what they asked for.
2244 $has_children = true;
2245 }
2246 }
2247
2248 if (!empty($settings['use_sitemap_index'])) {
2249 if (!$has_children) {
2250 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
2251 }
2252 return $settings;
2253 }
2254
2255 if (!$has_children) {
2256 $limit = $this->get_links_per_sitemap($settings);
2257 if ($limit > 0 && $this->estimate_total_sitemap_urls($inclusions) > $limit) {
2258 $settings['use_sitemap_index'] = true;
2259 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
2260 }
2261 }
2262
2263 return $settings;
2264 }
2265
2266 /**
2267 * Rough count of URLs a single-file sitemap would contain, used only to
2268 * decide whether to auto-promote to a paginated index. Cheap COUNT queries;
2269 * intentionally approximate (homepage + published posts of enabled types +
2270 * terms of enabled taxonomies).
2271 *
2272 * @param array $settings Sitemap settings.
2273 * @return int
2274 */
2275 private function estimate_total_sitemap_urls(array $settings): int {
2276 $total = 1; // homepage
2277
2278 foreach ($this->get_enabled_post_types($settings) as $post_type) {
2279 $counts = wp_count_posts($post_type);
2280 $total += isset($counts->publish) ? (int) $counts->publish : 0;
2281 }
2282
2283 foreach ($this->get_enabled_taxonomies($settings) as $taxonomy) {
2284 $count = wp_count_terms(['taxonomy' => $taxonomy, 'hide_empty' => true]);
2285 if (!is_wp_error($count)) {
2286 $total += (int) $count;
2287 }
2288 }
2289
2290 return $total;
2291 }
2292
2293 /**
2294 * Resolve the sitemap file the site publishes for the given settings.
2295 *
2296 * Index mode serves the index (`sitemap_index.xml`); the default single-file
2297 * mode serves `sitemap.xml`. Callers use this to link to — or check for —
2298 * the file the site actually serves, since ThinkRank's sitemap is a static
2299 * file in the web root rather than a route.
2300 *
2301 * @since 1.17.0
2302 * @param array $settings Sitemap settings.
2303 * @return string Sitemap filename.
2304 */
2305 public function get_primary_sitemap_filename(array $settings): string {
2306 return thinkrank_webroot_primary_sitemap_filename($settings);
2307 }
2308
2309 /**
2310 * Public URL of the sitemap the site serves.
2311 *
2312 * @since 1.17.0
2313 * @param array|null $settings Sitemap settings (falls back to saved site settings).
2314 * @return string Absolute sitemap URL.
2315 */
2316 public function get_primary_sitemap_url(?array $settings = null): string {
2317 $settings = $settings ?? $this->get_settings('site');
2318
2319 return home_url('/' . $this->get_primary_sitemap_filename($settings));
2320 }
2321
2322 /**
2323 * Whether the sitemap file this site publishes exists on disk.
2324 *
2325 * @since 1.17.0
2326 * @param array $settings Sitemap settings.
2327 * @return bool True when the file is present.
2328 */
2329 public function primary_sitemap_file_exists(array $settings): bool {
2330 return file_exists(ABSPATH . $this->get_primary_sitemap_filename($settings));
2331 }
2332
2333 /**
2334 * Save sitemap XML to file
2335 *
2336 * @since 1.0.0
2337 * @param string $sitemap_xml Sitemap XML content
2338 * @param string $filename Optional. Filename to save (defaults to 'sitemap.xml')
2339 * @return bool True on success, false on failure
2340 */
2341 private function save_sitemap_to_file(string $sitemap_xml, string $filename = 'sitemap.xml'): bool {
2342 // Validate and sanitize filename for security
2343 try {
2344 $filename = $this->validate_sitemap_filename($filename);
2345 } catch (InvalidArgumentException $e) {
2346 // File validation failed - error details available in exception
2347 return false;
2348 }
2349
2350 $sitemap_path = ABSPATH . $filename;
2351
2352 // Use WordPress filesystem API for better security
2353 global $wp_filesystem;
2354 if (!$wp_filesystem) {
2355 require_once ABSPATH . 'wp-admin/includes/file.php';
2356 WP_Filesystem();
2357 }
2358
2359 if ($wp_filesystem) {
2360 $written = $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
2361
2362 if ($written) {
2363 // Every sitemap this version writes carries the ownership
2364 // marker, so once one has been written an unmarked file at one
2365 // of our names cannot be ours. Recording that retires the
2366 // legacy fallback for this install — see
2367 // thinkrank_webroot_sitemap_is_ours().
2368 if (get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1') {
2369 update_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION, '1', false);
2370 }
2371 }
2372
2373 return $written;
2374 }
2375
2376 // WP_Filesystem initialization failed
2377 return false;
2378 }
2379
2380 /**
2381 * Build a segmented, index-based sitemap_urls list from inclusion settings.
2382 *
2383 * Produces the index entry plus one child sitemap per enabled content type
2384 * (posts/pages/categories) and one per public custom post type — the shape
2385 * the "complete" preset creates and that generate_multiple_sitemaps() expects.
2386 * Used when enabling the index during import so the index has real children
2387 * instead of being empty.
2388 *
2389 * @since 1.14.0
2390 *
2391 * @param array $inclusions Inclusion flags (include_posts/pages/categories)
2392 * and optionally custom_url_pattern.
2393 * @return array Sitemap URL configs
2394 */
2395 public function build_segmented_sitemap_urls(array $inclusions): array {
2396 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
2397
2398 $urls = [$this->sitemap_child_entry('/sitemap_index.xml', 'index')];
2399
2400 foreach (self::INCLUSION_CHILD_TYPES as $flag => $type) {
2401 if (!empty($inclusions[$flag])) {
2402 $urls[] = $this->build_child_sitemap_entry($type, $pattern);
2403 }
2404 }
2405
2406 // Public custom post types each get a child sitemap (parity with the
2407 // "complete" preset and with Rank Math, which lists every public CPT).
2408 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
2409 if ($this->should_include_post_type($cpt)) {
2410 $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
2411 }
2412 }
2413
2414 return $urls;
2415 }
2416
2417 /**
2418 * Apply only the inclusion flags the caller named to an existing child list.
2419 *
2420 * The narrow counterpart to build_segmented_sitemap_urls(): that one
2421 * regenerates the whole set from scratch, which is right when there is no set
2422 * yet and wrong when there is. Rebuilding an existing list would add a child
2423 * for every public custom post type it had never contained, so a payload that
2424 * switches one thing off would switch others on. Here a flag adds or removes
2425 * exactly its own child and leaves every other entry — custom post types,
2426 * hand-added URLs, per-child enabled/status state — untouched (#398).
2427 *
2428 * @since 1.31.0
2429 *
2430 * @param array $children Existing child sitemap entries.
2431 * @param string[] $named_inclusions Inclusion flag keys present in the payload.
2432 * @param array $inclusions Merged settings, for the resolved flag
2433 * values and custom_url_pattern.
2434 * @return array Updated child sitemap entries.
2435 */
2436 private function apply_inclusion_flags_to_children(
2437 array $children,
2438 array $named_inclusions,
2439 array $inclusions
2440 ): array {
2441 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
2442
2443 foreach ($named_inclusions as $flag) {
2444 $type = self::INCLUSION_CHILD_TYPES[$flag];
2445
2446 $present = false;
2447 foreach ($children as $entry) {
2448 if (($entry['type'] ?? '') === $type) {
2449 $present = true;
2450 break;
2451 }
2452 }
2453
2454 if (empty($inclusions[$flag])) {
2455 if ($present) {
2456 $children = array_values(array_filter(
2457 $children,
2458 static function ($entry) use ($type): bool {
2459 return (is_array($entry) ? ($entry['type'] ?? '') : '') !== $type;
2460 }
2461 ));
2462 }
2463 continue;
2464 }
2465
2466 if (!$present) {
2467 $children[] = $this->build_child_sitemap_entry($type, $pattern);
2468 }
2469 }
2470
2471 return $children;
2472 }
2473
2474 /**
2475 * Build one child sitemap entry, resolving its filename from the url pattern.
2476 *
2477 * @since 1.31.0
2478 *
2479 * @param string $type Child sitemap type (posts, pages, a post type name).
2480 * @param string $pattern Filename pattern containing {type}.
2481 * @return array Sitemap URL config.
2482 */
2483 private function build_child_sitemap_entry(string $type, string $pattern): array {
2484 $file = str_replace('{type}', $type, $pattern);
2485 if (strpos($file, '/') !== 0) {
2486 $file = '/' . $file;
2487 }
2488
2489 return $this->sitemap_child_entry($file, $type);
2490 }
2491
2492 /**
2493 * The shape generate_multiple_sitemaps() expects of a sitemap_urls entry.
2494 *
2495 * @since 1.31.0
2496 *
2497 * @param string $url Sitemap path.
2498 * @param string $type Entry type.
2499 * @return array Sitemap URL config.
2500 */
2501 private function sitemap_child_entry(string $url, string $type): array {
2502 return [
2503 'url' => $url,
2504 'type' => $type,
2505 'enabled' => true,
2506 'last_checked' => null,
2507 'status' => 'unknown',
2508 ];
2509 }
2510
2511 /**
2512 * Generate multiple sitemaps based on settings
2513 *
2514 * @since 1.0.0
2515 * @param array $settings Sitemap settings
2516 * @return array Results of sitemap generation
2517 */
2518 public function generate_multiple_sitemaps(array $settings): array {
2519 $results = [
2520 'success' => true,
2521 'sitemaps_generated' => [],
2522 'errors' => [],
2523 'total_urls' => 0
2524 ];
2525
2526 $sitemap_urls = (is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []);
2527
2528 if (empty($sitemap_urls)) {
2529 $results['success'] = false;
2530 $results['errors'][] = 'No sitemap URLs configured';
2531 return $results;
2532 }
2533
2534 $limit = $this->get_links_per_sitemap($settings);
2535
2536 // Defer the index until every child sitemap has been generated, so it can
2537 // list the actual files produced (including pagination pages).
2538 $index_config = null;
2539 $index_children = [];
2540
2541 foreach ($sitemap_urls as $sitemap_config) {
2542 if (empty($sitemap_config['enabled'])) {
2543 continue;
2544 }
2545
2546 $type = $sitemap_config['type'];
2547
2548 if ($type === 'index') {
2549 $index_config = $sitemap_config;
2550 continue;
2551 }
2552
2553 // Skip CPT children that no longer qualify (e.g. an internal store
2554 // like Templately's `templately_library`) even when a previously
2555 // saved config still lists them. Built-in aggregate types ('posts',
2556 // 'pages', 'general', etc.) are not post type names, so this only
2557 // affects real custom post types.
2558 if (post_type_exists($type) && !$this->should_include_post_type($type)) {
2559 continue;
2560 }
2561
2562 try {
2563 // The single "general"/"WordPress" sitemap is one un-paginated file
2564 // (there is no index to reference extra pages); it no longer drops
2565 // overflow URLs.
2566 if ($type === 'general' || $type === 'wordpress') { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- lowercase on purpose: this is the stored type slug.
2567 $xml = $this->generate_sitemap($settings);
2568 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
2569 continue;
2570 }
2571
2572 $source = $this->stream_type_entries($type, $settings);
2573
2574 // Unknown type — fall back to a single general sitemap file.
2575 if ($source === null) {
2576 $xml = $this->generate_sitemap($settings);
2577 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
2578 continue;
2579 }
2580
2581 // Stream the entries into files of at most $limit URLs, matching
2582 // Rank Math: page 1 keeps the base filename, pages 2+ get a -N
2583 // suffix, and every page is listed in the index. Buffering only one
2584 // page at a time keeps peak memory bounded to $limit entries rather
2585 // than every URL of the type.
2586 $image_ns = $source['image_ns'];
2587 $buffer = [];
2588 $page = 0;
2589
2590 foreach ($source['entries'] as $entry) {
2591 $buffer[] = $entry;
2592 if (count($buffer) >= $limit) {
2593 $page++;
2594 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
2595 $buffer = [];
2596 }
2597 }
2598
2599 // Flush the trailing partial page, or a single empty page when the
2600 // type had no entries at all (parity with the previous behavior of
2601 // always writing at least one page per configured child).
2602 if (!empty($buffer) || $page === 0) {
2603 $page++;
2604 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
2605 }
2606
2607 // Remove pages left over from a previous, larger generation.
2608 $this->cleanup_stale_pages($sitemap_config['url'], $page, $settings);
2609
2610 } catch (\Exception $e) {
2611 $results['errors'][] = "Error generating {$type} sitemap: " . $e->getMessage();
2612 $results['success'] = false;
2613 }
2614 }
2615
2616 // Local business sitemap (parity with Rank Math's local-sitemap.xml).
2617 // The physical file is written whenever a business identity is
2618 // configured — independent of segmented vs single mode — and is also
2619 // listed in the sitemap index when one exists.
2620 try {
2621 if ($this->regenerate_local_sitemap($settings) && $index_config !== null) {
2622 $index_children[] = ['url' => '/local-sitemap.xml'];
2623 }
2624 } catch (\Exception $e) {
2625 $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
2626 $results['success'] = false;
2627 }
2628
2629 // Build the index last, from the child files actually generated, plus the
2630 // sitemaps other plugins own: those serve their own URLs and write no
2631 // file here, so they are appended to the index only (#104).
2632 if ($index_config !== null) {
2633 foreach (self::additional_sitemaps() as $extra) {
2634 $index_children[] = ['url' => $extra];
2635 }
2636
2637 try {
2638 $index_xml = $this->generate_sitemap_index($index_children, $settings);
2639 $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
2640
2641 if ($this->save_sitemap_to_file($index_xml, $filename)) {
2642 $results['sitemaps_generated'][] = [
2643 'url' => $index_config['url'],
2644 'type' => 'index',
2645 'filename' => $filename,
2646 'url_count' => count($index_children),
2647 ];
2648 } else {
2649 $results['errors'][] = "Failed to save sitemap: {$filename}";
2650 $results['success'] = false;
2651 }
2652 } catch (\Exception $e) {
2653 $results['errors'][] = 'Error generating index sitemap: ' . $e->getMessage();
2654 $results['success'] = false;
2655 }
2656 }
2657
2658 // Remove segments that are no longer part of the set. Publishing was
2659 // purely additive: a type that dropped out (Categories unticked, a CPT
2660 // that stopped qualifying) simply stopped being overwritten, so its file
2661 // kept serving and — until the index happened to be rebuilt — kept being
2662 // listed in it. The index above is built from the children actually
2663 // generated, so pruning here leaves disk and index agreeing.
2664 $this->prune_orphaned_segments($settings, $results['sitemaps_generated']);
2665
2666 return $results;
2667 }
2668
2669 /**
2670 * Delete published segment files that this run did not write.
2671 *
2672 * Only filenames this site could have published under its own url pattern
2673 * are considered, so another plugin's or core's sitemap in the web root is
2674 * never a candidate — the same reason cleanup does not glob 'sitemap-*.xml'.
2675 *
2676 * @since 1.31.0
2677 *
2678 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
2679 * @param array $generated Entries from $results['sitemaps_generated'].
2680 * @return string[] Basenames removed.
2681 */
2682 private function prune_orphaned_segments(array $settings, array $generated): array {
2683 $kept = [];
2684 foreach ($generated as $entry) {
2685 if (!empty($entry['filename'])) {
2686 $kept[strtolower((string) $entry['filename'])] = true;
2687 }
2688 }
2689
2690 // The current mode's primary and the local business sitemap are written
2691 // by their own paths and are never orphans here.
2692 $primary = strtolower(basename($this->get_primary_sitemap_filename($settings)));
2693 $kept[$primary] = true;
2694 $kept['local-sitemap.xml'] = true;
2695
2696 // The OTHER mode's primary is an orphan the moment the mode changes:
2697 // index mode leaves sitemap.xml behind, flat mode leaves
2698 // sitemap_index.xml and its children. Both used to be kept
2699 // unconditionally, so the site served two sitemap trees and only ever
2700 // refreshed one (#563). The children are already covered by the segment
2701 // sweep below, which now sees them because the index is no longer kept.
2702 $stale_primaries = array_diff(['sitemap.xml', 'sitemap_index.xml'], [$primary]);
2703
2704 $removed = [];
2705
2706 global $wp_filesystem;
2707 if (!$wp_filesystem) {
2708 require_once ABSPATH . 'wp-admin/includes/file.php';
2709 WP_Filesystem();
2710 }
2711 if (!$wp_filesystem) {
2712 return $removed;
2713 }
2714
2715 $candidates = array_merge($this->publishable_segment_filenames($settings), $stale_primaries);
2716
2717 foreach ($candidates as $candidate) {
2718 if (isset($kept[strtolower($candidate)])) {
2719 continue;
2720 }
2721
2722 if (!preg_match('/^(.*)\.xml$/i', $candidate, $m)) {
2723 continue;
2724 }
2725
2726 // The base file plus its numeric pagination pages.
2727 $paths = [ABSPATH . $candidate];
2728 foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
2729 if (preg_match('/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i', basename($paged))) {
2730 $paths[] = $paged;
2731 }
2732 }
2733
2734 foreach ($paths as $path) {
2735 if (!file_exists($path)) {
2736 continue;
2737 }
2738 // A name we could have published is not proof we published
2739 // this file: RankMath and core write at the same paths (#515).
2740 if (!$this->webroot_sitemap_is_ours($path, $settings)) {
2741 continue;
2742 }
2743 if ($wp_filesystem->delete($path)) {
2744 $removed[] = basename($path);
2745 }
2746 }
2747 }
2748
2749 return $removed;
2750 }
2751
2752
2753 /**
2754 * Collect the full (un-paginated) entry list for a content sitemap type.
2755 *
2756 * @since 1.14.0
2757 *
2758 * @param string $type Sitemap config type (posts, pages, categories, …)
2759 * @param array $settings Sitemap settings
2760 * @return array{entries: array<string>, image_ns: bool}|null Null for an
2761 * unknown type (caller falls back to a general sitemap).
2762 */
2763 /**
2764 * Write (or remove) the physical local-sitemap.xml file.
2765 *
2766 * Called from every sitemap regeneration path — segmented generation, the
2767 * single-sitemap auto-regeneration, and the manual generate endpoint — so
2768 * the local sitemap works regardless of whether the site uses a sitemap
2769 * index. When no business identity is configured, any stale file is removed.
2770 *
2771 * @since 1.15.x
2772 * @param array|null $settings Sitemap settings (falls back to saved site settings).
2773 * @return bool True when the file was written, false when nothing was written.
2774 */
2775 public function regenerate_local_sitemap(?array $settings = null): bool {
2776 $settings = $settings ?? $this->get_settings('site');
2777 $entries = $this->collect_local_entries();
2778
2779 if (empty($entries)) {
2780 // Business identity was cleared — drop the file we left from
2781 // before, but only ours. `local-sitemap.xml` is the name Rank Math
2782 // publishes under too (this method mirrors it deliberately), so on
2783 // a migrated site the file at that path may never have been ours
2784 // to delete (#515).
2785 $path = ABSPATH . 'local-sitemap.xml';
2786 if (file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
2787 wp_delete_file($path);
2788 }
2789 return false;
2790 }
2791
2792 $xml = $this->wrap_urlset($entries, $settings, false);
2793 return $this->save_sitemap_to_file($xml, 'local-sitemap.xml');
2794 }
2795
2796 /**
2797 * Build the local business sitemap entries.
2798 *
2799 * Returns a single URL entry pointing at the on-site page that carries the
2800 * business's LocalBusiness/Organization schema (the configured business URL
2801 * when it's on this site, otherwise the homepage). Gated — like Rank Math's
2802 * `local-sitemap.xml` — on a business (non-person) type being configured
2803 * with an actual location (address or geo coordinates). Returns an empty
2804 * array when no business location is set, so no empty local sitemap is
2805 * written or added to the index.
2806 *
2807 * @since 1.15.x
2808 * @return array Zero or one URL entry
2809 */
2810 private function collect_local_entries(): array {
2811 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2812 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2813 }
2814
2815 $identity = (new \ThinkRank\SEO\Site_Identity_Manager())->get_settings('site');
2816
2817 // Gate like Rank Math's local sitemap: a business (non-person) identity
2818 // is configured. We only emit a single URL (the location page), so a
2819 // full postal address is NOT required — requiring one wrongly excluded
2820 // migrated sites, since the Rank Math importer carries over business
2821 // type/name/phone but not the address. Personal sites, and sites with no
2822 // business identity at all, get no local sitemap.
2823 $business_type = strtolower((string) ($identity['business_type'] ?? ''));
2824 if ($business_type === 'person') {
2825 return [];
2826 }
2827 if ($business_type === '' && empty($identity['business_name'])) {
2828 return [];
2829 }
2830
2831 // Prefer the configured business URL when it points at this site;
2832 // otherwise fall back to the homepage (which outputs the schema).
2833 $location_url = home_url('/');
2834 if (!empty($identity['business_url'])) {
2835 $candidate = esc_url_raw((string) $identity['business_url']);
2836 if ($candidate && wp_parse_url($candidate, PHP_URL_HOST) === wp_parse_url(home_url(), PHP_URL_HOST)) {
2837 $location_url = $candidate;
2838 }
2839 }
2840
2841 // Omit lastmod — no real modification source for the local business URL.
2842 return [$this->generate_url_entry($location_url, '', 0.8, 'weekly')];
2843 }
2844
2845 /**
2846 * Resolve a streaming entry source for a sitemap type.
2847 *
2848 * Returns an iterable that yields the type's <url> entries one at a time
2849 * (so the paginator never materializes the whole type), the image-namespace
2850 * flag, or null when the type isn't one we build (caller falls back to a
2851 * single general sitemap). Entry order matches the previous array-based
2852 * collect_type_entries() exactly.
2853 *
2854 * @param string $type Sitemap type.
2855 * @param array $settings Sitemap settings.
2856 * @return array{entries: iterable<string>, image_ns: bool}|null
2857 */
2858 private function stream_type_entries(string $type, array $settings): ?array {
2859 switch ($type) {
2860 case 'posts':
2861 return ['entries' => $this->collect_post_entries_iter(['post'], $settings), 'image_ns' => true];
2862
2863 case 'pages':
2864 // The homepage lives in the pages sitemap (parity with prior
2865 // output) and is emitted first; collect_post_entries_iter()
2866 // already excludes the static front page, so there's no duplicate.
2867 $entries = (function () use ($settings) {
2868 yield $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
2869 yield from $this->collect_post_entries_iter(['page'], $settings);
2870 })();
2871 return ['entries' => $entries, 'image_ns' => true];
2872
2873 case 'categories':
2874 return ['entries' => $this->collect_taxonomy_entries_iter('category', $settings), 'image_ns' => false];
2875
2876 case 'tags':
2877 return ['entries' => $this->collect_taxonomy_entries_iter('post_tag', $settings), 'image_ns' => false];
2878
2879 case 'products':
2880 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
2881 return ['entries' => $entries, 'image_ns' => true];
2882
2883 case 'product_categories':
2884 $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
2885 return ['entries' => $entries, 'image_ns' => false];
2886
2887 default:
2888 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2889 if (in_array($type, $custom_post_types, true)) {
2890 return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
2891 }
2892 return null;
2893 }
2894 }
2895
2896 /**
2897 * Write one sitemap page file and record it in the results + index child list.
2898 *
2899 * @since 1.14.0
2900 *
2901 * @param string $url Sitemap page URL
2902 * @param string $xml Sitemap XML
2903 * @param string $type Sitemap type (for reporting)
2904 * @param int $url_count Number of URLs in this page
2905 * @param array $results Results accumulator (by reference)
2906 * @param array $index_children Index child list (by reference)
2907 * @return void
2908 */
2909 private function write_sitemap_page(string $url, string $xml, string $type, int $url_count, array &$results, array &$index_children): void {
2910 $filename = basename(wp_parse_url($url, PHP_URL_PATH));
2911
2912 if ($this->save_sitemap_to_file($xml, $filename)) {
2913 $results['sitemaps_generated'][] = [
2914 'url' => $url,
2915 'type' => $type,
2916 'filename' => $filename,
2917 'url_count' => $url_count,
2918 ];
2919 $results['total_urls'] += $url_count;
2920 $index_children[] = ['url' => $url];
2921 } else {
2922 $results['errors'][] = "Failed to save sitemap: {$filename}";
2923 $results['success'] = false;
2924 }
2925 }
2926
2927 /**
2928 * Delete pagination files left over when a type shrinks to fewer pages.
2929 *
2930 * For a base URL like /sitemap-posts.xml, removes sitemap-posts-N.xml files
2931 * whose page number N exceeds the current page count. Page 1 (the base file,
2932 * which has no -N suffix) is never touched.
2933 *
2934 * @since 1.14.0
2935 *
2936 * @since 2.1.1 Each candidate must pass the content ownership test — a
2937 * `-N.xml` page of another plugin's sitemap paginates our
2938 * stem exactly as ours does (#515).
2939 *
2940 * @param string $base_url Base (page 1) sitemap URL
2941 * @param int $current_pages Number of pages generated this run
2942 * @param array $settings Sitemap settings, for the ownership test.
2943 * @return void
2944 */
2945 private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
2946 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2947 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2948 return;
2949 }
2950 $stem = $m[1];
2951
2952 global $wp_filesystem;
2953 if (!$wp_filesystem) {
2954 require_once ABSPATH . 'wp-admin/includes/file.php';
2955 WP_Filesystem();
2956 }
2957 if (!$wp_filesystem) {
2958 return;
2959 }
2960
2961 $candidates = glob(ABSPATH . $stem . '-*.xml') ?: [];
2962 foreach ($candidates as $path) {
2963 // Only delete numeric-suffixed pages beyond the current count.
2964 if (preg_match('/-(\d+)\.xml$/', basename($path), $mm)
2965 && (int) $mm[1] > $current_pages
2966 && $this->webroot_sitemap_is_ours($path, $settings)) {
2967 $wp_filesystem->delete($path);
2968 }
2969 }
2970 }
2971
2972 /**
2973 * Sitemap URLs contributed by other plugins.
2974 *
2975 * ThinkRank owns the sitemap index and the robots.txt `Sitemap:` lines, so a
2976 * companion plugin that serves its own sitemap — Pro's news and video
2977 * sitemaps, for instance — had no way to be discovered: it appeared in
2978 * neither, leaving manual Search Console submission as the only route in
2979 * (#104). Registering here puts a sitemap in the index when one exists, and
2980 * in robots.txt when it does not.
2981 *
2982 * Callers get root-relative paths. Entries are normalised to a leading
2983 * slash, de-duplicated, and anything that is not a non-empty string is
2984 * dropped, so one badly-behaved callback cannot produce a malformed index.
2985 *
2986 * @since 2.3.1
2987 *
2988 * @return string[] Root-relative sitemap paths, e.g. ['/news-sitemap.xml'].
2989 */
2990 public static function additional_sitemaps(): array {
2991 /**
2992 * Filters the sitemaps contributed by other plugins.
2993 *
2994 * @since 2.3.1
2995 *
2996 * @param string[] $sitemaps Root-relative sitemap paths.
2997 */
2998 $sitemaps = apply_filters('thinkrank_additional_sitemaps', []);
2999
3000 if (!is_array($sitemaps)) {
3001 return [];
3002 }
3003
3004 // Both consumers resolve an entry with home_url(), which prefixes the
3005 // install's own directory. Everything below is measured against that so
3006 // an absolute URL is reduced to what home_url() will put back.
3007 $home = wp_parse_url(home_url('/'));
3008 $home_host = strtolower((string) ($home['host'] ?? ''));
3009 $home_path = '/' . trim((string) ($home['path'] ?? ''), '/');
3010
3011 $clean = [];
3012 foreach ($sitemaps as $sitemap) {
3013 if (!is_string($sitemap)) {
3014 continue;
3015 }
3016
3017 $sitemap = trim($sitemap);
3018 if ('' === $sitemap) {
3019 continue;
3020 }
3021
3022 // A full URL on this site is accepted and reduced to the part
3023 // home_url() does not already supply, so a caller that reached for
3024 // home_url() still lands in the right place — including on a
3025 // subdirectory install, where keeping the whole path would repeat
3026 // the directory. A URL on another host is dropped rather than
3027 // rewritten: the sitemaps protocol will not accept a cross-host
3028 // child anyway, and reusing its path would advertise a URL on this
3029 // site that does not exist.
3030 if (preg_match('#^(https?:)?//#i', $sitemap)) {
3031 $parts = wp_parse_url('//' === substr($sitemap, 0, 2) ? 'https:' . $sitemap : $sitemap);
3032 if (!is_array($parts)) {
3033 continue;
3034 }
3035
3036 if (strtolower((string) ($parts['host'] ?? '')) !== $home_host) {
3037 continue;
3038 }
3039
3040 $path = (string) ($parts['path'] ?? '');
3041 if ('' === $path) {
3042 continue;
3043 }
3044
3045 if ('/' !== $home_path && ($path === $home_path || 0 === strpos($path, $home_path . '/'))) {
3046 $path = substr($path, strlen($home_path));
3047 }
3048
3049 // A sitemap served from a query string keeps it; dropping the
3050 // query would point at a different document.
3051 $query = (string) ($parts['query'] ?? '');
3052 $sitemap = $path . ('' !== $query ? '?' . $query : '');
3053 }
3054
3055 $clean[] = '/' . ltrim($sitemap, '/');
3056 }
3057
3058 return array_values(array_unique($clean));
3059 }
3060
3061 /**
3062 * Generate sitemap index XML from the list of child sitemap files produced
3063 * during generation (each already resolved to its final, possibly paginated,
3064 * URL).
3065 *
3066 * @since 1.0.0 (signature updated 1.14.0)
3067 * @param array $children Array of ['url' => string] child sitemap entries
3068 * @param array $settings Sitemap settings
3069 * @return string Sitemap index XML
3070 */
3071 private function generate_sitemap_index(array $children, array $settings): string {
3072 $xml = $this->xml_prolog($settings, 'sitemap-index.xsl');
3073 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
3074
3075 $site_url = home_url();
3076
3077 foreach ($children as $child) {
3078 $sitemap_url = $child['url'] ?? '';
3079 if ($sitemap_url === '') {
3080 continue;
3081 }
3082 if (!str_starts_with($sitemap_url, 'http')) {
3083 $sitemap_url = $site_url . $sitemap_url;
3084 }
3085
3086 $xml .= " <sitemap>\n";
3087 $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
3088 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
3089 $xml .= " </sitemap>\n";
3090 }
3091
3092 $xml .= '</sitemapindex>';
3093
3094 return $xml;
3095 }
3096
3097 /**
3098 * Count URLs in sitemap XML content
3099 *
3100 * @since 1.0.0
3101 * @param string $sitemap_xml Sitemap XML content
3102 * @return int Number of URLs
3103 */
3104 private function count_urls_in_xml(string $sitemap_xml): int {
3105 return substr_count($sitemap_xml, '<url>');
3106 }
3107
3108 /**
3109 * Validate and sanitize exclude posts input
3110 *
3111 * @since 1.0.0
3112 * @param string $exclude_posts Comma-separated post IDs
3113 * @return array Validated post IDs
3114 * @throws InvalidArgumentException If validation fails
3115 */
3116 private function validate_exclude_posts(string $exclude_posts): array {
3117 if (empty($exclude_posts)) {
3118 return [];
3119 }
3120
3121 // Limit input length to prevent DoS
3122 if (strlen($exclude_posts) > 1000) {
3123 throw new InvalidArgumentException('Exclude posts list too long (max 1000 characters)');
3124 }
3125
3126 // Validate comma-separated integers only
3127 if (!preg_match('/^[\d,\s]+$/', $exclude_posts)) {
3128 throw new InvalidArgumentException('Exclude posts must contain only numbers and commas');
3129 }
3130
3131 $ids = array_map('intval', array_filter(explode(',', $exclude_posts)));
3132
3133 // Limit number of exclusions to prevent performance issues
3134 if (count($ids) > 100) {
3135 throw new InvalidArgumentException('Too many posts to exclude (max 100)');
3136 }
3137
3138 return array_filter($ids, function($id) {
3139 return $id > 0; // Only positive integers
3140 });
3141 }
3142
3143 /**
3144 * Validate and sanitize exclude terms input
3145 *
3146 * @since 1.0.0
3147 * @param string $exclude_terms Comma-separated term IDs
3148 * @return array Validated term IDs
3149 * @throws InvalidArgumentException If validation fails
3150 */
3151 private function validate_exclude_terms(string $exclude_terms): array {
3152 if (empty($exclude_terms)) {
3153 return [];
3154 }
3155
3156 // Limit input length to prevent DoS
3157 if (strlen($exclude_terms) > 1000) {
3158 throw new InvalidArgumentException('Exclude terms list too long (max 1000 characters)');
3159 }
3160
3161 // Validate comma-separated integers only
3162 if (!preg_match('/^[\d,\s]+$/', $exclude_terms)) {
3163 throw new InvalidArgumentException('Exclude terms must contain only numbers and commas');
3164 }
3165
3166 $ids = array_map('intval', array_filter(explode(',', $exclude_terms)));
3167
3168 // Limit number of exclusions to prevent performance issues
3169 if (count($ids) > 100) {
3170 throw new InvalidArgumentException('Too many terms to exclude (max 100)');
3171 }
3172
3173 return array_filter($ids, function($id) {
3174 return $id > 0; // Only positive integers
3175 });
3176 }
3177
3178 /**
3179 * Validate and sanitize custom URL pattern
3180 *
3181 * @since 1.0.0
3182 * @param string $pattern Custom URL pattern
3183 * @return string Validated and sanitized pattern
3184 * @throws InvalidArgumentException If validation fails
3185 */
3186 private function validate_custom_url_pattern(string $pattern): string {
3187 if (empty($pattern)) {
3188 return 'sitemap-{type}.xml';
3189 }
3190
3191 // Remove any HTML/script tags to prevent XSS
3192 $pattern = wp_strip_all_tags($pattern);
3193
3194 // Limit pattern length
3195 if (strlen($pattern) > 100) {
3196 throw new InvalidArgumentException('URL pattern too long (max 100 characters)');
3197 }
3198
3199 // Validate pattern format - only allow safe characters
3200 if (!preg_match('/^[a-zA-Z0-9\-_{}\.]+$/', $pattern)) {
3201 throw new InvalidArgumentException('URL pattern contains invalid characters. Only letters, numbers, hyphens, underscores, dots, and {type} are allowed');
3202 }
3203
3204 // Ensure it contains {type} placeholder
3205 if (strpos($pattern, '{type}') === false) {
3206 throw new InvalidArgumentException('URL pattern must contain {type} placeholder');
3207 }
3208
3209 // Ensure it ends with .xml
3210 if (!str_ends_with($pattern, '.xml')) {
3211 $pattern .= '.xml';
3212 }
3213
3214 return sanitize_file_name($pattern);
3215 }
3216
3217 /**
3218 * Validate sitemap URL
3219 *
3220 * @since 1.0.0
3221 * @param string $url Sitemap URL
3222 * @return string Validated and sanitized URL
3223 * @throws InvalidArgumentException If validation fails
3224 */
3225 private function validate_sitemap_url(string $url): string {
3226 if (empty($url)) {
3227 throw new InvalidArgumentException('Sitemap URL cannot be empty');
3228 }
3229
3230 // Remove leading/trailing whitespace
3231 $url = trim($url);
3232
3233 // Limit URL length
3234 if (strlen($url) > 200) {
3235 throw new InvalidArgumentException('Sitemap URL too long (max 200 characters)');
3236 }
3237
3238 // Ensure it starts with /
3239 if (!str_starts_with($url, '/')) {
3240 $url = '/' . $url;
3241 }
3242
3243 // Validate URL path format
3244 if (!preg_match('/^\/[a-zA-Z0-9\-_\/\.]+\.xml$/', $url)) {
3245 throw new InvalidArgumentException('Invalid sitemap URL format. Must be a valid path ending with .xml');
3246 }
3247
3248 // Prevent directory traversal
3249 if (strpos($url, '..') !== false) {
3250 throw new InvalidArgumentException('Directory traversal not allowed in sitemap URL');
3251 }
3252
3253 // Prevent multiple slashes
3254 $url = preg_replace('/\/+/', '/', $url);
3255
3256 return sanitize_url($url);
3257 }
3258
3259 /**
3260 * Validate links per sitemap setting
3261 *
3262 * @since 1.0.0
3263 * @param mixed $links_per_sitemap Links per sitemap value
3264 * @return int Validated links per sitemap
3265 * @throws InvalidArgumentException If validation fails
3266 */
3267 private function validate_links_per_sitemap($links_per_sitemap): int {
3268 $links = intval($links_per_sitemap);
3269
3270 if ($links < 1) {
3271 throw new InvalidArgumentException('Links per sitemap must be at least 1');
3272 }
3273
3274 if ($links > 50000) {
3275 throw new InvalidArgumentException('Links per sitemap cannot exceed 50,000');
3276 }
3277
3278 return $links;
3279 }
3280
3281 /**
3282 * Validate sitemap filename for security
3283 *
3284 * @since 1.0.0
3285 * @param string $filename Filename to validate
3286 * @return string Validated and sanitized filename
3287 * @throws InvalidArgumentException If validation fails
3288 */
3289 private function validate_sitemap_filename(string $filename): string {
3290 if (empty($filename)) {
3291 return 'sitemap.xml';
3292 }
3293
3294 // Remove any path components to prevent directory traversal
3295 $filename = basename($filename);
3296
3297 // Limit filename length
3298 if (strlen($filename) > 100) {
3299 throw new InvalidArgumentException('Filename too long (max 100 characters)');
3300 }
3301
3302 // Validate filename format - only allow safe characters
3303 if (!preg_match('/^[a-zA-Z0-9\-_\.]+$/', $filename)) {
3304 throw new InvalidArgumentException('Filename contains invalid characters. Only letters, numbers, hyphens, underscores, and dots are allowed');
3305 }
3306
3307 // Prevent directory traversal attempts
3308 if (strpos($filename, '..') !== false) {
3309 throw new InvalidArgumentException('Directory traversal not allowed in filename');
3310 }
3311
3312 // Ensure it ends with .xml
3313 if (!str_ends_with($filename, '.xml')) {
3314 $filename .= '.xml';
3315 }
3316
3317 // Additional sanitization
3318 $filename = sanitize_file_name($filename);
3319
3320 // Final security check - ensure it's still a valid XML filename
3321 if (!preg_match('/^[a-zA-Z0-9\-_]+\.xml$/', $filename)) {
3322 throw new InvalidArgumentException('Invalid XML filename after sanitization');
3323 }
3324
3325 return $filename;
3326 }
3327 }
3328