PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.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.1.0, at includes/seo/class-sitemap-generator.php

2,699 lines 98.9 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 many term IDs to hydrate at a time while walking a taxonomy.
81 *
82 * @since 2.0.1
83 * @var int
84 */
85 private const TERM_WALK_CHUNK = 1000;
86
87 private array $sitemap_types = [
88 'posts' => [
89 'name' => 'Posts',
90 'post_types' => ['post'],
91 'priority' => 0.8,
92 'changefreq' => 'weekly'
93 ],
94 'pages' => [
95 'name' => 'Pages',
96 'post_types' => ['page'],
97 'priority' => 0.9,
98 'changefreq' => 'monthly'
99 ],
100 'categories' => [
101 'name' => 'Categories',
102 'taxonomy' => 'category',
103 'priority' => 0.6,
104 'changefreq' => 'weekly'
105 ],
106 'tags' => [
107 'name' => 'Tags',
108 'taxonomy' => 'post_tag',
109 'priority' => 0.4,
110 'changefreq' => 'monthly'
111 ]
112 ];
113
114 /**
115 * Constructor
116 *
117 * @since 1.0.0
118 *
119 * @param bool $register_hooks Optional. Whether to register the auto-generation
120 * hooks. Pass false for a read-only instance built
121 * solely to query settings — the hooks are bound to
122 * `$this`, so a second hook-registering instance
123 * would run `handle_content_change()` twice per save.
124 */
125 public function __construct(bool $register_hooks = true) {
126 parent::__construct('sitemap');
127
128 // Initialize auto-generation hooks
129 if ($register_hooks) {
130 $this->init_auto_generation_hooks();
131 }
132 }
133
134 /**
135 * Filter the args of a sitemap post query.
136 *
137 * Exists so integrations can widen what the sitemap sees — the multilingual
138 * manager uses it to include every language, since these queries otherwise
139 * run in whichever language happened to be active at generation time.
140 *
141 * @since 1.23.0
142 *
143 * @param array $args get_posts() arguments.
144 * @return array Filtered arguments.
145 */
146 private function filter_query_args(array $args): array {
147 /**
148 * Filter the arguments of a sitemap post query.
149 *
150 * @since 1.23.0
151 *
152 * @param array $args get_posts() arguments.
153 */
154 return (array) apply_filters('thinkrank_sitemap_query_args', $args);
155 }
156
157 /**
158 * Filter the args of a sitemap term query.
159 *
160 * @since 1.23.0
161 *
162 * @param array $args get_terms() arguments.
163 * @return array Filtered arguments.
164 */
165 private function filter_term_query_args(array $args): array {
166 /**
167 * Filter the arguments of a sitemap term query.
168 *
169 * @since 1.23.0
170 *
171 * @param array $args get_terms() arguments.
172 */
173 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
174 }
175
176 /**
177 * Initialize WordPress hooks for auto-generation
178 *
179 * @since 1.0.0
180 * @return void
181 */
182 private function init_auto_generation_hooks(): void {
183 // Content change hooks - use priority 20 to run after other plugins
184 add_action('save_post', [$this, 'handle_content_change'], 20, 2);
185 add_action('delete_post', [$this, 'handle_content_deletion'], 20);
186 add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
187 add_action('untrash_post', [$this, 'handle_content_change_by_id'], 20);
188
189 // Taxonomy change hooks
190 add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
191 add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
192 add_action('delete_term', [$this, 'handle_taxonomy_change'], 20, 3);
193
194 // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
195 // and thinkrank_regenerate_sitemap_settings) are registered at plugin
196 // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
197 // rather than here. A cron run never builds this class via the REST
198 // endpoint (no rest_api_init), so registering them in the constructor
199 // would leave the scheduled events with no listener at cron time.
200 }
201
202 /**
203 * Generate XML sitemap
204 *
205 * @since 1.0.0
206 *
207 * @param array $options Sitemap generation options
208 * @return string XML sitemap content
209 */
210 public function generate_sitemap(array $options = []): string {
211 $settings = $this->get_settings('site');
212
213 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
214
215 // Add XSL stylesheet only if styling is enabled
216 if (!empty($settings['enable_styling'])) {
217 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
218 }
219
220 // Add image namespace if images are enabled
221 if (!empty($settings['include_images'])) {
222 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
223 } else {
224 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
225 }
226
227 // Add homepage. Use the static front page's real modified time when set,
228 // so lastmod reflects real changes rather than the generation time.
229 $xml .= $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
230
231 // MEMORY-EFFICIENT: Generate posts using chunked processing.
232 // Note: the single "general" sitemap includes every URL (no per-file cap);
233 // per-file chunking applies to the segmented/index model where each type
234 // gets its own paginated file (see generate_multiple_sitemaps).
235 $enabled_post_types = $this->get_enabled_post_types($settings);
236 if (!empty($enabled_post_types)) {
237 $xml .= implode('', $this->collect_post_entries($enabled_post_types, $settings));
238 }
239
240 // OPTIMIZED: Get all enabled taxonomies and fetch in single query
241 $enabled_taxonomies = $this->get_enabled_taxonomies($settings);
242 if (!empty($enabled_taxonomies)) {
243 $all_taxonomy_data = $this->fetch_taxonomies_optimized($enabled_taxonomies, $settings);
244
245 // Process each taxonomy's data
246 foreach ($enabled_taxonomies as $taxonomy) {
247 if (!empty($all_taxonomy_data[$taxonomy])) {
248 $xml .= $this->process_taxonomies_to_xml($all_taxonomy_data[$taxonomy], $taxonomy, $settings);
249 }
250 }
251 }
252
253 $xml .= '</urlset>';
254
255 return $xml;
256 }
257
258 /**
259 * Validate SEO settings (implements interface)
260 *
261 * @since 1.0.0
262 *
263 * @param array $settings Settings array to validate
264 * @return array Validation results
265 */
266 public function validate_settings(array $settings): array {
267 $validation = [
268 'valid' => true,
269 'errors' => [],
270 'warnings' => [],
271 'suggestions' => []
272 ];
273
274 try {
275 // Validate exclude_posts
276 if (!empty($settings['exclude_posts'])) {
277 $this->validate_exclude_posts($settings['exclude_posts']);
278 }
279
280 // Validate exclude_terms
281 if (!empty($settings['exclude_terms'])) {
282 $this->validate_exclude_terms($settings['exclude_terms']);
283 }
284
285 // Validate custom_url_pattern
286 if (!empty($settings['custom_url_pattern'])) {
287 $this->validate_custom_url_pattern($settings['custom_url_pattern']);
288 }
289
290 // Validate sitemap_urls
291 if (!empty($settings['sitemap_urls']) && is_array($settings['sitemap_urls'])) {
292 foreach ($settings['sitemap_urls'] as $sitemap) {
293 if (!empty($sitemap['url'])) {
294 $this->validate_sitemap_url($sitemap['url']);
295 }
296 }
297 }
298
299 // Validate numeric settings
300 if (isset($settings['links_per_sitemap'])) {
301 $this->validate_links_per_sitemap($settings['links_per_sitemap']);
302 }
303
304 } catch (InvalidArgumentException $e) {
305 $validation['valid'] = false;
306 $validation['errors'][] = $e->getMessage();
307 }
308
309 return $validation;
310 }
311
312 /**
313 * Get output data for frontend rendering (implements interface)
314 *
315 * @since 1.0.0
316 *
317 * @param string $context_type The context type
318 * @param int|null $context_id Optional. Context ID
319 * @return array Output data ready for frontend rendering
320 */
321 public function get_output_data(string $context_type, ?int $context_id): array {
322 $settings = $this->get_settings($context_type, $context_id);
323
324 return [
325 'sitemap_url' => home_url('/sitemap.xml'),
326 'enabled' => $settings['enabled'] ?? true,
327 'last_generated' => $settings['last_generated'] ?? '',
328 'total_urls' => $this->count_sitemap_urls($settings)
329 ];
330 }
331
332 /**
333 * Sitemap keys outside the defaults.
334 *
335 * @since 2.0.1
336 *
337 * @return string[]
338 */
339 protected function additional_setting_keys(): array {
340 return ['selected_preset'];
341 }
342
343 /**
344 * Inclusion flags are per post type and per taxonomy.
345 *
346 * A site registering a `product` post type stores `include_product`; an
347 * enumerated list would go stale on the next registration, so the family
348 * is matched instead.
349 *
350 * @since 2.0.1
351 *
352 * @return string[]
353 */
354 protected function dynamic_setting_key_patterns(): array {
355 return ['/^include_[a-z0-9_]+$/', '/^exclude_[a-z0-9_]+$/'];
356 }
357
358 /**
359 * Get default settings for a context type (implements interface)
360 *
361 * @since 1.0.0
362 *
363 * @param string $context_type The context type to get defaults for
364 * @return array Default settings array
365 */
366 public function get_default_settings(string $context_type): array {
367 return [
368 // Core settings
369 'enabled' => true,
370
371 // Multiple Sitemap URLs
372 'sitemap_urls' => [
373 [
374 'url' => '/sitemap.xml',
375 'type' => 'general',
376 'enabled' => true,
377 'last_checked' => null,
378 'status' => 'unknown'
379 ]
380 ],
381 'use_sitemap_index' => false,
382
383 // General Settings
384 'links_per_sitemap' => 1000,
385 'include_images' => true,
386 'include_featured_images' => false,
387 'auto_generate' => true,
388 'ping_search_engines' => true,
389
390 // Content Inclusion (backward compatibility)
391 'include_posts' => true,
392 'include_pages' => true,
393 'include_categories' => true,
394 'include_tags' => false,
395
396 // Content Filtering
397 'exclude_posts' => '',
398 'exclude_terms' => '',
399 'exclude_password_protected' => true,
400 'exclude_private_posts' => true,
401
402 // Advanced Options
403 'enable_styling' => true,
404 'custom_url_pattern' => 'sitemap-{type}.xml',
405
406 // Generation tracking
407 'last_generated' => ''
408 ];
409 }
410
411 /**
412 * Get settings schema definition (implements interface)
413 *
414 * @since 1.0.0
415 *
416 * @param string $context_type The context type to get schema for
417 * @return array Settings schema definition
418 */
419 public function get_settings_schema(string $context_type): array {
420 return [
421 'enabled' => [
422 'type' => 'boolean',
423 'title' => 'Enable Sitemap',
424 'description' => 'Generate XML sitemap for search engines',
425 'default' => true
426 ],
427 'include_posts' => [
428 'type' => 'boolean',
429 'title' => 'Include Posts',
430 'description' => 'Include blog posts in sitemap',
431 'default' => true
432 ],
433 'include_pages' => [
434 'type' => 'boolean',
435 'title' => 'Include Pages',
436 'description' => 'Include static pages in sitemap',
437 'default' => true
438 ],
439 'include_categories' => [
440 'type' => 'boolean',
441 'title' => 'Include Categories',
442 'description' => 'Include category pages in sitemap',
443 'default' => true
444 ],
445 'include_tags' => [
446 'type' => 'boolean',
447 'title' => 'Include Tags',
448 'description' => 'Include tag pages in sitemap',
449 'default' => false
450 ],
451
452 'auto_generate' => [
453 'type' => 'boolean',
454 'title' => 'Auto Generate',
455 'description' => 'Automatically regenerate sitemap when content changes',
456 'default' => true
457 ],
458 'ping_search_engines' => [
459 'type' => 'boolean',
460 'title' => 'Ping Search Engines',
461 'description' => 'Notify Google and Bing when sitemap is updated',
462 'default' => true
463 ],
464 'last_generated' => [
465 'type' => 'string',
466 'title' => 'Last Generated',
467 'description' => 'Timestamp of last sitemap generation',
468 'default' => ''
469 ]
470 ];
471 }
472
473 /**
474 * Generate URL entry for sitemap
475 *
476 * @since 1.0.0
477 *
478 * @param string $url URL
479 * @param string $lastmod Last modification date
480 * @param float $priority Priority (0.0 to 1.0)
481 * @param string $changefreq Change frequency
482 * @param array $images Optional array of image data
483 * @return string XML URL entry
484 */
485 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
486 $xml = " <url>\n";
487 $xml .= " <loc>" . esc_url($url) . "</loc>\n";
488 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
489 // than no timestamp, and an absent lastmod is valid per the spec.
490 if (!empty($lastmod)) {
491 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
492 }
493 $xml .= " <priority>" . number_format($priority, 1) . "</priority>\n";
494 $xml .= " <changefreq>" . esc_html($changefreq) . "</changefreq>\n";
495
496 // Add image entries if provided
497 foreach ($images as $image) {
498 $xml .= " <image:image>\n";
499 $xml .= " <image:loc>" . esc_url($image['url']) . "</image:loc>\n";
500
501 if (!empty($image['title'])) {
502 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
503 }
504
505 if (!empty($image['alt'])) {
506 $xml .= " <image:caption>" . esc_html($image['alt']) . "</image:caption>\n";
507 }
508
509 $xml .= " </image:image>\n";
510 }
511
512 $xml .= " </url>\n";
513
514 return $xml;
515 }
516
517 /**
518 * Collect every post <url> entry for the given post type(s) as an array of
519 * XML strings, using memory-efficient chunked fetching.
520 *
521 * Unlike the old capped generator, this returns ALL matching entries — the
522 * links-per-sitemap limit is applied later by paginating this array into
523 * separate files (see generate_multiple_sitemaps), matching how Rank Math
524 * splits large sitemaps instead of truncating them.
525 *
526 * @since 1.14.0
527 *
528 * @param array $post_types Array of post types to fetch
529 * @param array $settings Sitemap settings
530 * @return array<string> Individual <url>…</url> entry strings
531 */
532 /**
533 * lastmod for the homepage <url> entry: the static front page's real
534 * modification time when one is set, otherwise the generation time.
535 *
536 * @return string ISO-8601 date.
537 */
538 private function get_homepage_lastmod(): string {
539 if (get_option('show_on_front') === 'page') {
540 $front_id = (int) get_option('page_on_front');
541 if ($front_id) {
542 $modified = get_post_field('post_modified_gmt', $front_id);
543 if (!empty($modified) && $modified !== '0000-00-00 00:00:00') {
544 return gmdate('c', strtotime($modified));
545 }
546 }
547 }
548 return gmdate('c');
549 }
550
551 private function collect_post_entries(array $post_types, array $settings): array {
552 // Materialize the streaming source for callers that need the full set
553 // (e.g. the single un-paginated "general" sitemap). The paginated index
554 // path streams collect_post_entries_iter() directly to bound memory.
555 return iterator_to_array($this->collect_post_entries_iter($post_types, $settings), false);
556 }
557
558 /**
559 * Stream <url> entries for the given post types, yielding one at a time.
560 *
561 * Same chunked query, filtering, and ordering as before, but yields each
562 * entry instead of accumulating the whole set — so the paginated index
563 * generator never holds every URL of a large post type in memory at once.
564 *
565 * @param array $post_types Post types to include.
566 * @param array $settings Sitemap settings.
567 * @return \Generator<string> <url> entry strings.
568 */
569 private function collect_post_entries_iter(array $post_types, array $settings): \Generator {
570 if (empty($post_types)) {
571 return;
572 }
573
574 // Parse and validate exclude_posts setting
575 $exclude_ids = [];
576 if (!empty($settings['exclude_posts'])) {
577 try {
578 $exclude_ids = $this->validate_exclude_posts($settings['exclude_posts']);
579 } catch (InvalidArgumentException $e) {
580 // Continue with empty array on validation failure - error details available in exception
581 }
582 }
583
584 // The static front page is emitted once as the explicit homepage entry,
585 // so exclude it here to avoid a duplicate <loc> (its permalink equals
586 // home_url('/')).
587 if (get_option('show_on_front') === 'page') {
588 $front_id = (int) get_option('page_on_front');
589 if ($front_id) {
590 $exclude_ids[] = $front_id;
591 }
592 }
593
594 // Resolve the ordered ID list in one indexed query, then hydrate in
595 // chunks via post__in. This avoids large OFFSET windows (which MySQL
596 // must scan-and-discard, making a full walk O(n^2)) while still loading
597 // only one chunk of full post objects into memory at a time. The
598 // original order is preserved (the id query and post__in hydration both
599 // use it).
600 $all_ids = get_posts($this->filter_query_args([
601 'post_type' => $post_types,
602 'post_status' => 'publish',
603 'numberposts' => -1,
604 'exclude' => $exclude_ids,
605 'orderby' => 'post_type post_date',
606 'order' => 'ASC DESC',
607 'fields' => 'ids',
608 ]));
609
610 if (empty($all_ids)) {
611 return;
612 }
613
614 // Walk the ID list with a moving window rather than array_chunk().
615 // array_chunk() builds a second array holding every element again, so
616 // peak memory was twice the ID list — on a 100k-post site that is ~16MB
617 // where ~8MB is needed, and this walk is the one part of an otherwise
618 // well-bounded routine with no ceiling (#402).
619 $total = count($all_ids);
620
621 for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
622 $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
623
624 $posts = get_posts($this->filter_query_args([
625 'post_type' => $post_types,
626 'post_status' => 'publish',
627 'numberposts' => count($chunk),
628 'post__in' => $chunk,
629 'orderby' => 'post__in', // preserve the resolved order
630 ]));
631
632 foreach ($posts as $post) {
633 if ($this->should_include_in_sitemap($post, $settings)) {
634 /**
635 * Filter a sitemap entry's permalink.
636 *
637 * The multilingual manager uses this to generate each
638 * translation's URL in its OWN language: the sitemap query
639 * deliberately runs with suppress_filters, and the cron
640 * rebuild runs with no language context at all, so a bare
641 * get_permalink() resolved every translation to the
642 * default-language URL — N entries sharing one <loc> (#409).
643 *
644 * @since 2.0.1
645 * @param string $url Permalink as WordPress resolved it.
646 * @param \WP_Post $post Post the entry describes.
647 */
648 $url = apply_filters('thinkrank_sitemap_post_permalink', get_permalink($post), $post);
649 $lastmod = gmdate('c', strtotime($post->post_modified_gmt));
650 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
651 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
652 $images = $this->extract_post_images($post, $settings);
653
654 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
655 }
656 }
657
658 // Free the hydrated chunk before loading the next one.
659 unset($posts);
660 }
661 }
662
663 /**
664 * Resolve and bound the configured links-per-sitemap limit.
665 *
666 * @since 1.14.0
667 *
668 * @param array $settings Sitemap settings
669 * @return int Links per sitemap file (1–50000)
670 */
671 private function get_links_per_sitemap(array $settings): int {
672 $limit = !empty($settings['links_per_sitemap']) ? intval($settings['links_per_sitemap']) : 1000;
673
674 return max(1, min(50000, $limit));
675 }
676
677 /**
678 * Stream <url> entries for a taxonomy's terms, yielding one at a time and
679 * fetching terms in bounded chunks (number/offset) — so the paginated index
680 * generator never holds every term of a large taxonomy in memory at once.
681 *
682 * @param string $taxonomy Taxonomy name.
683 * @param array $settings Sitemap settings.
684 * @return \Generator<string> <url> entry strings.
685 */
686 private function collect_taxonomy_entries_iter(string $taxonomy, array $settings): \Generator {
687 $exclude_term_ids = [];
688 if (!empty($settings['exclude_terms'])) {
689 try {
690 $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
691 } catch (InvalidArgumentException $e) {
692 // Continue with an empty exclude list on validation failure.
693 }
694 }
695
696 // product_cat may legitimately have empty terms (products added later);
697 // every other taxonomy hides empties — matching fetch_taxonomies_optimized().
698 $hide_empty = $taxonomy !== 'product_cat';
699 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
700
701 // Resolve the ordered term IDs in one query, then hydrate in chunks via
702 // include. Avoids large OFFSET windows (O(n^2) over a full walk) while
703 // holding only one chunk of full term objects at a time.
704 $all_ids = get_terms($this->filter_term_query_args([
705 'taxonomy' => $taxonomy,
706 'hide_empty' => $hide_empty,
707 'exclude' => $exclude_term_ids,
708 'orderby' => 'count',
709 'order' => 'DESC',
710 'fields' => 'ids',
711 ]));
712
713 if (is_wp_error($all_ids) || empty($all_ids)) {
714 return;
715 }
716
717 // Same moving window as the post walk above, for the same reason.
718 $total = count($all_ids);
719
720 for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
721 $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
722
723 $terms = get_terms($this->filter_term_query_args([
724 'taxonomy' => $taxonomy,
725 'include' => $chunk,
726 'orderby' => 'include', // preserve the resolved order
727 'hide_empty' => false, // already filtered by the id query
728 ]));
729
730 if (is_wp_error($terms) || empty($terms)) {
731 continue;
732 }
733
734 foreach ($terms as $term) {
735 // A term the user marked noindex must not be advertised in the
736 // sitemap: the robots tag now honours term meta, so listing it
737 // here would have the sitemap contradict the page's own tag.
738 if ($this->term_is_noindexed((int) $term->term_id)) {
739 continue;
740 }
741
742 $url = get_term_link($term);
743 if (!is_wp_error($url)) {
744 // Omit lastmod for terms — the generation time is not a real
745 // modification time and would mislabel every term as just-changed.
746 yield $this->generate_url_entry($url, '', $priority, 'weekly');
747 }
748 }
749
750 unset($terms);
751 }
752 }
753
754 /**
755 * Wrap a set of <url> entry strings in a complete <urlset> document.
756 *
757 * @since 1.14.0
758 *
759 * @param array<string> $entries Entry strings
760 * @param array $settings Sitemap settings
761 * @param bool $with_image_ns Include the image sitemap namespace
762 * @return string Full sitemap XML
763 */
764 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
765 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
766
767 if (!empty($settings['enable_styling'])) {
768 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
769 }
770
771 if ($with_image_ns && !empty($settings['include_images'])) {
772 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
773 } else {
774 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
775 }
776
777 $xml .= implode('', $entries);
778 $xml .= '</urlset>';
779
780 return $xml;
781 }
782
783 /**
784 * Derive the filename/URL for a given pagination page.
785 *
786 * Page 1 keeps the base URL (e.g. /sitemap-posts.xml); pages 2+ insert the
787 * page number before the extension (e.g. /sitemap-posts-2.xml).
788 *
789 * @since 1.14.0
790 *
791 * @param string $url Base sitemap URL
792 * @param int $page 1-based page number
793 * @return string Paginated URL
794 */
795 private function paginate_url(string $url, int $page): string {
796 if ($page <= 1) {
797 return $url;
798 }
799
800 return (string) preg_replace('/\.xml$/i', '-' . $page . '.xml', $url);
801 }
802
803
804
805 /**
806 * Extract images from post for sitemap
807 *
808 * @since 1.0.0
809 *
810 * @param \WP_Post $post Post object
811 * @param array $settings Sitemap settings
812 * @return array Array of image data
813 */
814 private function extract_post_images(\WP_Post $post, array $settings): array {
815 $images = [];
816
817 // Skip if images are disabled
818 if (empty($settings['include_images'])) {
819 return $images;
820 }
821
822 // Get featured image if enabled
823 if (!empty($settings['include_featured_images'])) {
824 $featured_image = $this->get_featured_image($post->ID);
825 if ($featured_image) {
826 $images[] = $featured_image;
827 }
828 }
829
830 // Extract images from content
831 $content_images = $this->extract_content_images($post->post_content);
832 $images = array_merge($images, $content_images);
833
834 // Remove duplicates based on URL
835 $unique_images = [];
836 $seen_urls = [];
837 foreach ($images as $image) {
838 if (!in_array($image['url'], $seen_urls, true)) {
839 $unique_images[] = $image;
840 $seen_urls[] = $image['url'];
841 }
842 }
843
844 return $unique_images;
845 }
846
847 /**
848 * Get featured image data
849 *
850 * @since 1.0.0
851 *
852 * @param int $post_id Post ID
853 * @return array|null Featured image data or null
854 */
855 private function get_featured_image(int $post_id): ?array {
856 $thumbnail_id = get_post_thumbnail_id($post_id);
857 if (!$thumbnail_id) {
858 return null;
859 }
860
861 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
862 if (!$image_url) {
863 return null;
864 }
865
866 $image_title = get_the_title($thumbnail_id);
867 $image_alt = get_post_meta($thumbnail_id, '_wp_attachment_image_alt', true);
868
869 return [
870 'url' => $image_url,
871 'title' => $image_title ?: '',
872 'alt' => $image_alt ?: ''
873 ];
874 }
875
876 /**
877 * Extract images from post content
878 *
879 * @since 1.0.0
880 *
881 * @param string $content Post content
882 * @return array Array of image data
883 */
884 private function extract_content_images(string $content): array {
885 $images = [];
886
887 // Find all img tags in content
888 preg_match_all('/<img[^>]+>/i', $content, $img_tags);
889
890 foreach ($img_tags[0] as $img_tag) {
891 // Extract src attribute
892 if (preg_match('/src=["\']([^"\']+)["\']/', $img_tag, $src_match)) {
893 $image_url = $src_match[1];
894
895 // Skip if not a valid URL or external image
896 if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
897 continue;
898 }
899
900 // Extract title and alt attributes
901 $title = '';
902 $alt = '';
903
904 if (preg_match('/title=["\']([^"\']*)["\']/', $img_tag, $title_match)) {
905 $title = $title_match[1];
906 }
907
908 if (preg_match('/alt=["\']([^"\']*)["\']/', $img_tag, $alt_match)) {
909 $alt = $alt_match[1];
910 }
911
912 $images[] = [
913 'url' => $image_url,
914 'title' => $title,
915 'alt' => $alt
916 ];
917 }
918 }
919
920 return $images;
921 }
922
923 /**
924 * Get enabled taxonomies based on settings
925 *
926 * @since 1.0.0
927 * @param array $settings Sitemap settings
928 * @return array Array of enabled taxonomies
929 */
930 private function get_enabled_taxonomies(array $settings): array {
931 $taxonomies = [];
932
933 // Core taxonomies based on settings
934 if (!empty($settings['include_categories'])) { // �
935 Evidence-based field name
936 $taxonomies[] = 'category';
937 }
938
939 if (!empty($settings['include_tags'])) { // �
940 Evidence-based field name
941 $taxonomies[] = 'post_tag';
942 }
943
944 // Auto-detect public custom taxonomies
945 $custom_taxonomies = get_taxonomies([
946 'public' => true,
947 '_builtin' => false
948 ], 'names');
949
950 foreach ($custom_taxonomies as $taxonomy) {
951 if ($this->should_include_taxonomy($taxonomy)) {
952 $taxonomies[] = $taxonomy;
953 }
954 }
955
956 return array_unique($taxonomies);
957 }
958
959
960
961 /**
962 * Fetch taxonomies using optimized combined query
963 *
964 * @since 1.0.0
965 *
966 * @param array $taxonomies Array of taxonomy names to fetch
967 * @param array $settings Sitemap settings
968 * @return array Grouped terms by taxonomy
969 */
970 private function fetch_taxonomies_optimized(array $taxonomies, array $settings): array {
971 if (empty($taxonomies)) {
972 return [];
973 }
974
975 // Parse and validate exclude_terms setting
976 $exclude_term_ids = [];
977 if (!empty($settings['exclude_terms'])) {
978 try {
979 $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
980 } catch (InvalidArgumentException $e) {
981 // Continue with empty array on validation failure - error details available in exception
982 }
983 }
984
985 // Determine hide_empty setting based on taxonomies
986 $hide_empty = true;
987 foreach ($taxonomies as $taxonomy) {
988 // For product categories, don't hide empty categories since they might not have products yet
989 if ($taxonomy === 'product_cat') {
990 $hide_empty = false;
991 break;
992 }
993 }
994
995 // OPTIMIZED: Single query for multiple taxonomies
996 $all_terms = get_terms($this->filter_term_query_args([
997 'taxonomy' => $taxonomies, // �
998 Multiple taxonomies in single query
999 'hide_empty' => $hide_empty,
1000 'exclude' => $exclude_term_ids,
1001 'orderby' => 'taxonomy count',
1002 'order' => 'ASC DESC' // Order by taxonomy ASC, then count DESC
1003 ]));
1004
1005 if (is_wp_error($all_terms)) {
1006 return [];
1007 }
1008
1009 // Drop terms the user marked noindex. This path feeds the single general
1010 // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
1011 // so both need the filter or the two disagree about the same term.
1012 $all_terms = array_values(array_filter(
1013 $all_terms,
1014 fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1015 ));
1016
1017 // Group terms by taxonomy
1018 return $this->group_terms_by_taxonomy($all_terms);
1019 }
1020
1021 /**
1022 * Group terms by taxonomy
1023 *
1024 * @since 1.0.0
1025 *
1026 * @param array $terms Array of term objects
1027 * @return array Grouped terms by taxonomy
1028 */
1029 private function group_terms_by_taxonomy(array $terms): array {
1030 $grouped = [];
1031
1032 foreach ($terms as $term) {
1033 $taxonomy = $term->taxonomy; // �
1034 Evidence-based property name
1035
1036 if (!isset($grouped[$taxonomy])) {
1037 $grouped[$taxonomy] = [];
1038 }
1039
1040 $grouped[$taxonomy][] = $term;
1041 }
1042
1043 return $grouped;
1044 }
1045
1046 /**
1047 * Process taxonomy terms to XML entries
1048 *
1049 * @since 1.0.0
1050 *
1051 * @param array $terms Array of term objects
1052 * @param string $taxonomy Taxonomy name
1053 * @param array $settings Sitemap settings
1054 * @return string XML entries
1055 */
1056 private function process_taxonomies_to_xml(array $terms, string $taxonomy, array $settings): string {
1057 $xml = '';
1058
1059 foreach ($terms as $term) {
1060 $url = get_term_link($term);
1061 if (!is_wp_error($url)) {
1062 // Omit lastmod for terms (generation time is not a real
1063 // modification time).
1064 $lastmod = '';
1065 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
1066 $changefreq = 'weekly';
1067
1068 $xml .= $this->generate_url_entry($url, $lastmod, $priority, $changefreq);
1069 }
1070 }
1071
1072 return $xml;
1073 }
1074
1075 /**
1076 * Calculate intelligent priority based on content factors
1077 *
1078 * @since 1.0.0
1079 *
1080 * @param \WP_Post $post Post object
1081 * @param string $post_type Post type
1082 * @return float Priority value between 0.1 and 1.0
1083 */
1084 private function calculate_intelligent_priority(\WP_Post $post, string $post_type): float {
1085 $base_priority = $post_type === 'page' ? 0.9 : 0.8;
1086
1087 // Factors that can adjust priority
1088 $adjustments = 0;
1089
1090 // Recent content gets higher priority
1091 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
1092 if ($days_old < 30) {
1093 $adjustments += 0.1; // Recent content boost
1094 } elseif ($days_old > 365) {
1095 $adjustments -= 0.1; // Older content penalty
1096 }
1097
1098 // Content length factor
1099 $content_length = strlen(wp_strip_all_tags($post->post_content));
1100 if ($content_length > 2000) {
1101 $adjustments += 0.05; // Comprehensive content boost
1102 } elseif ($content_length < 500) {
1103 $adjustments -= 0.1; // Thin content penalty
1104 }
1105
1106 // Special page types get higher priority
1107 if ($post_type === 'page') {
1108 $page_template = get_page_template_slug($post->ID);
1109 if (in_array($page_template, ['page-home.php', 'front-page.php'], true) ||
1110 (int) $post->ID === (int) get_option('page_on_front')) {
1111 $base_priority = 1.0; // Homepage gets maximum priority
1112 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1113 $adjustments += 0.05; // Important pages boost
1114 }
1115 }
1116
1117 // Ensure priority stays within valid range
1118 $final_priority = max(0.1, min(1.0, $base_priority + $adjustments));
1119
1120 return round($final_priority, 1);
1121 }
1122
1123 /**
1124 * Calculate change frequency based on content type and age
1125 *
1126 * @since 1.0.0
1127 *
1128 * @param \WP_Post $post Post object
1129 * @param string $post_type Post type
1130 * @return string Change frequency
1131 */
1132 private function calculate_change_frequency(\WP_Post $post, string $post_type): string {
1133 // Pages typically change less frequently
1134 if ($post_type === 'page') {
1135 $page_template = get_page_template_slug($post->ID);
1136 if ((int) $post->ID === (int) get_option('page_on_front')) {
1137 return 'daily'; // Homepage changes frequently
1138 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1139 return 'monthly'; // Static pages change monthly
1140 }
1141 return 'yearly'; // Other pages change rarely
1142 }
1143
1144 // Posts frequency based on age and type
1145 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
1146
1147 if ($days_old < 7) {
1148 return 'daily'; // Very recent posts
1149 } elseif ($days_old < 30) {
1150 return 'weekly'; // Recent posts
1151 } elseif ($days_old < 365) {
1152 return 'monthly'; // Older posts
1153 }
1154
1155 return 'yearly'; // Very old posts
1156 }
1157
1158 /**
1159 * Determine if content should be included in sitemap
1160 *
1161 * @since 1.0.0
1162 *
1163 * @param \WP_Post $post Post object
1164 * @param array $settings Sitemap settings
1165 * @return bool Whether to include in sitemap
1166 */
1167 private function should_include_in_sitemap(\WP_Post $post, array $settings): bool {
1168 // The WooCommerce cart, checkout and account pages are transactional,
1169 // never indexable, and generate_default_robots_rules() already emits a
1170 // Disallow for each of them. Listing them here submitted URLs our own
1171 // robots.txt blocks, which Search Console reports as "Submitted URL
1172 // blocked by robots.txt". Yoast and Rank Math exclude the same three.
1173 if (in_array($post->ID, $this->woocommerce_excluded_page_ids(), true)) {
1174 return false;
1175 }
1176
1177 // Respect user setting for password protected content
1178 if (!empty($post->post_password) && !empty($settings['exclude_password_protected'])) {
1179 return false;
1180 }
1181
1182 // Respect user setting for private posts
1183 if ($post->post_status === 'private' && !empty($settings['exclude_private_posts'])) {
1184 return false;
1185 }
1186
1187 // Only published (and, per setting, private) content belongs in the
1188 // sitemap. Content-quality heuristics (length, "demo"/"test"/"sample"
1189 // in the title, "lorem ipsum" text) were intentionally removed: an XML
1190 // sitemap should list every indexable published URL. Filtering by
1191 // description length silently dropped legitimate WooCommerce products
1192 // with short descriptions, and the substring title match excluded real
1193 // pages such as "Demo" or "Product Samples". Indexability is governed
1194 // by noindex directives below, not by heuristics.
1195 if (!in_array($post->post_status, ['publish', 'private'], true)) {
1196 return false;
1197 }
1198
1199 // Check if post overrides robots and sets noindex.
1200 if ((bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true)) {
1201 $raw = get_post_meta($post->ID, '_thinkrank_robots_meta', true);
1202 if (is_string($raw) && $raw !== '') {
1203 $robots = json_decode($raw, true);
1204 if (is_array($robots) && !empty($robots['noindex'])) {
1205 return false;
1206 }
1207 }
1208 }
1209
1210 return true;
1211 }
1212
1213 /**
1214 * WooCommerce pages that must never reach the sitemap.
1215 *
1216 * Resolved through wc_get_page_id() so a store that moved or renamed its
1217 * cart/checkout/account pages is still matched. Returns an empty list when
1218 * WooCommerce is not active. Memoised — should_include_in_sitemap() runs
1219 * once per post.
1220 *
1221 * @since 2.0.1
1222 *
1223 * @return int[] Page IDs to exclude.
1224 */
1225 private function woocommerce_excluded_page_ids(): array {
1226 if ($this->woocommerce_excluded_page_ids !== null) {
1227 return $this->woocommerce_excluded_page_ids;
1228 }
1229
1230 $ids = [];
1231
1232 if (function_exists('wc_get_page_id')) {
1233 foreach (['cart', 'checkout', 'myaccount'] as $page) {
1234 $id = (int) wc_get_page_id($page);
1235 // wc_get_page_id() returns -1 when the page is not configured.
1236 if ($id > 0) {
1237 $ids[] = $id;
1238 }
1239 }
1240 }
1241
1242 $this->woocommerce_excluded_page_ids = $ids;
1243
1244 return $ids;
1245 }
1246
1247 /**
1248 * Whether a term carries an explicit noindex override.
1249 *
1250 * Mirrors the post-side check in should_include_post(); terms store the same
1251 * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1252 * by the update-term-seo ability and by the SEO importer.
1253 *
1254 * @since 1.31.0
1255 *
1256 * @param int $term_id Term to test.
1257 * @return bool True when the term is marked noindex.
1258 */
1259 private function term_is_noindexed(int $term_id): bool {
1260 if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1261 return false;
1262 }
1263
1264 $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1265 if (!is_string($raw) || $raw === '') {
1266 return false;
1267 }
1268
1269 $robots = json_decode($raw, true);
1270
1271 return is_array($robots) && !empty($robots['noindex']);
1272 }
1273
1274 /**
1275 * Count total URLs in sitemap
1276 *
1277 * @since 1.0.0
1278 *
1279 * @param array $settings Sitemap settings
1280 * @return int Total URL count
1281 */
1282 public function count_sitemap_urls(array $settings): int {
1283 $count = 1; // Homepage
1284
1285 if (!empty($settings['include_posts'])) {
1286 $count += wp_count_posts('post')->publish;
1287 }
1288
1289 if (!empty($settings['include_pages'])) {
1290 $count += wp_count_posts('page')->publish;
1291 }
1292
1293 if (!empty($settings['include_categories'])) {
1294 $count += wp_count_terms(['taxonomy' => 'category', 'hide_empty' => true]);
1295 }
1296
1297 if (!empty($settings['include_tags'])) {
1298 $count += wp_count_terms(['taxonomy' => 'post_tag', 'hide_empty' => true]);
1299 }
1300
1301 return $count;
1302 }
1303
1304 /**
1305 * Handle content changes for auto-generation
1306 *
1307 * @since 1.0.0
1308 * @param int $post_id Post ID
1309 * @param \WP_Post $post Post object
1310 * @return void
1311 */
1312 public function handle_content_change(int $post_id, \WP_Post $post): void {
1313 // Skip if auto-generation is disabled
1314 if (!$this->should_auto_generate()) {
1315 return;
1316 }
1317
1318 // Skip autosaves and revisions
1319 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
1320 return;
1321 }
1322
1323 // Only process published content
1324 if ($post->post_status !== 'publish') {
1325 return;
1326 }
1327
1328 // Check if this post type should trigger regeneration
1329 if (!$this->should_include_post_type($post->post_type)) {
1330 return;
1331 }
1332
1333 // Schedule debounced regeneration
1334 $this->schedule_debounced_regeneration();
1335 }
1336
1337 /**
1338 * Handle content deletion for auto-generation
1339 *
1340 * @since 1.0.0
1341 * @param int $post_id Post ID
1342 * @return void
1343 */
1344 public function handle_content_deletion(int $post_id): void {
1345 // Skip if auto-generation is disabled
1346 if (!$this->should_auto_generate()) {
1347 return;
1348 }
1349
1350 $post = get_post($post_id);
1351 if (!$post) {
1352 return;
1353 }
1354
1355 // Check if this post type should trigger regeneration
1356 if (!$this->should_include_post_type($post->post_type)) {
1357 return;
1358 }
1359
1360 // Schedule debounced regeneration
1361 $this->schedule_debounced_regeneration();
1362 }
1363
1364 /**
1365 * Handle content change by ID (for untrash, etc.)
1366 *
1367 * @since 1.0.0
1368 * @param int $post_id Post ID
1369 * @return void
1370 */
1371 public function handle_content_change_by_id(int $post_id): void {
1372 $post = get_post($post_id);
1373 if ($post) {
1374 $this->handle_content_change($post_id, $post);
1375 }
1376 }
1377
1378 /**
1379 * Handle taxonomy changes for auto-generation
1380 *
1381 * @since 1.0.0
1382 * @param int $term_id Term ID
1383 * @param int $tt_id Term taxonomy ID
1384 * @param string $taxonomy Taxonomy slug
1385 * @return void
1386 */
1387 public function handle_taxonomy_change(int $term_id, int $tt_id, string $taxonomy): void {
1388 // Skip if auto-generation is disabled
1389 if (!$this->should_auto_generate()) {
1390 return;
1391 }
1392
1393 // Check if this taxonomy should trigger regeneration
1394 if (!$this->should_include_taxonomy($taxonomy)) {
1395 return;
1396 }
1397
1398 // Schedule debounced regeneration
1399 $this->schedule_debounced_regeneration();
1400 }
1401
1402 /**
1403 * Check if auto-generation is enabled
1404 *
1405 * @since 1.0.0
1406 * @return bool True if auto-generation is enabled
1407 */
1408 private function should_auto_generate(): bool {
1409 $settings = $this->get_settings('site');
1410
1411 // Check if sitemap is enabled
1412 if (empty($settings['enabled'])) {
1413 return false;
1414 }
1415
1416 // Check if auto-generation is enabled
1417 return !empty($settings['auto_generate']);
1418 }
1419
1420 /**
1421 * Get enabled post types based on settings
1422 *
1423 * @since 1.0.0
1424 * @param array $settings Sitemap settings
1425 * @return array Array of enabled post types
1426 */
1427 private function get_enabled_post_types(array $settings): array {
1428 $post_types = [];
1429
1430 // Core post types based on settings
1431 if (!empty($settings['include_posts'])) {
1432 $post_types[] = 'post';
1433 }
1434
1435 if (!empty($settings['include_pages'])) {
1436 $post_types[] = 'page';
1437 }
1438
1439 // Auto-detect public custom post types that should be included
1440 $custom_post_types = get_post_types([
1441 'public' => true,
1442 '_builtin' => false
1443 ], 'names');
1444
1445 foreach ($custom_post_types as $post_type) {
1446 if ($this->should_include_post_type($post_type)) {
1447 $post_types[] = $post_type;
1448 }
1449 }
1450
1451 return array_unique($post_types);
1452 }
1453
1454 /**
1455 * Check if post type should trigger regeneration
1456 *
1457 * @since 1.0.0
1458 * @param string $post_type Post type
1459 * @return bool True if post type should trigger regeneration
1460 */
1461 private function should_include_post_type(string $post_type): bool {
1462 // Match Rank Math: only publicly viewable post types belong in the
1463 // sitemap (public && publicly_queryable). Additionally skip post types
1464 // that opt out of front-end search (exclude_from_search => true) — e.g.
1465 // Templately's internal `templately_library` store — which are template
1466 // records, not standalone indexable URLs. BetterDocs `docs` and
1467 // WooCommerce `product` register exclude_from_search => false, so they
1468 // remain included.
1469 if (!is_post_type_viewable($post_type)) {
1470 return false;
1471 }
1472
1473 $post_type_obj = get_post_type_object($post_type);
1474 if (!$post_type_obj || !empty($post_type_obj->exclude_from_search)) {
1475 return false;
1476 }
1477
1478 return true;
1479 }
1480
1481 /**
1482 * Check if taxonomy should trigger regeneration
1483 *
1484 * @since 1.0.0
1485 * @param string $taxonomy Taxonomy slug
1486 * @return bool True if taxonomy should trigger regeneration
1487 */
1488 private function should_include_taxonomy(string $taxonomy): bool {
1489 // Include all public taxonomies (presets control which sitemaps are created)
1490 $taxonomy_obj = get_taxonomy($taxonomy);
1491 return $taxonomy_obj && $taxonomy_obj->public;
1492 }
1493
1494 /**
1495 * Schedule debounced sitemap regeneration
1496 *
1497 * @since 1.0.0
1498 * @return void
1499 */
1500 private function schedule_debounced_regeneration(): void {
1501 // Clear any existing scheduled regeneration
1502 wp_clear_scheduled_hook('thinkrank_regenerate_sitemap');
1503
1504 // Schedule regeneration in 30 seconds to debounce rapid changes
1505 wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap');
1506 }
1507
1508 /**
1509 * Public entry point to debounce-rebuild the sitemap after a settings change
1510 * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
1511 * file reflects the new settings instead of going stale until a content edit.
1512 *
1513 * @return void
1514 */
1515 public function schedule_regeneration(): void {
1516 // Debounce against rapid successive saves, but use the settings-specific
1517 // hook so the rebuild runs regardless of the auto_generate toggle (which
1518 // only governs content-change-triggered regeneration).
1519 wp_clear_scheduled_hook('thinkrank_regenerate_sitemap_settings');
1520 wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap_settings');
1521 }
1522
1523 /**
1524 * Rebuild the served sitemap after an explicit settings change.
1525 *
1526 * Unlike {@see auto_regenerate_sitemap()}, this is NOT gated on the
1527 * auto_generate setting: the user deliberately changed inclusion rules and
1528 * expects the served file to reflect them even if content-triggered
1529 * auto-generation is turned off. Still respects the master `enabled` flag.
1530 *
1531 * @return void
1532 */
1533 public function regenerate_sitemap_from_settings(): void {
1534 try {
1535 $settings = $this->get_settings('site');
1536 if (empty($settings['enabled'])) {
1537 // The sitemap was disabled: remove the previously generated static
1538 // files so the web server stops serving a stale sitemap that
1539 // crawlers would otherwise keep fetching.
1540 $this->delete_published_sitemaps();
1541 return;
1542 }
1543 $this->generate_and_save($settings);
1544 } catch (\Throwable $e) {
1545 // Settings-triggered regeneration failed - details in exception.
1546 }
1547 }
1548
1549 /**
1550 * Remove every static sitemap file ThinkRank publishes to the web root.
1551 *
1552 * Called when the sitemap feature is disabled, by the cleanup route, and by
1553 * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
1554 * children (incl. paginated -N pages), and /local-sitemap.xml stop being
1555 * served. Only ThinkRank's own filenames are targeted; WordPress core's
1556 * wp-sitemap.xml and any other plugin's sitemap in the web root are left
1557 * untouched.
1558 *
1559 * @since 1.31.0 Returns the filenames removed, and accepts the settings to
1560 * derive them from, so a caller that already read them (and
1561 * needs to report what went) does not have to re-read or
1562 * re-derive the name list.
1563 * @since 2.1.0 Delegates to thinkrank_webroot_delete_sitemaps(). Uninstall
1564 * needs the same removal but has no autoloader to reach this
1565 * class, so the logic moved to includes/cleanup-webroot.php
1566 * and this stays as the in-plugin entry point.
1567 *
1568 * @param array|null $settings Optional. Sitemap settings; defaults to the
1569 * saved site settings.
1570 * @return array{deleted: string[], failed: string[]} Basenames removed, and
1571 * those that existed but could not be removed.
1572 */
1573 public function delete_published_sitemaps(?array $settings = null): array {
1574 return thinkrank_webroot_delete_sitemaps($settings ?? $this->get_settings('site'));
1575 }
1576
1577 /**
1578 * Every child-sitemap filename this site could have published.
1579 *
1580 * @since 1.31.0
1581 * @since 2.1.0 Delegates to thinkrank_webroot_segment_filenames().
1582 *
1583 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
1584 * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
1585 */
1586 private function publishable_segment_filenames(array $settings): array {
1587 return thinkrank_webroot_segment_filenames($settings);
1588 }
1589
1590 /**
1591 * Auto-regenerate sitemap (called by scheduled action)
1592 *
1593 * @since 1.0.0
1594 * @return void
1595 */
1596 public function auto_regenerate_sitemap(): void {
1597 try {
1598 // Double-check that auto-generation is still enabled
1599 if (!$this->should_auto_generate()) {
1600 return;
1601 }
1602
1603 $this->generate_and_save($this->get_settings('site'));
1604
1605 // Sitemap auto-regenerated successfully
1606
1607 } catch (\Throwable $e) {
1608 // Sitemap auto-regeneration failed - error details available in exception
1609 }
1610 }
1611
1612 /**
1613 * Build and write the sitemap files for the given settings.
1614 *
1615 * Segmented files plus an index when the settings configure one, a single
1616 * file otherwise, and the standalone local business sitemap either way.
1617 * Records `last_generated` so the UI's "View Generated Sitemaps" links
1618 * unlock, mirroring the manual generate endpoint.
1619 *
1620 * @since 1.17.0
1621 * @param array $settings Sitemap settings.
1622 * @return bool True when the sitemap files were written.
1623 */
1624 public function generate_and_save(array $settings): bool {
1625 // Index mode is driven by the use_sitemap_index toggle (not merely by how
1626 // many sitemap_urls happen to be configured). When the toggle is on but
1627 // no child sitemaps are set up yet, synthesize the per-type segmented set
1628 // so we emit a real <sitemapindex> with paginated children instead of a
1629 // flat urlset misnamed sitemap_index.xml.
1630 $settings = $this->maybe_promote_to_index($settings);
1631
1632 if (!empty($settings['use_sitemap_index']) || count((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : [])) > 1) {
1633 $results = $this->generate_multiple_sitemaps($settings);
1634 $written = !empty($results['success']);
1635 } else {
1636 $xml = $this->generate_sitemap($settings);
1637 $written = $this->save_sitemap_to_file($xml, $this->get_primary_sitemap_filename($settings));
1638
1639 // Local business sitemap is a standalone file, regenerated on the
1640 // single-sitemap path too (this is the default mode).
1641 $this->regenerate_local_sitemap($settings);
1642 }
1643
1644 if ($written) {
1645 $settings['last_generated'] = gmdate('c');
1646 $this->save_settings('site', null, $settings);
1647 }
1648
1649 return $written;
1650 }
1651
1652 /**
1653 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
1654 *
1655 * - When use_sitemap_index is on but no child sitemaps are configured, build
1656 * the per-type segmented set so a real <sitemapindex> is produced (#127).
1657 * - When the toggle is off but a single flat file would exceed the per-file
1658 * URL cap, auto-promote to a paginated index instead of one oversized file
1659 * that can cross Google's 50k-URL/50MB limits (#129).
1660 *
1661 * @param array $settings Sitemap settings.
1662 * @return array Possibly-updated settings.
1663 */
1664 public function maybe_promote_to_index(array $settings): array {
1665 $saved = $this->get_settings('site');
1666
1667 // Read from the *payload*, before the merge below folds the saved values
1668 // in: "the caller named this" and "this has a value" are different
1669 // questions, and the mode resolution turns on the former.
1670 $mode_supplied = array_key_exists('use_sitemap_index', $settings);
1671 $urls_supplied = is_array($settings['sitemap_urls'] ?? null);
1672 $has_children = count($urls_supplied ? $settings['sitemap_urls'] : []) > 1;
1673
1674 // Which inclusion flags did the caller actually name? The child list is
1675 // the only thing that reads them, and inheriting a saved one skipped
1676 // that — so on an index-mode site the include_* flags were enforced
1677 // nowhere but in the browser, where SitemapGeneration.js recomputes
1678 // sitemap_urls itself. Every non-UI client, the shipped
1679 // `update-sitemap-settings` ability included, saved the flag and changed
1680 // nothing (#398). Read from the payload for the same reason as above:
1681 // after the merge every saved flag would look like one the caller named.
1682 $named_inclusions = array_intersect(
1683 array_keys(self::INCLUSION_CHILD_TYPES),
1684 array_keys($settings)
1685 );
1686 $inclusions_supplied = (bool) $named_inclusions;
1687
1688 // Inclusion flags may be absent from a partial payload (e.g. the manual
1689 // generate endpoint) — fall back to saved settings so synthesized child
1690 // sitemaps reflect the real include_posts/pages/categories choices.
1691 $inclusions = array_merge($saved, $settings);
1692
1693 // Hand the generators a *complete* settings array. Only the mode was
1694 // resolved before, so every other unnamed key reached them missing: a
1695 // bare `{}` from a REST/MCP client republished the sitemap with
1696 // enable_styling and include_images read as off, overwriting the live
1697 // files with output that had lost its XSL stylesheet, its image
1698 // namespace and its image entries. Presentation and inclusion settings
1699 // are not something a generate call opts into — they are the site's
1700 // configuration, and only a value actually present in the payload
1701 // overrides them.
1702 $settings = $inclusions;
1703
1704 // The two keys that drive mode keep their own resolution rules below,
1705 // so they must go back to "not specified" when the caller omitted them.
1706 if (!$mode_supplied) {
1707 unset($settings['use_sitemap_index']);
1708 }
1709 if (!$urls_supplied) {
1710 unset($settings['sitemap_urls']);
1711 }
1712
1713 // An absent use_sitemap_index means "not specified", which is not the
1714 // same as "single file". Reading it as the latter meant a partial payload
1715 // — `{}` from a REST/MCP client, or anything short of the full settings
1716 // object the admin bundle sends — republished one flat sitemap.xml on an
1717 // index-mode site and left sitemap_index.xml and its children stale or
1718 // missing. Inherit the saved mode instead; only a value actually present
1719 // in the payload decides the mode.
1720 if (!array_key_exists('use_sitemap_index', $settings)) {
1721 $settings['use_sitemap_index'] = $saved['use_sitemap_index'] ?? '';
1722
1723 // Inheriting the mode means inheriting its children too, unless the
1724 // caller named its own set.
1725 $saved_children = (is_array($saved['sitemap_urls'] ?? null) ? $saved['sitemap_urls'] : []);
1726 if (!empty($settings['use_sitemap_index'])
1727 && !$urls_supplied
1728 && count($saved_children) > 1) {
1729 // A named inclusion flag is applied *to* the inherited list, not
1730 // used to regenerate it. build_segmented_sitemap_urls() also adds
1731 // a child for every public custom post type, so rebuilding here
1732 // would make `{include_pages: false}` — one thing off — silently
1733 // switch on children the saved list never had (an Elementor
1734 // internal CPT, a WooCommerce product feed). Only the flags the
1735 // caller actually named change anything.
1736 $settings['sitemap_urls'] = $inclusions_supplied
1737 ? $this->apply_inclusion_flags_to_children($saved_children, $named_inclusions, $inclusions)
1738 : $saved_children;
1739
1740 // The children are resolved either way — including when the
1741 // caller switched the last one off, which leaves a bare index and
1742 // is what they asked for.
1743 $has_children = true;
1744 }
1745 }
1746
1747 if (!empty($settings['use_sitemap_index'])) {
1748 if (!$has_children) {
1749 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
1750 }
1751 return $settings;
1752 }
1753
1754 if (!$has_children) {
1755 $limit = $this->get_links_per_sitemap($settings);
1756 if ($limit > 0 && $this->estimate_total_sitemap_urls($inclusions) > $limit) {
1757 $settings['use_sitemap_index'] = true;
1758 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
1759 }
1760 }
1761
1762 return $settings;
1763 }
1764
1765 /**
1766 * Rough count of URLs a single-file sitemap would contain, used only to
1767 * decide whether to auto-promote to a paginated index. Cheap COUNT queries;
1768 * intentionally approximate (homepage + published posts of enabled types +
1769 * terms of enabled taxonomies).
1770 *
1771 * @param array $settings Sitemap settings.
1772 * @return int
1773 */
1774 private function estimate_total_sitemap_urls(array $settings): int {
1775 $total = 1; // homepage
1776
1777 foreach ($this->get_enabled_post_types($settings) as $post_type) {
1778 $counts = wp_count_posts($post_type);
1779 $total += isset($counts->publish) ? (int) $counts->publish : 0;
1780 }
1781
1782 foreach ($this->get_enabled_taxonomies($settings) as $taxonomy) {
1783 $count = wp_count_terms(['taxonomy' => $taxonomy, 'hide_empty' => true]);
1784 if (!is_wp_error($count)) {
1785 $total += (int) $count;
1786 }
1787 }
1788
1789 return $total;
1790 }
1791
1792 /**
1793 * Resolve the sitemap file the site publishes for the given settings.
1794 *
1795 * Index mode serves the index (`sitemap_index.xml`); the default single-file
1796 * mode serves `sitemap.xml`. Callers use this to link to — or check for —
1797 * the file the site actually serves, since ThinkRank's sitemap is a static
1798 * file in the web root rather than a route.
1799 *
1800 * @since 1.17.0
1801 * @param array $settings Sitemap settings.
1802 * @return string Sitemap filename.
1803 */
1804 public function get_primary_sitemap_filename(array $settings): string {
1805 return thinkrank_webroot_primary_sitemap_filename($settings);
1806 }
1807
1808 /**
1809 * Public URL of the sitemap the site serves.
1810 *
1811 * @since 1.17.0
1812 * @param array|null $settings Sitemap settings (falls back to saved site settings).
1813 * @return string Absolute sitemap URL.
1814 */
1815 public function get_primary_sitemap_url(?array $settings = null): string {
1816 $settings = $settings ?? $this->get_settings('site');
1817
1818 return home_url('/' . $this->get_primary_sitemap_filename($settings));
1819 }
1820
1821 /**
1822 * Whether the sitemap file this site publishes exists on disk.
1823 *
1824 * @since 1.17.0
1825 * @param array $settings Sitemap settings.
1826 * @return bool True when the file is present.
1827 */
1828 public function primary_sitemap_file_exists(array $settings): bool {
1829 return file_exists(ABSPATH . $this->get_primary_sitemap_filename($settings));
1830 }
1831
1832 /**
1833 * Save sitemap XML to file
1834 *
1835 * @since 1.0.0
1836 * @param string $sitemap_xml Sitemap XML content
1837 * @param string $filename Optional. Filename to save (defaults to 'sitemap.xml')
1838 * @return bool True on success, false on failure
1839 */
1840 private function save_sitemap_to_file(string $sitemap_xml, string $filename = 'sitemap.xml'): bool {
1841 // Validate and sanitize filename for security
1842 try {
1843 $filename = $this->validate_sitemap_filename($filename);
1844 } catch (InvalidArgumentException $e) {
1845 // File validation failed - error details available in exception
1846 return false;
1847 }
1848
1849 $sitemap_path = ABSPATH . $filename;
1850
1851 // Use WordPress filesystem API for better security
1852 global $wp_filesystem;
1853 if (!$wp_filesystem) {
1854 require_once ABSPATH . 'wp-admin/includes/file.php';
1855 WP_Filesystem();
1856 }
1857
1858 if ($wp_filesystem) {
1859 return $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
1860 }
1861
1862 // WP_Filesystem initialization failed
1863 return false;
1864 }
1865
1866 /**
1867 * Build a segmented, index-based sitemap_urls list from inclusion settings.
1868 *
1869 * Produces the index entry plus one child sitemap per enabled content type
1870 * (posts/pages/categories) and one per public custom post type — the shape
1871 * the "complete" preset creates and that generate_multiple_sitemaps() expects.
1872 * Used when enabling the index during import so the index has real children
1873 * instead of being empty.
1874 *
1875 * @since 1.14.0
1876 *
1877 * @param array $inclusions Inclusion flags (include_posts/pages/categories)
1878 * and optionally custom_url_pattern.
1879 * @return array Sitemap URL configs
1880 */
1881 public function build_segmented_sitemap_urls(array $inclusions): array {
1882 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
1883
1884 $urls = [$this->sitemap_child_entry('/sitemap_index.xml', 'index')];
1885
1886 foreach (self::INCLUSION_CHILD_TYPES as $flag => $type) {
1887 if (!empty($inclusions[$flag])) {
1888 $urls[] = $this->build_child_sitemap_entry($type, $pattern);
1889 }
1890 }
1891
1892 // Public custom post types each get a child sitemap (parity with the
1893 // "complete" preset and with Rank Math, which lists every public CPT).
1894 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
1895 if ($this->should_include_post_type($cpt)) {
1896 $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
1897 }
1898 }
1899
1900 return $urls;
1901 }
1902
1903 /**
1904 * Apply only the inclusion flags the caller named to an existing child list.
1905 *
1906 * The narrow counterpart to build_segmented_sitemap_urls(): that one
1907 * regenerates the whole set from scratch, which is right when there is no set
1908 * yet and wrong when there is. Rebuilding an existing list would add a child
1909 * for every public custom post type it had never contained, so a payload that
1910 * switches one thing off would switch others on. Here a flag adds or removes
1911 * exactly its own child and leaves every other entry — custom post types,
1912 * hand-added URLs, per-child enabled/status state — untouched (#398).
1913 *
1914 * @since 1.31.0
1915 *
1916 * @param array $children Existing child sitemap entries.
1917 * @param string[] $named_inclusions Inclusion flag keys present in the payload.
1918 * @param array $inclusions Merged settings, for the resolved flag
1919 * values and custom_url_pattern.
1920 * @return array Updated child sitemap entries.
1921 */
1922 private function apply_inclusion_flags_to_children(
1923 array $children,
1924 array $named_inclusions,
1925 array $inclusions
1926 ): array {
1927 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
1928
1929 foreach ($named_inclusions as $flag) {
1930 $type = self::INCLUSION_CHILD_TYPES[$flag];
1931
1932 $present = false;
1933 foreach ($children as $entry) {
1934 if (($entry['type'] ?? '') === $type) {
1935 $present = true;
1936 break;
1937 }
1938 }
1939
1940 if (empty($inclusions[$flag])) {
1941 if ($present) {
1942 $children = array_values(array_filter(
1943 $children,
1944 static function ($entry) use ($type): bool {
1945 return (is_array($entry) ? ($entry['type'] ?? '') : '') !== $type;
1946 }
1947 ));
1948 }
1949 continue;
1950 }
1951
1952 if (!$present) {
1953 $children[] = $this->build_child_sitemap_entry($type, $pattern);
1954 }
1955 }
1956
1957 return $children;
1958 }
1959
1960 /**
1961 * Build one child sitemap entry, resolving its filename from the url pattern.
1962 *
1963 * @since 1.31.0
1964 *
1965 * @param string $type Child sitemap type (posts, pages, a post type name).
1966 * @param string $pattern Filename pattern containing {type}.
1967 * @return array Sitemap URL config.
1968 */
1969 private function build_child_sitemap_entry(string $type, string $pattern): array {
1970 $file = str_replace('{type}', $type, $pattern);
1971 if (strpos($file, '/') !== 0) {
1972 $file = '/' . $file;
1973 }
1974
1975 return $this->sitemap_child_entry($file, $type);
1976 }
1977
1978 /**
1979 * The shape generate_multiple_sitemaps() expects of a sitemap_urls entry.
1980 *
1981 * @since 1.31.0
1982 *
1983 * @param string $url Sitemap path.
1984 * @param string $type Entry type.
1985 * @return array Sitemap URL config.
1986 */
1987 private function sitemap_child_entry(string $url, string $type): array {
1988 return [
1989 'url' => $url,
1990 'type' => $type,
1991 'enabled' => true,
1992 'last_checked' => null,
1993 'status' => 'unknown',
1994 ];
1995 }
1996
1997 /**
1998 * Generate multiple sitemaps based on settings
1999 *
2000 * @since 1.0.0
2001 * @param array $settings Sitemap settings
2002 * @return array Results of sitemap generation
2003 */
2004 public function generate_multiple_sitemaps(array $settings): array {
2005 $results = [
2006 'success' => true,
2007 'sitemaps_generated' => [],
2008 'errors' => [],
2009 'total_urls' => 0
2010 ];
2011
2012 $sitemap_urls = (is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []);
2013
2014 if (empty($sitemap_urls)) {
2015 $results['success'] = false;
2016 $results['errors'][] = 'No sitemap URLs configured';
2017 return $results;
2018 }
2019
2020 $limit = $this->get_links_per_sitemap($settings);
2021
2022 // Defer the index until every child sitemap has been generated, so it can
2023 // list the actual files produced (including pagination pages).
2024 $index_config = null;
2025 $index_children = [];
2026
2027 foreach ($sitemap_urls as $sitemap_config) {
2028 if (empty($sitemap_config['enabled'])) {
2029 continue;
2030 }
2031
2032 $type = $sitemap_config['type'];
2033
2034 if ($type === 'index') {
2035 $index_config = $sitemap_config;
2036 continue;
2037 }
2038
2039 // Skip CPT children that no longer qualify (e.g. an internal store
2040 // like Templately's `templately_library`) even when a previously
2041 // saved config still lists them. Built-in aggregate types ('posts',
2042 // 'pages', 'general', etc.) are not post type names, so this only
2043 // affects real custom post types.
2044 if (post_type_exists($type) && !$this->should_include_post_type($type)) {
2045 continue;
2046 }
2047
2048 try {
2049 // The single "general"/"WordPress" sitemap is one un-paginated file
2050 // (there is no index to reference extra pages); it no longer drops
2051 // overflow URLs.
2052 if ($type === 'general' || $type === 'wordpress') { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- lowercase on purpose: this is the stored type slug.
2053 $xml = $this->generate_sitemap($settings);
2054 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
2055 continue;
2056 }
2057
2058 $source = $this->stream_type_entries($type, $settings);
2059
2060 // Unknown type — fall back to a single general sitemap file.
2061 if ($source === null) {
2062 $xml = $this->generate_sitemap($settings);
2063 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
2064 continue;
2065 }
2066
2067 // Stream the entries into files of at most $limit URLs, matching
2068 // Rank Math: page 1 keeps the base filename, pages 2+ get a -N
2069 // suffix, and every page is listed in the index. Buffering only one
2070 // page at a time keeps peak memory bounded to $limit entries rather
2071 // than every URL of the type.
2072 $image_ns = $source['image_ns'];
2073 $buffer = [];
2074 $page = 0;
2075
2076 foreach ($source['entries'] as $entry) {
2077 $buffer[] = $entry;
2078 if (count($buffer) >= $limit) {
2079 $page++;
2080 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
2081 $buffer = [];
2082 }
2083 }
2084
2085 // Flush the trailing partial page, or a single empty page when the
2086 // type had no entries at all (parity with the previous behavior of
2087 // always writing at least one page per configured child).
2088 if (!empty($buffer) || $page === 0) {
2089 $page++;
2090 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
2091 }
2092
2093 // Remove pages left over from a previous, larger generation.
2094 $this->cleanup_stale_pages($sitemap_config['url'], $page);
2095
2096 } catch (\Exception $e) {
2097 $results['errors'][] = "Error generating {$type} sitemap: " . $e->getMessage();
2098 $results['success'] = false;
2099 }
2100 }
2101
2102 // Local business sitemap (parity with Rank Math's local-sitemap.xml).
2103 // The physical file is written whenever a business identity is
2104 // configured — independent of segmented vs single mode — and is also
2105 // listed in the sitemap index when one exists.
2106 try {
2107 if ($this->regenerate_local_sitemap($settings) && $index_config !== null) {
2108 $index_children[] = ['url' => '/local-sitemap.xml'];
2109 }
2110 } catch (\Exception $e) {
2111 $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
2112 $results['success'] = false;
2113 }
2114
2115 // Build the index last, from the child files actually generated.
2116 if ($index_config !== null) {
2117 try {
2118 $index_xml = $this->generate_sitemap_index($index_children, $settings);
2119 $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
2120
2121 if ($this->save_sitemap_to_file($index_xml, $filename)) {
2122 $results['sitemaps_generated'][] = [
2123 'url' => $index_config['url'],
2124 'type' => 'index',
2125 'filename' => $filename,
2126 'url_count' => count($index_children),
2127 ];
2128 } else {
2129 $results['errors'][] = "Failed to save sitemap: {$filename}";
2130 $results['success'] = false;
2131 }
2132 } catch (\Exception $e) {
2133 $results['errors'][] = 'Error generating index sitemap: ' . $e->getMessage();
2134 $results['success'] = false;
2135 }
2136 }
2137
2138 // Remove segments that are no longer part of the set. Publishing was
2139 // purely additive: a type that dropped out (Categories unticked, a CPT
2140 // that stopped qualifying) simply stopped being overwritten, so its file
2141 // kept serving and — until the index happened to be rebuilt — kept being
2142 // listed in it. The index above is built from the children actually
2143 // generated, so pruning here leaves disk and index agreeing.
2144 $this->prune_orphaned_segments($settings, $results['sitemaps_generated']);
2145
2146 return $results;
2147 }
2148
2149 /**
2150 * Delete published segment files that this run did not write.
2151 *
2152 * Only filenames this site could have published under its own url pattern
2153 * are considered, so another plugin's or core's sitemap in the web root is
2154 * never a candidate — the same reason cleanup does not glob 'sitemap-*.xml'.
2155 *
2156 * @since 1.31.0
2157 *
2158 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
2159 * @param array $generated Entries from $results['sitemaps_generated'].
2160 * @return string[] Basenames removed.
2161 */
2162 private function prune_orphaned_segments(array $settings, array $generated): array {
2163 $kept = [];
2164 foreach ($generated as $entry) {
2165 if (!empty($entry['filename'])) {
2166 $kept[strtolower((string) $entry['filename'])] = true;
2167 }
2168 }
2169
2170 // The index and the local business sitemap are written by their own
2171 // paths and are not segments, so they are never orphans here.
2172 $kept[strtolower(basename($this->get_primary_sitemap_filename($settings)))] = true;
2173 $kept['local-sitemap.xml'] = true;
2174 $kept['sitemap.xml'] = true;
2175 $kept['sitemap_index.xml'] = true;
2176
2177 $removed = [];
2178
2179 global $wp_filesystem;
2180 if (!$wp_filesystem) {
2181 require_once ABSPATH . 'wp-admin/includes/file.php';
2182 WP_Filesystem();
2183 }
2184 if (!$wp_filesystem) {
2185 return $removed;
2186 }
2187
2188 foreach ($this->publishable_segment_filenames($settings) as $candidate) {
2189 if (isset($kept[strtolower($candidate)])) {
2190 continue;
2191 }
2192
2193 if (!preg_match('/^(.*)\.xml$/i', $candidate, $m)) {
2194 continue;
2195 }
2196
2197 // The base file plus its numeric pagination pages.
2198 $paths = [ABSPATH . $candidate];
2199 foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
2200 if (preg_match('/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i', basename($paged))) {
2201 $paths[] = $paged;
2202 }
2203 }
2204
2205 foreach ($paths as $path) {
2206 if (!file_exists($path)) {
2207 continue;
2208 }
2209 if ($wp_filesystem->delete($path)) {
2210 $removed[] = basename($path);
2211 }
2212 }
2213 }
2214
2215 return $removed;
2216 }
2217
2218
2219 /**
2220 * Collect the full (un-paginated) entry list for a content sitemap type.
2221 *
2222 * @since 1.14.0
2223 *
2224 * @param string $type Sitemap config type (posts, pages, categories, …)
2225 * @param array $settings Sitemap settings
2226 * @return array{entries: array<string>, image_ns: bool}|null Null for an
2227 * unknown type (caller falls back to a general sitemap).
2228 */
2229 /**
2230 * Write (or remove) the physical local-sitemap.xml file.
2231 *
2232 * Called from every sitemap regeneration path — segmented generation, the
2233 * single-sitemap auto-regeneration, and the manual generate endpoint — so
2234 * the local sitemap works regardless of whether the site uses a sitemap
2235 * index. When no business identity is configured, any stale file is removed.
2236 *
2237 * @since 1.15.x
2238 * @param array|null $settings Sitemap settings (falls back to saved site settings).
2239 * @return bool True when the file was written, false when nothing was written.
2240 */
2241 public function regenerate_local_sitemap(?array $settings = null): bool {
2242 $settings = $settings ?? $this->get_settings('site');
2243 $entries = $this->collect_local_entries();
2244
2245 if (empty($entries)) {
2246 // Business identity was cleared — drop any file left from before.
2247 $path = ABSPATH . 'local-sitemap.xml';
2248 if (file_exists($path)) {
2249 wp_delete_file($path);
2250 }
2251 return false;
2252 }
2253
2254 $xml = $this->wrap_urlset($entries, $settings, false);
2255 return $this->save_sitemap_to_file($xml, 'local-sitemap.xml');
2256 }
2257
2258 /**
2259 * Build the local business sitemap entries.
2260 *
2261 * Returns a single URL entry pointing at the on-site page that carries the
2262 * business's LocalBusiness/Organization schema (the configured business URL
2263 * when it's on this site, otherwise the homepage). Gated — like Rank Math's
2264 * `local-sitemap.xml` — on a business (non-person) type being configured
2265 * with an actual location (address or geo coordinates). Returns an empty
2266 * array when no business location is set, so no empty local sitemap is
2267 * written or added to the index.
2268 *
2269 * @since 1.15.x
2270 * @return array Zero or one URL entry
2271 */
2272 private function collect_local_entries(): array {
2273 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2274 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2275 }
2276
2277 $identity = (new \ThinkRank\SEO\Site_Identity_Manager())->get_settings('site');
2278
2279 // Gate like Rank Math's local sitemap: a business (non-person) identity
2280 // is configured. We only emit a single URL (the location page), so a
2281 // full postal address is NOT required — requiring one wrongly excluded
2282 // migrated sites, since the Rank Math importer carries over business
2283 // type/name/phone but not the address. Personal sites, and sites with no
2284 // business identity at all, get no local sitemap.
2285 $business_type = strtolower((string) ($identity['business_type'] ?? ''));
2286 if ($business_type === 'person') {
2287 return [];
2288 }
2289 if ($business_type === '' && empty($identity['business_name'])) {
2290 return [];
2291 }
2292
2293 // Prefer the configured business URL when it points at this site;
2294 // otherwise fall back to the homepage (which outputs the schema).
2295 $location_url = home_url('/');
2296 if (!empty($identity['business_url'])) {
2297 $candidate = esc_url_raw((string) $identity['business_url']);
2298 if ($candidate && wp_parse_url($candidate, PHP_URL_HOST) === wp_parse_url(home_url(), PHP_URL_HOST)) {
2299 $location_url = $candidate;
2300 }
2301 }
2302
2303 // Omit lastmod — no real modification source for the local business URL.
2304 return [$this->generate_url_entry($location_url, '', 0.8, 'weekly')];
2305 }
2306
2307 /**
2308 * Resolve a streaming entry source for a sitemap type.
2309 *
2310 * Returns an iterable that yields the type's <url> entries one at a time
2311 * (so the paginator never materializes the whole type), the image-namespace
2312 * flag, or null when the type isn't one we build (caller falls back to a
2313 * single general sitemap). Entry order matches the previous array-based
2314 * collect_type_entries() exactly.
2315 *
2316 * @param string $type Sitemap type.
2317 * @param array $settings Sitemap settings.
2318 * @return array{entries: iterable<string>, image_ns: bool}|null
2319 */
2320 private function stream_type_entries(string $type, array $settings): ?array {
2321 switch ($type) {
2322 case 'posts':
2323 return ['entries' => $this->collect_post_entries_iter(['post'], $settings), 'image_ns' => true];
2324
2325 case 'pages':
2326 // The homepage lives in the pages sitemap (parity with prior
2327 // output) and is emitted first; collect_post_entries_iter()
2328 // already excludes the static front page, so there's no duplicate.
2329 $entries = (function () use ($settings) {
2330 yield $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
2331 yield from $this->collect_post_entries_iter(['page'], $settings);
2332 })();
2333 return ['entries' => $entries, 'image_ns' => true];
2334
2335 case 'categories':
2336 return ['entries' => $this->collect_taxonomy_entries_iter('category', $settings), 'image_ns' => false];
2337
2338 case 'tags':
2339 return ['entries' => $this->collect_taxonomy_entries_iter('post_tag', $settings), 'image_ns' => false];
2340
2341 case 'products':
2342 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
2343 return ['entries' => $entries, 'image_ns' => true];
2344
2345 case 'product_categories':
2346 $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
2347 return ['entries' => $entries, 'image_ns' => false];
2348
2349 default:
2350 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2351 if (in_array($type, $custom_post_types, true)) {
2352 return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
2353 }
2354 return null;
2355 }
2356 }
2357
2358 /**
2359 * Write one sitemap page file and record it in the results + index child list.
2360 *
2361 * @since 1.14.0
2362 *
2363 * @param string $url Sitemap page URL
2364 * @param string $xml Sitemap XML
2365 * @param string $type Sitemap type (for reporting)
2366 * @param int $url_count Number of URLs in this page
2367 * @param array $results Results accumulator (by reference)
2368 * @param array $index_children Index child list (by reference)
2369 * @return void
2370 */
2371 private function write_sitemap_page(string $url, string $xml, string $type, int $url_count, array &$results, array &$index_children): void {
2372 $filename = basename(wp_parse_url($url, PHP_URL_PATH));
2373
2374 if ($this->save_sitemap_to_file($xml, $filename)) {
2375 $results['sitemaps_generated'][] = [
2376 'url' => $url,
2377 'type' => $type,
2378 'filename' => $filename,
2379 'url_count' => $url_count,
2380 ];
2381 $results['total_urls'] += $url_count;
2382 $index_children[] = ['url' => $url];
2383 } else {
2384 $results['errors'][] = "Failed to save sitemap: {$filename}";
2385 $results['success'] = false;
2386 }
2387 }
2388
2389 /**
2390 * Delete pagination files left over when a type shrinks to fewer pages.
2391 *
2392 * For a base URL like /sitemap-posts.xml, removes sitemap-posts-N.xml files
2393 * whose page number N exceeds the current page count. Page 1 (the base file,
2394 * which has no -N suffix) is never touched.
2395 *
2396 * @since 1.14.0
2397 *
2398 * @param string $base_url Base (page 1) sitemap URL
2399 * @param int $current_pages Number of pages generated this run
2400 * @return void
2401 */
2402 private function cleanup_stale_pages(string $base_url, int $current_pages): void {
2403 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2404 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2405 return;
2406 }
2407 $stem = $m[1];
2408
2409 global $wp_filesystem;
2410 if (!$wp_filesystem) {
2411 require_once ABSPATH . 'wp-admin/includes/file.php';
2412 WP_Filesystem();
2413 }
2414 if (!$wp_filesystem) {
2415 return;
2416 }
2417
2418 $candidates = glob(ABSPATH . $stem . '-*.xml') ?: [];
2419 foreach ($candidates as $path) {
2420 // Only delete numeric-suffixed pages beyond the current count.
2421 if (preg_match('/-(\d+)\.xml$/', basename($path), $mm) && (int) $mm[1] > $current_pages) {
2422 $wp_filesystem->delete($path);
2423 }
2424 }
2425 }
2426
2427 /**
2428 * Generate sitemap index XML from the list of child sitemap files produced
2429 * during generation (each already resolved to its final, possibly paginated,
2430 * URL).
2431 *
2432 * @since 1.0.0 (signature updated 1.14.0)
2433 * @param array $children Array of ['url' => string] child sitemap entries
2434 * @param array $settings Sitemap settings
2435 * @return string Sitemap index XML
2436 */
2437 private function generate_sitemap_index(array $children, array $settings): string {
2438 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
2439
2440 // Add XSL stylesheet only if styling is enabled
2441 if (!empty($settings['enable_styling'])) {
2442 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap-index.xsl') . '"?>' . "\n";
2443 }
2444 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
2445
2446 $site_url = home_url();
2447
2448 foreach ($children as $child) {
2449 $sitemap_url = $child['url'] ?? '';
2450 if ($sitemap_url === '') {
2451 continue;
2452 }
2453 if (!str_starts_with($sitemap_url, 'http')) {
2454 $sitemap_url = $site_url . $sitemap_url;
2455 }
2456
2457 $xml .= " <sitemap>\n";
2458 $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
2459 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
2460 $xml .= " </sitemap>\n";
2461 }
2462
2463 $xml .= '</sitemapindex>';
2464
2465 return $xml;
2466 }
2467
2468 /**
2469 * Count URLs in sitemap XML content
2470 *
2471 * @since 1.0.0
2472 * @param string $sitemap_xml Sitemap XML content
2473 * @return int Number of URLs
2474 */
2475 private function count_urls_in_xml(string $sitemap_xml): int {
2476 return substr_count($sitemap_xml, '<url>');
2477 }
2478
2479 /**
2480 * Validate and sanitize exclude posts input
2481 *
2482 * @since 1.0.0
2483 * @param string $exclude_posts Comma-separated post IDs
2484 * @return array Validated post IDs
2485 * @throws InvalidArgumentException If validation fails
2486 */
2487 private function validate_exclude_posts(string $exclude_posts): array {
2488 if (empty($exclude_posts)) {
2489 return [];
2490 }
2491
2492 // Limit input length to prevent DoS
2493 if (strlen($exclude_posts) > 1000) {
2494 throw new InvalidArgumentException('Exclude posts list too long (max 1000 characters)');
2495 }
2496
2497 // Validate comma-separated integers only
2498 if (!preg_match('/^[\d,\s]+$/', $exclude_posts)) {
2499 throw new InvalidArgumentException('Exclude posts must contain only numbers and commas');
2500 }
2501
2502 $ids = array_map('intval', array_filter(explode(',', $exclude_posts)));
2503
2504 // Limit number of exclusions to prevent performance issues
2505 if (count($ids) > 100) {
2506 throw new InvalidArgumentException('Too many posts to exclude (max 100)');
2507 }
2508
2509 return array_filter($ids, function($id) {
2510 return $id > 0; // Only positive integers
2511 });
2512 }
2513
2514 /**
2515 * Validate and sanitize exclude terms input
2516 *
2517 * @since 1.0.0
2518 * @param string $exclude_terms Comma-separated term IDs
2519 * @return array Validated term IDs
2520 * @throws InvalidArgumentException If validation fails
2521 */
2522 private function validate_exclude_terms(string $exclude_terms): array {
2523 if (empty($exclude_terms)) {
2524 return [];
2525 }
2526
2527 // Limit input length to prevent DoS
2528 if (strlen($exclude_terms) > 1000) {
2529 throw new InvalidArgumentException('Exclude terms list too long (max 1000 characters)');
2530 }
2531
2532 // Validate comma-separated integers only
2533 if (!preg_match('/^[\d,\s]+$/', $exclude_terms)) {
2534 throw new InvalidArgumentException('Exclude terms must contain only numbers and commas');
2535 }
2536
2537 $ids = array_map('intval', array_filter(explode(',', $exclude_terms)));
2538
2539 // Limit number of exclusions to prevent performance issues
2540 if (count($ids) > 100) {
2541 throw new InvalidArgumentException('Too many terms to exclude (max 100)');
2542 }
2543
2544 return array_filter($ids, function($id) {
2545 return $id > 0; // Only positive integers
2546 });
2547 }
2548
2549 /**
2550 * Validate and sanitize custom URL pattern
2551 *
2552 * @since 1.0.0
2553 * @param string $pattern Custom URL pattern
2554 * @return string Validated and sanitized pattern
2555 * @throws InvalidArgumentException If validation fails
2556 */
2557 private function validate_custom_url_pattern(string $pattern): string {
2558 if (empty($pattern)) {
2559 return 'sitemap-{type}.xml';
2560 }
2561
2562 // Remove any HTML/script tags to prevent XSS
2563 $pattern = wp_strip_all_tags($pattern);
2564
2565 // Limit pattern length
2566 if (strlen($pattern) > 100) {
2567 throw new InvalidArgumentException('URL pattern too long (max 100 characters)');
2568 }
2569
2570 // Validate pattern format - only allow safe characters
2571 if (!preg_match('/^[a-zA-Z0-9\-_{}\.]+$/', $pattern)) {
2572 throw new InvalidArgumentException('URL pattern contains invalid characters. Only letters, numbers, hyphens, underscores, dots, and {type} are allowed');
2573 }
2574
2575 // Ensure it contains {type} placeholder
2576 if (strpos($pattern, '{type}') === false) {
2577 throw new InvalidArgumentException('URL pattern must contain {type} placeholder');
2578 }
2579
2580 // Ensure it ends with .xml
2581 if (!str_ends_with($pattern, '.xml')) {
2582 $pattern .= '.xml';
2583 }
2584
2585 return sanitize_file_name($pattern);
2586 }
2587
2588 /**
2589 * Validate sitemap URL
2590 *
2591 * @since 1.0.0
2592 * @param string $url Sitemap URL
2593 * @return string Validated and sanitized URL
2594 * @throws InvalidArgumentException If validation fails
2595 */
2596 private function validate_sitemap_url(string $url): string {
2597 if (empty($url)) {
2598 throw new InvalidArgumentException('Sitemap URL cannot be empty');
2599 }
2600
2601 // Remove leading/trailing whitespace
2602 $url = trim($url);
2603
2604 // Limit URL length
2605 if (strlen($url) > 200) {
2606 throw new InvalidArgumentException('Sitemap URL too long (max 200 characters)');
2607 }
2608
2609 // Ensure it starts with /
2610 if (!str_starts_with($url, '/')) {
2611 $url = '/' . $url;
2612 }
2613
2614 // Validate URL path format
2615 if (!preg_match('/^\/[a-zA-Z0-9\-_\/\.]+\.xml$/', $url)) {
2616 throw new InvalidArgumentException('Invalid sitemap URL format. Must be a valid path ending with .xml');
2617 }
2618
2619 // Prevent directory traversal
2620 if (strpos($url, '..') !== false) {
2621 throw new InvalidArgumentException('Directory traversal not allowed in sitemap URL');
2622 }
2623
2624 // Prevent multiple slashes
2625 $url = preg_replace('/\/+/', '/', $url);
2626
2627 return sanitize_url($url);
2628 }
2629
2630 /**
2631 * Validate links per sitemap setting
2632 *
2633 * @since 1.0.0
2634 * @param mixed $links_per_sitemap Links per sitemap value
2635 * @return int Validated links per sitemap
2636 * @throws InvalidArgumentException If validation fails
2637 */
2638 private function validate_links_per_sitemap($links_per_sitemap): int {
2639 $links = intval($links_per_sitemap);
2640
2641 if ($links < 1) {
2642 throw new InvalidArgumentException('Links per sitemap must be at least 1');
2643 }
2644
2645 if ($links > 50000) {
2646 throw new InvalidArgumentException('Links per sitemap cannot exceed 50,000');
2647 }
2648
2649 return $links;
2650 }
2651
2652 /**
2653 * Validate sitemap filename for security
2654 *
2655 * @since 1.0.0
2656 * @param string $filename Filename to validate
2657 * @return string Validated and sanitized filename
2658 * @throws InvalidArgumentException If validation fails
2659 */
2660 private function validate_sitemap_filename(string $filename): string {
2661 if (empty($filename)) {
2662 return 'sitemap.xml';
2663 }
2664
2665 // Remove any path components to prevent directory traversal
2666 $filename = basename($filename);
2667
2668 // Limit filename length
2669 if (strlen($filename) > 100) {
2670 throw new InvalidArgumentException('Filename too long (max 100 characters)');
2671 }
2672
2673 // Validate filename format - only allow safe characters
2674 if (!preg_match('/^[a-zA-Z0-9\-_\.]+$/', $filename)) {
2675 throw new InvalidArgumentException('Filename contains invalid characters. Only letters, numbers, hyphens, underscores, and dots are allowed');
2676 }
2677
2678 // Prevent directory traversal attempts
2679 if (strpos($filename, '..') !== false) {
2680 throw new InvalidArgumentException('Directory traversal not allowed in filename');
2681 }
2682
2683 // Ensure it ends with .xml
2684 if (!str_ends_with($filename, '.xml')) {
2685 $filename .= '.xml';
2686 }
2687
2688 // Additional sanitization
2689 $filename = sanitize_file_name($filename);
2690
2691 // Final security check - ensure it's still a valid XML filename
2692 if (!preg_match('/^[a-zA-Z0-9\-_]+\.xml$/', $filename)) {
2693 throw new InvalidArgumentException('Invalid XML filename after sanitization');
2694 }
2695
2696 return $filename;
2697 }
2698 }
2699