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

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