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

2,807 lines 103.0 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 /**
26 * Sitemap Generator Class
27 *
28 * Generates and manages XML sitemaps for search engine optimization.
29 * Provides dynamic sitemap generation with content filtering, priority
30 * calculation, and change frequency optimization.
31 *
32 * @since 1.0.0
33 */
34 class Sitemap_Generator extends Abstract_SEO_Manager {
35
36 /**
37 * Memoised WooCommerce page IDs kept out of the sitemap. Null until resolved.
38 *
39 * @since 2.0.1
40 * @var int[]|null
41 */
42 private ?array $woocommerce_excluded_page_ids = null;
43
44 /**
45 * Inclusion flag -> the child sitemap it controls.
46 *
47 * Shared by the child-list builder and by the "did the caller change what
48 * the sitemap includes?" check in maybe_promote_to_index().
49 *
50 * @since 1.31.0
51 * @var array<string, string>
52 */
53 private const INCLUSION_CHILD_TYPES = [
54 'include_posts' => 'posts',
55 'include_pages' => 'pages',
56 'include_categories' => 'categories',
57 'include_tags' => 'tags',
58 ];
59
60 /**
61 * Supported sitemap types
62 *
63 * @since 1.0.0
64 * @var array
65 */
66 /**
67 * How many post IDs to hydrate at a time while walking the sitemap set.
68 *
69 * @since 2.0.1
70 * @var int
71 */
72 private const ID_WALK_CHUNK = 500;
73
74 /**
75 * How many term IDs to hydrate at a time while walking a taxonomy.
76 *
77 * @since 2.0.1
78 * @var int
79 */
80 private const TERM_WALK_CHUNK = 1000;
81
82 private array $sitemap_types = [
83 'posts' => [
84 'name' => 'Posts',
85 'post_types' => ['post'],
86 'priority' => 0.8,
87 'changefreq' => 'weekly'
88 ],
89 'pages' => [
90 'name' => 'Pages',
91 'post_types' => ['page'],
92 'priority' => 0.9,
93 'changefreq' => 'monthly'
94 ],
95 'categories' => [
96 'name' => 'Categories',
97 'taxonomy' => 'category',
98 'priority' => 0.6,
99 'changefreq' => 'weekly'
100 ],
101 'tags' => [
102 'name' => 'Tags',
103 'taxonomy' => 'post_tag',
104 'priority' => 0.4,
105 'changefreq' => 'monthly'
106 ]
107 ];
108
109 /**
110 * Constructor
111 *
112 * @since 1.0.0
113 *
114 * @param bool $register_hooks Optional. Whether to register the auto-generation
115 * hooks. Pass false for a read-only instance built
116 * solely to query settings — the hooks are bound to
117 * `$this`, so a second hook-registering instance
118 * would run `handle_content_change()` twice per save.
119 */
120 public function __construct(bool $register_hooks = true) {
121 parent::__construct('sitemap');
122
123 // Initialize auto-generation hooks
124 if ($register_hooks) {
125 $this->init_auto_generation_hooks();
126 }
127 }
128
129 /**
130 * Filter the args of a sitemap post query.
131 *
132 * Exists so integrations can widen what the sitemap sees — the multilingual
133 * manager uses it to include every language, since these queries otherwise
134 * run in whichever language happened to be active at generation time.
135 *
136 * @since 1.23.0
137 *
138 * @param array $args get_posts() arguments.
139 * @return array Filtered arguments.
140 */
141 private function filter_query_args(array $args): array {
142 /**
143 * Filter the arguments of a sitemap post query.
144 *
145 * @since 1.23.0
146 *
147 * @param array $args get_posts() arguments.
148 */
149 return (array) apply_filters('thinkrank_sitemap_query_args', $args);
150 }
151
152 /**
153 * Filter the args of a sitemap term query.
154 *
155 * @since 1.23.0
156 *
157 * @param array $args get_terms() arguments.
158 * @return array Filtered arguments.
159 */
160 private function filter_term_query_args(array $args): array {
161 /**
162 * Filter the arguments of a sitemap term query.
163 *
164 * @since 1.23.0
165 *
166 * @param array $args get_terms() arguments.
167 */
168 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
169 }
170
171 /**
172 * Initialize WordPress hooks for auto-generation
173 *
174 * @since 1.0.0
175 * @return void
176 */
177 private function init_auto_generation_hooks(): void {
178 // Content change hooks - use priority 20 to run after other plugins
179 add_action('save_post', [$this, 'handle_content_change'], 20, 2);
180 add_action('delete_post', [$this, 'handle_content_deletion'], 20);
181 add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
182 add_action('untrash_post', [$this, 'handle_content_change_by_id'], 20);
183
184 // Taxonomy change hooks
185 add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
186 add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
187 add_action('delete_term', [$this, 'handle_taxonomy_change'], 20, 3);
188
189 // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
190 // and thinkrank_regenerate_sitemap_settings) are registered at plugin
191 // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
192 // rather than here. A cron run never builds this class via the REST
193 // endpoint (no rest_api_init), so registering them in the constructor
194 // would leave the scheduled events with no listener at cron time.
195 }
196
197 /**
198 * Generate XML sitemap
199 *
200 * @since 1.0.0
201 *
202 * @param array $options Sitemap generation options
203 * @return string XML sitemap content
204 */
205 public function generate_sitemap(array $options = []): string {
206 $settings = $this->get_settings('site');
207
208 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
209
210 // Add XSL stylesheet only if styling is enabled
211 if (!empty($settings['enable_styling'])) {
212 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
213 }
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 * Wrap a set of <url> entry strings in a complete <urlset> document.
751 *
752 * @since 1.14.0
753 *
754 * @param array<string> $entries Entry strings
755 * @param array $settings Sitemap settings
756 * @param bool $with_image_ns Include the image sitemap namespace
757 * @return string Full sitemap XML
758 */
759 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
760 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
761
762 if (!empty($settings['enable_styling'])) {
763 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap.xsl') . '"?>' . "\n";
764 }
765
766 if ($with_image_ns && !empty($settings['include_images'])) {
767 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
768 } else {
769 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
770 }
771
772 $xml .= implode('', $entries);
773 $xml .= '</urlset>';
774
775 return $xml;
776 }
777
778 /**
779 * Derive the filename/URL for a given pagination page.
780 *
781 * Page 1 keeps the base URL (e.g. /sitemap-posts.xml); pages 2+ insert the
782 * page number before the extension (e.g. /sitemap-posts-2.xml).
783 *
784 * @since 1.14.0
785 *
786 * @param string $url Base sitemap URL
787 * @param int $page 1-based page number
788 * @return string Paginated URL
789 */
790 private function paginate_url(string $url, int $page): string {
791 if ($page <= 1) {
792 return $url;
793 }
794
795 return (string) preg_replace('/\.xml$/i', '-' . $page . '.xml', $url);
796 }
797
798
799
800 /**
801 * Extract images from post for sitemap
802 *
803 * @since 1.0.0
804 *
805 * @param \WP_Post $post Post object
806 * @param array $settings Sitemap settings
807 * @return array Array of image data
808 */
809 private function extract_post_images(\WP_Post $post, array $settings): array {
810 $images = [];
811
812 // Skip if images are disabled
813 if (empty($settings['include_images'])) {
814 return $images;
815 }
816
817 // Get featured image if enabled
818 if (!empty($settings['include_featured_images'])) {
819 $featured_image = $this->get_featured_image($post->ID);
820 if ($featured_image) {
821 $images[] = $featured_image;
822 }
823 }
824
825 // Extract images from content
826 $content_images = $this->extract_content_images($post->post_content);
827 $images = array_merge($images, $content_images);
828
829 // Remove duplicates based on URL
830 $unique_images = [];
831 $seen_urls = [];
832 foreach ($images as $image) {
833 if (!in_array($image['url'], $seen_urls, true)) {
834 $unique_images[] = $image;
835 $seen_urls[] = $image['url'];
836 }
837 }
838
839 return $unique_images;
840 }
841
842 /**
843 * Get featured image data
844 *
845 * @since 1.0.0
846 *
847 * @param int $post_id Post ID
848 * @return array|null Featured image data or null
849 */
850 private function get_featured_image(int $post_id): ?array {
851 $thumbnail_id = get_post_thumbnail_id($post_id);
852 if (!$thumbnail_id) {
853 return null;
854 }
855
856 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
857 if (!$image_url) {
858 return null;
859 }
860
861 $image_title = get_the_title($thumbnail_id);
862 $image_alt = get_post_meta($thumbnail_id, '_wp_attachment_image_alt', true);
863
864 return [
865 'url' => $image_url,
866 'title' => $image_title ?: '',
867 'alt' => $image_alt ?: ''
868 ];
869 }
870
871 /**
872 * Extract images from post content
873 *
874 * @since 1.0.0
875 *
876 * @param string $content Post content
877 * @return array Array of image data
878 */
879 private function extract_content_images(string $content): array {
880 $images = [];
881
882 // Find all img tags in content
883 preg_match_all('/<img[^>]+>/i', $content, $img_tags);
884
885 foreach ($img_tags[0] as $img_tag) {
886 // Extract src attribute
887 if (preg_match('/src=["\']([^"\']+)["\']/', $img_tag, $src_match)) {
888 $image_url = $src_match[1];
889
890 // Skip if not a valid URL or external image
891 if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
892 continue;
893 }
894
895 // Extract title and alt attributes
896 $title = '';
897 $alt = '';
898
899 if (preg_match('/title=["\']([^"\']*)["\']/', $img_tag, $title_match)) {
900 $title = $title_match[1];
901 }
902
903 if (preg_match('/alt=["\']([^"\']*)["\']/', $img_tag, $alt_match)) {
904 $alt = $alt_match[1];
905 }
906
907 $images[] = [
908 'url' => $image_url,
909 'title' => $title,
910 'alt' => $alt
911 ];
912 }
913 }
914
915 return $images;
916 }
917
918 /**
919 * Get enabled taxonomies based on settings
920 *
921 * @since 1.0.0
922 * @param array $settings Sitemap settings
923 * @return array Array of enabled taxonomies
924 */
925 private function get_enabled_taxonomies(array $settings): array {
926 $taxonomies = [];
927
928 // Core taxonomies based on settings
929 if (!empty($settings['include_categories'])) { // �
930 Evidence-based field name
931 $taxonomies[] = 'category';
932 }
933
934 if (!empty($settings['include_tags'])) { // �
935 Evidence-based field name
936 $taxonomies[] = 'post_tag';
937 }
938
939 // Auto-detect public custom taxonomies
940 $custom_taxonomies = get_taxonomies([
941 'public' => true,
942 '_builtin' => false
943 ], 'names');
944
945 foreach ($custom_taxonomies as $taxonomy) {
946 if ($this->should_include_taxonomy($taxonomy)) {
947 $taxonomies[] = $taxonomy;
948 }
949 }
950
951 return array_unique($taxonomies);
952 }
953
954
955
956 /**
957 * Fetch taxonomies using optimized combined query
958 *
959 * @since 1.0.0
960 *
961 * @param array $taxonomies Array of taxonomy names to fetch
962 * @param array $settings Sitemap settings
963 * @return array Grouped terms by taxonomy
964 */
965 private function fetch_taxonomies_optimized(array $taxonomies, array $settings): array {
966 if (empty($taxonomies)) {
967 return [];
968 }
969
970 // Parse and validate exclude_terms setting
971 $exclude_term_ids = [];
972 if (!empty($settings['exclude_terms'])) {
973 try {
974 $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
975 } catch (InvalidArgumentException $e) {
976 // Continue with empty array on validation failure - error details available in exception
977 }
978 }
979
980 // Determine hide_empty setting based on taxonomies
981 $hide_empty = true;
982 foreach ($taxonomies as $taxonomy) {
983 // For product categories, don't hide empty categories since they might not have products yet
984 if ($taxonomy === 'product_cat') {
985 $hide_empty = false;
986 break;
987 }
988 }
989
990 // OPTIMIZED: Single query for multiple taxonomies
991 $all_terms = get_terms($this->filter_term_query_args([
992 'taxonomy' => $taxonomies, // �
993 Multiple taxonomies in single query
994 'hide_empty' => $hide_empty,
995 'exclude' => $exclude_term_ids,
996 'orderby' => 'taxonomy count',
997 'order' => 'ASC DESC' // Order by taxonomy ASC, then count DESC
998 ]));
999
1000 if (is_wp_error($all_terms)) {
1001 return [];
1002 }
1003
1004 // Drop terms the user marked noindex. This path feeds the single general
1005 // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
1006 // so both need the filter or the two disagree about the same term.
1007 $all_terms = array_values(array_filter(
1008 $all_terms,
1009 fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1010 ));
1011
1012 // Group terms by taxonomy
1013 return $this->group_terms_by_taxonomy($all_terms);
1014 }
1015
1016 /**
1017 * Group terms by taxonomy
1018 *
1019 * @since 1.0.0
1020 *
1021 * @param array $terms Array of term objects
1022 * @return array Grouped terms by taxonomy
1023 */
1024 private function group_terms_by_taxonomy(array $terms): array {
1025 $grouped = [];
1026
1027 foreach ($terms as $term) {
1028 $taxonomy = $term->taxonomy; // �
1029 Evidence-based property name
1030
1031 if (!isset($grouped[$taxonomy])) {
1032 $grouped[$taxonomy] = [];
1033 }
1034
1035 $grouped[$taxonomy][] = $term;
1036 }
1037
1038 return $grouped;
1039 }
1040
1041 /**
1042 * Process taxonomy terms to XML entries
1043 *
1044 * @since 1.0.0
1045 *
1046 * @param array $terms Array of term objects
1047 * @param string $taxonomy Taxonomy name
1048 * @param array $settings Sitemap settings
1049 * @return string XML entries
1050 */
1051 private function process_taxonomies_to_xml(array $terms, string $taxonomy, array $settings): string {
1052 $xml = '';
1053
1054 foreach ($terms as $term) {
1055 $url = get_term_link($term);
1056 if (!is_wp_error($url)) {
1057 // Omit lastmod for terms (generation time is not a real
1058 // modification time).
1059 $lastmod = '';
1060 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
1061 $changefreq = 'weekly';
1062
1063 $xml .= $this->generate_url_entry($url, $lastmod, $priority, $changefreq);
1064 }
1065 }
1066
1067 return $xml;
1068 }
1069
1070 /**
1071 * Calculate intelligent priority based on content factors
1072 *
1073 * @since 1.0.0
1074 *
1075 * @param \WP_Post $post Post object
1076 * @param string $post_type Post type
1077 * @return float Priority value between 0.1 and 1.0
1078 */
1079 private function calculate_intelligent_priority(\WP_Post $post, string $post_type): float {
1080 $base_priority = $post_type === 'page' ? 0.9 : 0.8;
1081
1082 // Factors that can adjust priority
1083 $adjustments = 0;
1084
1085 // Recent content gets higher priority
1086 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
1087 if ($days_old < 30) {
1088 $adjustments += 0.1; // Recent content boost
1089 } elseif ($days_old > 365) {
1090 $adjustments -= 0.1; // Older content penalty
1091 }
1092
1093 // Content length factor
1094 $content_length = strlen(wp_strip_all_tags($post->post_content));
1095 if ($content_length > 2000) {
1096 $adjustments += 0.05; // Comprehensive content boost
1097 } elseif ($content_length < 500) {
1098 $adjustments -= 0.1; // Thin content penalty
1099 }
1100
1101 // Special page types get higher priority
1102 if ($post_type === 'page') {
1103 $page_template = get_page_template_slug($post->ID);
1104 if (in_array($page_template, ['page-home.php', 'front-page.php'], true) ||
1105 (int) $post->ID === (int) get_option('page_on_front')) {
1106 $base_priority = 1.0; // Homepage gets maximum priority
1107 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1108 $adjustments += 0.05; // Important pages boost
1109 }
1110 }
1111
1112 // Ensure priority stays within valid range
1113 $final_priority = max(0.1, min(1.0, $base_priority + $adjustments));
1114
1115 return round($final_priority, 1);
1116 }
1117
1118 /**
1119 * Calculate change frequency based on content type and age
1120 *
1121 * @since 1.0.0
1122 *
1123 * @param \WP_Post $post Post object
1124 * @param string $post_type Post type
1125 * @return string Change frequency
1126 */
1127 private function calculate_change_frequency(\WP_Post $post, string $post_type): string {
1128 // Pages typically change less frequently
1129 if ($post_type === 'page') {
1130 $page_template = get_page_template_slug($post->ID);
1131 if ((int) $post->ID === (int) get_option('page_on_front')) {
1132 return 'daily'; // Homepage changes frequently
1133 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1134 return 'monthly'; // Static pages change monthly
1135 }
1136 return 'yearly'; // Other pages change rarely
1137 }
1138
1139 // Posts frequency based on age and type
1140 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
1141
1142 if ($days_old < 7) {
1143 return 'daily'; // Very recent posts
1144 } elseif ($days_old < 30) {
1145 return 'weekly'; // Recent posts
1146 } elseif ($days_old < 365) {
1147 return 'monthly'; // Older posts
1148 }
1149
1150 return 'yearly'; // Very old posts
1151 }
1152
1153 /**
1154 * Determine if content should be included in sitemap
1155 *
1156 * @since 1.0.0
1157 *
1158 * @param \WP_Post $post Post object
1159 * @param array $settings Sitemap settings
1160 * @return bool Whether to include in sitemap
1161 */
1162 private function should_include_in_sitemap(\WP_Post $post, array $settings): bool {
1163 // The WooCommerce cart, checkout and account pages are transactional,
1164 // never indexable, and generate_default_robots_rules() already emits a
1165 // Disallow for each of them. Listing them here submitted URLs our own
1166 // robots.txt blocks, which Search Console reports as "Submitted URL
1167 // blocked by robots.txt". Yoast and Rank Math exclude the same three.
1168 if (in_array($post->ID, $this->woocommerce_excluded_page_ids(), true)) {
1169 return false;
1170 }
1171
1172 // Respect user setting for password protected content
1173 if (!empty($post->post_password) && !empty($settings['exclude_password_protected'])) {
1174 return false;
1175 }
1176
1177 // Respect user setting for private posts
1178 if ($post->post_status === 'private' && !empty($settings['exclude_private_posts'])) {
1179 return false;
1180 }
1181
1182 // Only published (and, per setting, private) content belongs in the
1183 // sitemap. Content-quality heuristics (length, "demo"/"test"/"sample"
1184 // in the title, "lorem ipsum" text) were intentionally removed: an XML
1185 // sitemap should list every indexable published URL. Filtering by
1186 // description length silently dropped legitimate WooCommerce products
1187 // with short descriptions, and the substring title match excluded real
1188 // pages such as "Demo" or "Product Samples". Indexability is governed
1189 // by noindex directives below, not by heuristics.
1190 if (!in_array($post->post_status, ['publish', 'private'], true)) {
1191 return false;
1192 }
1193
1194 // Check if post overrides robots and sets noindex.
1195 if ((bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true)) {
1196 $raw = get_post_meta($post->ID, '_thinkrank_robots_meta', true);
1197 if (is_string($raw) && $raw !== '') {
1198 $robots = json_decode($raw, true);
1199 if (is_array($robots) && !empty($robots['noindex'])) {
1200 return false;
1201 }
1202 }
1203 }
1204
1205 return true;
1206 }
1207
1208 /**
1209 * WooCommerce pages that must never reach the sitemap.
1210 *
1211 * Resolved through wc_get_page_id() so a store that moved or renamed its
1212 * cart/checkout/account pages is still matched. Returns an empty list when
1213 * WooCommerce is not active. Memoised — should_include_in_sitemap() runs
1214 * once per post.
1215 *
1216 * @since 2.0.1
1217 *
1218 * @return int[] Page IDs to exclude.
1219 */
1220 private function woocommerce_excluded_page_ids(): array {
1221 if ($this->woocommerce_excluded_page_ids !== null) {
1222 return $this->woocommerce_excluded_page_ids;
1223 }
1224
1225 $ids = [];
1226
1227 if (function_exists('wc_get_page_id')) {
1228 foreach (['cart', 'checkout', 'myaccount'] as $page) {
1229 $id = (int) wc_get_page_id($page);
1230 // wc_get_page_id() returns -1 when the page is not configured.
1231 if ($id > 0) {
1232 $ids[] = $id;
1233 }
1234 }
1235 }
1236
1237 $this->woocommerce_excluded_page_ids = $ids;
1238
1239 return $ids;
1240 }
1241
1242 /**
1243 * Whether a term carries an explicit noindex override.
1244 *
1245 * Mirrors the post-side check in should_include_post(); terms store the same
1246 * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1247 * by the update-term-seo ability and by the SEO importer.
1248 *
1249 * @since 1.31.0
1250 *
1251 * @param int $term_id Term to test.
1252 * @return bool True when the term is marked noindex.
1253 */
1254 private function term_is_noindexed(int $term_id): bool {
1255 if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1256 return false;
1257 }
1258
1259 $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1260 if (!is_string($raw) || $raw === '') {
1261 return false;
1262 }
1263
1264 $robots = json_decode($raw, true);
1265
1266 return is_array($robots) && !empty($robots['noindex']);
1267 }
1268
1269 /**
1270 * Count total URLs in sitemap
1271 *
1272 * @since 1.0.0
1273 *
1274 * @param array $settings Sitemap settings
1275 * @return int Total URL count
1276 */
1277 public function count_sitemap_urls(array $settings): int {
1278 $count = 1; // Homepage
1279
1280 if (!empty($settings['include_posts'])) {
1281 $count += wp_count_posts('post')->publish;
1282 }
1283
1284 if (!empty($settings['include_pages'])) {
1285 $count += wp_count_posts('page')->publish;
1286 }
1287
1288 if (!empty($settings['include_categories'])) {
1289 $count += wp_count_terms(['taxonomy' => 'category', 'hide_empty' => true]);
1290 }
1291
1292 if (!empty($settings['include_tags'])) {
1293 $count += wp_count_terms(['taxonomy' => 'post_tag', 'hide_empty' => true]);
1294 }
1295
1296 return $count;
1297 }
1298
1299 /**
1300 * Handle content changes for auto-generation
1301 *
1302 * @since 1.0.0
1303 * @param int $post_id Post ID
1304 * @param \WP_Post $post Post object
1305 * @return void
1306 */
1307 public function handle_content_change(int $post_id, \WP_Post $post): void {
1308 // Skip if auto-generation is disabled
1309 if (!$this->should_auto_generate()) {
1310 return;
1311 }
1312
1313 // Skip autosaves and revisions
1314 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
1315 return;
1316 }
1317
1318 // Only process published content
1319 if ($post->post_status !== 'publish') {
1320 return;
1321 }
1322
1323 // Check if this post type should trigger regeneration
1324 if (!$this->should_include_post_type($post->post_type)) {
1325 return;
1326 }
1327
1328 // Schedule debounced regeneration
1329 $this->schedule_debounced_regeneration();
1330 }
1331
1332 /**
1333 * Handle content deletion for auto-generation
1334 *
1335 * @since 1.0.0
1336 * @param int $post_id Post ID
1337 * @return void
1338 */
1339 public function handle_content_deletion(int $post_id): void {
1340 // Skip if auto-generation is disabled
1341 if (!$this->should_auto_generate()) {
1342 return;
1343 }
1344
1345 $post = get_post($post_id);
1346 if (!$post) {
1347 return;
1348 }
1349
1350 // Check if this post type should trigger regeneration
1351 if (!$this->should_include_post_type($post->post_type)) {
1352 return;
1353 }
1354
1355 // Schedule debounced regeneration
1356 $this->schedule_debounced_regeneration();
1357 }
1358
1359 /**
1360 * Handle content change by ID (for untrash, etc.)
1361 *
1362 * @since 1.0.0
1363 * @param int $post_id Post ID
1364 * @return void
1365 */
1366 public function handle_content_change_by_id(int $post_id): void {
1367 $post = get_post($post_id);
1368 if ($post) {
1369 $this->handle_content_change($post_id, $post);
1370 }
1371 }
1372
1373 /**
1374 * Handle taxonomy changes for auto-generation
1375 *
1376 * @since 1.0.0
1377 * @param int $term_id Term ID
1378 * @param int $tt_id Term taxonomy ID
1379 * @param string $taxonomy Taxonomy slug
1380 * @return void
1381 */
1382 public function handle_taxonomy_change(int $term_id, int $tt_id, string $taxonomy): void {
1383 // Skip if auto-generation is disabled
1384 if (!$this->should_auto_generate()) {
1385 return;
1386 }
1387
1388 // Check if this taxonomy should trigger regeneration
1389 if (!$this->should_include_taxonomy($taxonomy)) {
1390 return;
1391 }
1392
1393 // Schedule debounced regeneration
1394 $this->schedule_debounced_regeneration();
1395 }
1396
1397 /**
1398 * Check if auto-generation is enabled
1399 *
1400 * @since 1.0.0
1401 * @return bool True if auto-generation is enabled
1402 */
1403 private function should_auto_generate(): bool {
1404 $settings = $this->get_settings('site');
1405
1406 // Check if sitemap is enabled
1407 if (empty($settings['enabled'])) {
1408 return false;
1409 }
1410
1411 // Check if auto-generation is enabled
1412 return !empty($settings['auto_generate']);
1413 }
1414
1415 /**
1416 * Get enabled post types based on settings
1417 *
1418 * @since 1.0.0
1419 * @param array $settings Sitemap settings
1420 * @return array Array of enabled post types
1421 */
1422 private function get_enabled_post_types(array $settings): array {
1423 $post_types = [];
1424
1425 // Core post types based on settings
1426 if (!empty($settings['include_posts'])) {
1427 $post_types[] = 'post';
1428 }
1429
1430 if (!empty($settings['include_pages'])) {
1431 $post_types[] = 'page';
1432 }
1433
1434 // Auto-detect public custom post types that should be included
1435 $custom_post_types = get_post_types([
1436 'public' => true,
1437 '_builtin' => false
1438 ], 'names');
1439
1440 foreach ($custom_post_types as $post_type) {
1441 if ($this->should_include_post_type($post_type)) {
1442 $post_types[] = $post_type;
1443 }
1444 }
1445
1446 return array_unique($post_types);
1447 }
1448
1449 /**
1450 * Check if post type should trigger regeneration
1451 *
1452 * @since 1.0.0
1453 * @param string $post_type Post type
1454 * @return bool True if post type should trigger regeneration
1455 */
1456 private function should_include_post_type(string $post_type): bool {
1457 // Match Rank Math: only publicly viewable post types belong in the
1458 // sitemap (public && publicly_queryable). Additionally skip post types
1459 // that opt out of front-end search (exclude_from_search => true) — e.g.
1460 // Templately's internal `templately_library` store — which are template
1461 // records, not standalone indexable URLs. BetterDocs `docs` and
1462 // WooCommerce `product` register exclude_from_search => false, so they
1463 // remain included.
1464 if (!is_post_type_viewable($post_type)) {
1465 return false;
1466 }
1467
1468 $post_type_obj = get_post_type_object($post_type);
1469 if (!$post_type_obj || !empty($post_type_obj->exclude_from_search)) {
1470 return false;
1471 }
1472
1473 return true;
1474 }
1475
1476 /**
1477 * Check if taxonomy should trigger regeneration
1478 *
1479 * @since 1.0.0
1480 * @param string $taxonomy Taxonomy slug
1481 * @return bool True if taxonomy should trigger regeneration
1482 */
1483 private function should_include_taxonomy(string $taxonomy): bool {
1484 // Include all public taxonomies (presets control which sitemaps are created)
1485 $taxonomy_obj = get_taxonomy($taxonomy);
1486 return $taxonomy_obj && $taxonomy_obj->public;
1487 }
1488
1489 /**
1490 * Schedule debounced sitemap regeneration
1491 *
1492 * @since 1.0.0
1493 * @return void
1494 */
1495 private function schedule_debounced_regeneration(): void {
1496 // Clear any existing scheduled regeneration
1497 wp_clear_scheduled_hook('thinkrank_regenerate_sitemap');
1498
1499 // Schedule regeneration in 30 seconds to debounce rapid changes
1500 wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap');
1501 }
1502
1503 /**
1504 * Public entry point to debounce-rebuild the sitemap after a settings change
1505 * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
1506 * file reflects the new settings instead of going stale until a content edit.
1507 *
1508 * @return void
1509 */
1510 public function schedule_regeneration(): void {
1511 // Debounce against rapid successive saves, but use the settings-specific
1512 // hook so the rebuild runs regardless of the auto_generate toggle (which
1513 // only governs content-change-triggered regeneration).
1514 wp_clear_scheduled_hook('thinkrank_regenerate_sitemap_settings');
1515 wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap_settings');
1516 }
1517
1518 /**
1519 * Rebuild the served sitemap after an explicit settings change.
1520 *
1521 * Unlike {@see auto_regenerate_sitemap()}, this is NOT gated on the
1522 * auto_generate setting: the user deliberately changed inclusion rules and
1523 * expects the served file to reflect them even if content-triggered
1524 * auto-generation is turned off. Still respects the master `enabled` flag.
1525 *
1526 * @return void
1527 */
1528 public function regenerate_sitemap_from_settings(): void {
1529 try {
1530 $settings = $this->get_settings('site');
1531 if (empty($settings['enabled'])) {
1532 // The sitemap was disabled: remove the previously generated static
1533 // files so the web server stops serving a stale sitemap that
1534 // crawlers would otherwise keep fetching.
1535 $this->delete_published_sitemaps();
1536 return;
1537 }
1538 $this->generate_and_save($settings);
1539 } catch (\Throwable $e) {
1540 // Settings-triggered regeneration failed - details in exception.
1541 }
1542 }
1543
1544 /**
1545 * Remove every static sitemap file ThinkRank publishes to the web root.
1546 *
1547 * Called when the sitemap feature is disabled, and by the cleanup route, so
1548 * /sitemap.xml, /sitemap_index.xml, the segmented children (incl. paginated
1549 * -N pages), and /local-sitemap.xml stop being served. Only ThinkRank's own
1550 * filenames are targeted; WordPress core's wp-sitemap.xml and any other
1551 * plugin's sitemap in the web root are left untouched.
1552 *
1553 * @since 1.31.0 Returns the filenames removed, and accepts the settings to
1554 * derive them from, so a caller that already read them (and
1555 * needs to report what went) does not have to re-read or
1556 * re-derive the name list.
1557 *
1558 * @param array|null $settings Optional. Sitemap settings; defaults to the
1559 * saved site settings.
1560 * @return array{deleted: string[], failed: string[]} Basenames removed, and
1561 * those that existed but could not be removed.
1562 */
1563 public function delete_published_sitemaps(?array $settings = null): array {
1564 $settings = $settings ?? $this->get_settings('site');
1565 $deleted = [];
1566 $failed = [];
1567
1568 // Base filenames to remove. Derive the child/index names from the stored
1569 // sitemap_urls so a custom custom_url_pattern (e.g. seo-{type}.xml) is
1570 // honored, not just the default sitemap-*.xml naming. Include the current
1571 // primary + the default names as a safety net.
1572 $names = [
1573 'sitemap.xml',
1574 'sitemap_index.xml',
1575 'local-sitemap.xml',
1576 $this->get_primary_sitemap_filename($settings),
1577 ];
1578
1579 foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
1580 if (empty($config['url'])) {
1581 continue;
1582 }
1583 $name = basename((string) wp_parse_url($config['url'], PHP_URL_PATH));
1584 if ($name !== '') {
1585 $names[] = $name;
1586 }
1587 }
1588
1589 // Segment names for every type we could have published under the current
1590 // url pattern, not just the ones stored in sitemap_urls. Two states leave
1591 // a published child unlisted there: settings that drifted from what is on
1592 // disk (a single-file entry while an index and its children are live), and
1593 // a content type that has since been switched off. Deriving the names
1594 // from the pattern reaches those without falling back to a 'sitemap-*.xml'
1595 // glob, which would also match another plugin's segments.
1596 $names = array_merge($names, $this->publishable_segment_filenames($settings));
1597
1598 foreach (array_unique(array_filter($names)) as $name) {
1599 // Remove the file itself and any paginated -N variants of its stem
1600 // (e.g. seo-posts.xml plus seo-posts-2.xml, seo-posts-3.xml…).
1601 $path = ABSPATH . $name;
1602 if (file_exists($path)) {
1603 wp_delete_file($path);
1604 // wp_delete_file() returns nothing, so confirm by re-checking.
1605 if (file_exists($path)) {
1606 $failed[] = $name;
1607 } else {
1608 $deleted[] = $name;
1609 }
1610 }
1611 if (preg_match('/^(.*)\.xml$/i', $name, $m)) {
1612 // Pagination pages only — a numeric suffix on this exact stem.
1613 // Globbing '<stem>-*.xml' matched any name that merely started
1614 // with the stem, so the default 'sitemap.xml' entry pulled in
1615 // every sitemap-*.xml in the root, including another plugin's.
1616 $paged_pattern = '/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i';
1617
1618 foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
1619 $paged_name = basename($paged);
1620 if (!preg_match($paged_pattern, $paged_name)) {
1621 continue;
1622 }
1623
1624 wp_delete_file($paged);
1625 if (file_exists($paged)) {
1626 $failed[] = $paged_name;
1627 } else {
1628 $deleted[] = $paged_name;
1629 }
1630 }
1631 }
1632 }
1633
1634 return [
1635 'deleted' => array_values(array_unique($deleted)),
1636 'failed' => array_values(array_unique($failed)),
1637 ];
1638 }
1639
1640 /**
1641 * Every child-sitemap filename this site could have published.
1642 *
1643 * Formats the configured url pattern against each type ThinkRank segments by
1644 * — the four built-ins plus every public custom post type and public custom
1645 * taxonomy — ignoring whether that type is currently included. The point is
1646 * to recognise our own filenames, and a type that was published and later
1647 * disabled still left a file behind.
1648 *
1649 * @since 1.31.0
1650 *
1651 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
1652 * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
1653 */
1654 private function publishable_segment_filenames(array $settings): array {
1655 $pattern = (string) ($settings['custom_url_pattern'] ?? 'sitemap-{type}.xml');
1656 if (strpos($pattern, '{type}') === false) {
1657 return [];
1658 }
1659
1660 $types = ['posts', 'pages', 'categories', 'tags'];
1661
1662 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
1663 $types[] = (string) $cpt;
1664 }
1665
1666 foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
1667 $types[] = (string) $taxonomy;
1668 }
1669
1670 $names = [];
1671 foreach (array_unique($types) as $type) {
1672 $name = basename(str_replace('{type}', $type, $pattern));
1673 if ($name !== '') {
1674 $names[] = $name;
1675 }
1676 }
1677
1678 return $names;
1679 }
1680
1681 /**
1682 * Auto-regenerate sitemap (called by scheduled action)
1683 *
1684 * @since 1.0.0
1685 * @return void
1686 */
1687 public function auto_regenerate_sitemap(): void {
1688 try {
1689 // Double-check that auto-generation is still enabled
1690 if (!$this->should_auto_generate()) {
1691 return;
1692 }
1693
1694 $this->generate_and_save($this->get_settings('site'));
1695
1696 // Sitemap auto-regenerated successfully
1697
1698 } catch (\Throwable $e) {
1699 // Sitemap auto-regeneration failed - error details available in exception
1700 }
1701 }
1702
1703 /**
1704 * Build and write the sitemap files for the given settings.
1705 *
1706 * Segmented files plus an index when the settings configure one, a single
1707 * file otherwise, and the standalone local business sitemap either way.
1708 * Records `last_generated` so the UI's "View Generated Sitemaps" links
1709 * unlock, mirroring the manual generate endpoint.
1710 *
1711 * @since 1.17.0
1712 * @param array $settings Sitemap settings.
1713 * @return bool True when the sitemap files were written.
1714 */
1715 public function generate_and_save(array $settings): bool {
1716 // Index mode is driven by the use_sitemap_index toggle (not merely by how
1717 // many sitemap_urls happen to be configured). When the toggle is on but
1718 // no child sitemaps are set up yet, synthesize the per-type segmented set
1719 // so we emit a real <sitemapindex> with paginated children instead of a
1720 // flat urlset misnamed sitemap_index.xml.
1721 $settings = $this->maybe_promote_to_index($settings);
1722
1723 if (!empty($settings['use_sitemap_index']) || count((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : [])) > 1) {
1724 $results = $this->generate_multiple_sitemaps($settings);
1725 $written = !empty($results['success']);
1726 } else {
1727 $xml = $this->generate_sitemap($settings);
1728 $written = $this->save_sitemap_to_file($xml, $this->get_primary_sitemap_filename($settings));
1729
1730 // Local business sitemap is a standalone file, regenerated on the
1731 // single-sitemap path too (this is the default mode).
1732 $this->regenerate_local_sitemap($settings);
1733 }
1734
1735 if ($written) {
1736 $settings['last_generated'] = gmdate('c');
1737 $this->save_settings('site', null, $settings);
1738 }
1739
1740 return $written;
1741 }
1742
1743 /**
1744 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
1745 *
1746 * - When use_sitemap_index is on but no child sitemaps are configured, build
1747 * the per-type segmented set so a real <sitemapindex> is produced (#127).
1748 * - When the toggle is off but a single flat file would exceed the per-file
1749 * URL cap, auto-promote to a paginated index instead of one oversized file
1750 * that can cross Google's 50k-URL/50MB limits (#129).
1751 *
1752 * @param array $settings Sitemap settings.
1753 * @return array Possibly-updated settings.
1754 */
1755 public function maybe_promote_to_index(array $settings): array {
1756 $saved = $this->get_settings('site');
1757
1758 // Read from the *payload*, before the merge below folds the saved values
1759 // in: "the caller named this" and "this has a value" are different
1760 // questions, and the mode resolution turns on the former.
1761 $mode_supplied = array_key_exists('use_sitemap_index', $settings);
1762 $urls_supplied = is_array($settings['sitemap_urls'] ?? null);
1763 $has_children = count($urls_supplied ? $settings['sitemap_urls'] : []) > 1;
1764
1765 // Which inclusion flags did the caller actually name? The child list is
1766 // the only thing that reads them, and inheriting a saved one skipped
1767 // that — so on an index-mode site the include_* flags were enforced
1768 // nowhere but in the browser, where SitemapGeneration.js recomputes
1769 // sitemap_urls itself. Every non-UI client, the shipped
1770 // `update-sitemap-settings` ability included, saved the flag and changed
1771 // nothing (#398). Read from the payload for the same reason as above:
1772 // after the merge every saved flag would look like one the caller named.
1773 $named_inclusions = array_intersect(
1774 array_keys(self::INCLUSION_CHILD_TYPES),
1775 array_keys($settings)
1776 );
1777 $inclusions_supplied = (bool) $named_inclusions;
1778
1779 // Inclusion flags may be absent from a partial payload (e.g. the manual
1780 // generate endpoint) — fall back to saved settings so synthesized child
1781 // sitemaps reflect the real include_posts/pages/categories choices.
1782 $inclusions = array_merge($saved, $settings);
1783
1784 // Hand the generators a *complete* settings array. Only the mode was
1785 // resolved before, so every other unnamed key reached them missing: a
1786 // bare `{}` from a REST/MCP client republished the sitemap with
1787 // enable_styling and include_images read as off, overwriting the live
1788 // files with output that had lost its XSL stylesheet, its image
1789 // namespace and its image entries. Presentation and inclusion settings
1790 // are not something a generate call opts into — they are the site's
1791 // configuration, and only a value actually present in the payload
1792 // overrides them.
1793 $settings = $inclusions;
1794
1795 // The two keys that drive mode keep their own resolution rules below,
1796 // so they must go back to "not specified" when the caller omitted them.
1797 if (!$mode_supplied) {
1798 unset($settings['use_sitemap_index']);
1799 }
1800 if (!$urls_supplied) {
1801 unset($settings['sitemap_urls']);
1802 }
1803
1804 // An absent use_sitemap_index means "not specified", which is not the
1805 // same as "single file". Reading it as the latter meant a partial payload
1806 // — `{}` from a REST/MCP client, or anything short of the full settings
1807 // object the admin bundle sends — republished one flat sitemap.xml on an
1808 // index-mode site and left sitemap_index.xml and its children stale or
1809 // missing. Inherit the saved mode instead; only a value actually present
1810 // in the payload decides the mode.
1811 if (!array_key_exists('use_sitemap_index', $settings)) {
1812 $settings['use_sitemap_index'] = $saved['use_sitemap_index'] ?? '';
1813
1814 // Inheriting the mode means inheriting its children too, unless the
1815 // caller named its own set.
1816 $saved_children = (is_array($saved['sitemap_urls'] ?? null) ? $saved['sitemap_urls'] : []);
1817 if (!empty($settings['use_sitemap_index'])
1818 && !$urls_supplied
1819 && count($saved_children) > 1) {
1820 // A named inclusion flag is applied *to* the inherited list, not
1821 // used to regenerate it. build_segmented_sitemap_urls() also adds
1822 // a child for every public custom post type, so rebuilding here
1823 // would make `{include_pages: false}` — one thing off — silently
1824 // switch on children the saved list never had (an Elementor
1825 // internal CPT, a WooCommerce product feed). Only the flags the
1826 // caller actually named change anything.
1827 $settings['sitemap_urls'] = $inclusions_supplied
1828 ? $this->apply_inclusion_flags_to_children($saved_children, $named_inclusions, $inclusions)
1829 : $saved_children;
1830
1831 // The children are resolved either way — including when the
1832 // caller switched the last one off, which leaves a bare index and
1833 // is what they asked for.
1834 $has_children = true;
1835 }
1836 }
1837
1838 if (!empty($settings['use_sitemap_index'])) {
1839 if (!$has_children) {
1840 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
1841 }
1842 return $settings;
1843 }
1844
1845 if (!$has_children) {
1846 $limit = $this->get_links_per_sitemap($settings);
1847 if ($limit > 0 && $this->estimate_total_sitemap_urls($inclusions) > $limit) {
1848 $settings['use_sitemap_index'] = true;
1849 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
1850 }
1851 }
1852
1853 return $settings;
1854 }
1855
1856 /**
1857 * Rough count of URLs a single-file sitemap would contain, used only to
1858 * decide whether to auto-promote to a paginated index. Cheap COUNT queries;
1859 * intentionally approximate (homepage + published posts of enabled types +
1860 * terms of enabled taxonomies).
1861 *
1862 * @param array $settings Sitemap settings.
1863 * @return int
1864 */
1865 private function estimate_total_sitemap_urls(array $settings): int {
1866 $total = 1; // homepage
1867
1868 foreach ($this->get_enabled_post_types($settings) as $post_type) {
1869 $counts = wp_count_posts($post_type);
1870 $total += isset($counts->publish) ? (int) $counts->publish : 0;
1871 }
1872
1873 foreach ($this->get_enabled_taxonomies($settings) as $taxonomy) {
1874 $count = wp_count_terms(['taxonomy' => $taxonomy, 'hide_empty' => true]);
1875 if (!is_wp_error($count)) {
1876 $total += (int) $count;
1877 }
1878 }
1879
1880 return $total;
1881 }
1882
1883 /**
1884 * Resolve the sitemap file the site publishes for the given settings.
1885 *
1886 * Index mode serves the index (`sitemap_index.xml`); the default single-file
1887 * mode serves `sitemap.xml`. Callers use this to link to — or check for —
1888 * the file the site actually serves, since ThinkRank's sitemap is a static
1889 * file in the web root rather than a route.
1890 *
1891 * @since 1.17.0
1892 * @param array $settings Sitemap settings.
1893 * @return string Sitemap filename.
1894 */
1895 public function get_primary_sitemap_filename(array $settings): string {
1896 $use_index = !empty($settings['use_sitemap_index']);
1897
1898 foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
1899 if (empty($config['enabled']) || empty($config['url'])) {
1900 continue;
1901 }
1902
1903 if ($use_index !== (($config['type'] ?? '') === 'index')) {
1904 continue;
1905 }
1906
1907 $path = wp_parse_url($config['url'], PHP_URL_PATH);
1908 if (!empty($path)) {
1909 return basename($path);
1910 }
1911 }
1912
1913 return $use_index ? 'sitemap_index.xml' : 'sitemap.xml';
1914 }
1915
1916 /**
1917 * Public URL of the sitemap the site serves.
1918 *
1919 * @since 1.17.0
1920 * @param array|null $settings Sitemap settings (falls back to saved site settings).
1921 * @return string Absolute sitemap URL.
1922 */
1923 public function get_primary_sitemap_url(?array $settings = null): string {
1924 $settings = $settings ?? $this->get_settings('site');
1925
1926 return home_url('/' . $this->get_primary_sitemap_filename($settings));
1927 }
1928
1929 /**
1930 * Whether the sitemap file this site publishes exists on disk.
1931 *
1932 * @since 1.17.0
1933 * @param array $settings Sitemap settings.
1934 * @return bool True when the file is present.
1935 */
1936 public function primary_sitemap_file_exists(array $settings): bool {
1937 return file_exists(ABSPATH . $this->get_primary_sitemap_filename($settings));
1938 }
1939
1940 /**
1941 * Save sitemap XML to file
1942 *
1943 * @since 1.0.0
1944 * @param string $sitemap_xml Sitemap XML content
1945 * @param string $filename Optional. Filename to save (defaults to 'sitemap.xml')
1946 * @return bool True on success, false on failure
1947 */
1948 private function save_sitemap_to_file(string $sitemap_xml, string $filename = 'sitemap.xml'): bool {
1949 // Validate and sanitize filename for security
1950 try {
1951 $filename = $this->validate_sitemap_filename($filename);
1952 } catch (InvalidArgumentException $e) {
1953 // File validation failed - error details available in exception
1954 return false;
1955 }
1956
1957 $sitemap_path = ABSPATH . $filename;
1958
1959 // Use WordPress filesystem API for better security
1960 global $wp_filesystem;
1961 if (!$wp_filesystem) {
1962 require_once ABSPATH . 'wp-admin/includes/file.php';
1963 WP_Filesystem();
1964 }
1965
1966 if ($wp_filesystem) {
1967 return $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
1968 }
1969
1970 // WP_Filesystem initialization failed
1971 return false;
1972 }
1973
1974 /**
1975 * Build a segmented, index-based sitemap_urls list from inclusion settings.
1976 *
1977 * Produces the index entry plus one child sitemap per enabled content type
1978 * (posts/pages/categories) and one per public custom post type — the shape
1979 * the "complete" preset creates and that generate_multiple_sitemaps() expects.
1980 * Used when enabling the index during import so the index has real children
1981 * instead of being empty.
1982 *
1983 * @since 1.14.0
1984 *
1985 * @param array $inclusions Inclusion flags (include_posts/pages/categories)
1986 * and optionally custom_url_pattern.
1987 * @return array Sitemap URL configs
1988 */
1989 public function build_segmented_sitemap_urls(array $inclusions): array {
1990 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
1991
1992 $urls = [$this->sitemap_child_entry('/sitemap_index.xml', 'index')];
1993
1994 foreach (self::INCLUSION_CHILD_TYPES as $flag => $type) {
1995 if (!empty($inclusions[$flag])) {
1996 $urls[] = $this->build_child_sitemap_entry($type, $pattern);
1997 }
1998 }
1999
2000 // Public custom post types each get a child sitemap (parity with the
2001 // "complete" preset and with Rank Math, which lists every public CPT).
2002 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
2003 if ($this->should_include_post_type($cpt)) {
2004 $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
2005 }
2006 }
2007
2008 return $urls;
2009 }
2010
2011 /**
2012 * Apply only the inclusion flags the caller named to an existing child list.
2013 *
2014 * The narrow counterpart to build_segmented_sitemap_urls(): that one
2015 * regenerates the whole set from scratch, which is right when there is no set
2016 * yet and wrong when there is. Rebuilding an existing list would add a child
2017 * for every public custom post type it had never contained, so a payload that
2018 * switches one thing off would switch others on. Here a flag adds or removes
2019 * exactly its own child and leaves every other entry — custom post types,
2020 * hand-added URLs, per-child enabled/status state — untouched (#398).
2021 *
2022 * @since 1.31.0
2023 *
2024 * @param array $children Existing child sitemap entries.
2025 * @param string[] $named_inclusions Inclusion flag keys present in the payload.
2026 * @param array $inclusions Merged settings, for the resolved flag
2027 * values and custom_url_pattern.
2028 * @return array Updated child sitemap entries.
2029 */
2030 private function apply_inclusion_flags_to_children(
2031 array $children,
2032 array $named_inclusions,
2033 array $inclusions
2034 ): array {
2035 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
2036
2037 foreach ($named_inclusions as $flag) {
2038 $type = self::INCLUSION_CHILD_TYPES[$flag];
2039
2040 $present = false;
2041 foreach ($children as $entry) {
2042 if (($entry['type'] ?? '') === $type) {
2043 $present = true;
2044 break;
2045 }
2046 }
2047
2048 if (empty($inclusions[$flag])) {
2049 if ($present) {
2050 $children = array_values(array_filter(
2051 $children,
2052 static function ($entry) use ($type): bool {
2053 return (is_array($entry) ? ($entry['type'] ?? '') : '') !== $type;
2054 }
2055 ));
2056 }
2057 continue;
2058 }
2059
2060 if (!$present) {
2061 $children[] = $this->build_child_sitemap_entry($type, $pattern);
2062 }
2063 }
2064
2065 return $children;
2066 }
2067
2068 /**
2069 * Build one child sitemap entry, resolving its filename from the url pattern.
2070 *
2071 * @since 1.31.0
2072 *
2073 * @param string $type Child sitemap type (posts, pages, a post type name).
2074 * @param string $pattern Filename pattern containing {type}.
2075 * @return array Sitemap URL config.
2076 */
2077 private function build_child_sitemap_entry(string $type, string $pattern): array {
2078 $file = str_replace('{type}', $type, $pattern);
2079 if (strpos($file, '/') !== 0) {
2080 $file = '/' . $file;
2081 }
2082
2083 return $this->sitemap_child_entry($file, $type);
2084 }
2085
2086 /**
2087 * The shape generate_multiple_sitemaps() expects of a sitemap_urls entry.
2088 *
2089 * @since 1.31.0
2090 *
2091 * @param string $url Sitemap path.
2092 * @param string $type Entry type.
2093 * @return array Sitemap URL config.
2094 */
2095 private function sitemap_child_entry(string $url, string $type): array {
2096 return [
2097 'url' => $url,
2098 'type' => $type,
2099 'enabled' => true,
2100 'last_checked' => null,
2101 'status' => 'unknown',
2102 ];
2103 }
2104
2105 /**
2106 * Generate multiple sitemaps based on settings
2107 *
2108 * @since 1.0.0
2109 * @param array $settings Sitemap settings
2110 * @return array Results of sitemap generation
2111 */
2112 public function generate_multiple_sitemaps(array $settings): array {
2113 $results = [
2114 'success' => true,
2115 'sitemaps_generated' => [],
2116 'errors' => [],
2117 'total_urls' => 0
2118 ];
2119
2120 $sitemap_urls = (is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []);
2121
2122 if (empty($sitemap_urls)) {
2123 $results['success'] = false;
2124 $results['errors'][] = 'No sitemap URLs configured';
2125 return $results;
2126 }
2127
2128 $limit = $this->get_links_per_sitemap($settings);
2129
2130 // Defer the index until every child sitemap has been generated, so it can
2131 // list the actual files produced (including pagination pages).
2132 $index_config = null;
2133 $index_children = [];
2134
2135 foreach ($sitemap_urls as $sitemap_config) {
2136 if (empty($sitemap_config['enabled'])) {
2137 continue;
2138 }
2139
2140 $type = $sitemap_config['type'];
2141
2142 if ($type === 'index') {
2143 $index_config = $sitemap_config;
2144 continue;
2145 }
2146
2147 // Skip CPT children that no longer qualify (e.g. an internal store
2148 // like Templately's `templately_library`) even when a previously
2149 // saved config still lists them. Built-in aggregate types ('posts',
2150 // 'pages', 'general', etc.) are not post type names, so this only
2151 // affects real custom post types.
2152 if (post_type_exists($type) && !$this->should_include_post_type($type)) {
2153 continue;
2154 }
2155
2156 try {
2157 // The single "general"/"WordPress" sitemap is one un-paginated file
2158 // (there is no index to reference extra pages); it no longer drops
2159 // overflow URLs.
2160 if ($type === 'general' || $type === 'wordpress') { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- lowercase on purpose: this is the stored type slug.
2161 $xml = $this->generate_sitemap($settings);
2162 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
2163 continue;
2164 }
2165
2166 $source = $this->stream_type_entries($type, $settings);
2167
2168 // Unknown type — fall back to a single general sitemap file.
2169 if ($source === null) {
2170 $xml = $this->generate_sitemap($settings);
2171 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
2172 continue;
2173 }
2174
2175 // Stream the entries into files of at most $limit URLs, matching
2176 // Rank Math: page 1 keeps the base filename, pages 2+ get a -N
2177 // suffix, and every page is listed in the index. Buffering only one
2178 // page at a time keeps peak memory bounded to $limit entries rather
2179 // than every URL of the type.
2180 $image_ns = $source['image_ns'];
2181 $buffer = [];
2182 $page = 0;
2183
2184 foreach ($source['entries'] as $entry) {
2185 $buffer[] = $entry;
2186 if (count($buffer) >= $limit) {
2187 $page++;
2188 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
2189 $buffer = [];
2190 }
2191 }
2192
2193 // Flush the trailing partial page, or a single empty page when the
2194 // type had no entries at all (parity with the previous behavior of
2195 // always writing at least one page per configured child).
2196 if (!empty($buffer) || $page === 0) {
2197 $page++;
2198 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
2199 }
2200
2201 // Remove pages left over from a previous, larger generation.
2202 $this->cleanup_stale_pages($sitemap_config['url'], $page);
2203
2204 } catch (\Exception $e) {
2205 $results['errors'][] = "Error generating {$type} sitemap: " . $e->getMessage();
2206 $results['success'] = false;
2207 }
2208 }
2209
2210 // Local business sitemap (parity with Rank Math's local-sitemap.xml).
2211 // The physical file is written whenever a business identity is
2212 // configured — independent of segmented vs single mode — and is also
2213 // listed in the sitemap index when one exists.
2214 try {
2215 if ($this->regenerate_local_sitemap($settings) && $index_config !== null) {
2216 $index_children[] = ['url' => '/local-sitemap.xml'];
2217 }
2218 } catch (\Exception $e) {
2219 $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
2220 $results['success'] = false;
2221 }
2222
2223 // Build the index last, from the child files actually generated.
2224 if ($index_config !== null) {
2225 try {
2226 $index_xml = $this->generate_sitemap_index($index_children, $settings);
2227 $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
2228
2229 if ($this->save_sitemap_to_file($index_xml, $filename)) {
2230 $results['sitemaps_generated'][] = [
2231 'url' => $index_config['url'],
2232 'type' => 'index',
2233 'filename' => $filename,
2234 'url_count' => count($index_children),
2235 ];
2236 } else {
2237 $results['errors'][] = "Failed to save sitemap: {$filename}";
2238 $results['success'] = false;
2239 }
2240 } catch (\Exception $e) {
2241 $results['errors'][] = 'Error generating index sitemap: ' . $e->getMessage();
2242 $results['success'] = false;
2243 }
2244 }
2245
2246 // Remove segments that are no longer part of the set. Publishing was
2247 // purely additive: a type that dropped out (Categories unticked, a CPT
2248 // that stopped qualifying) simply stopped being overwritten, so its file
2249 // kept serving and — until the index happened to be rebuilt — kept being
2250 // listed in it. The index above is built from the children actually
2251 // generated, so pruning here leaves disk and index agreeing.
2252 $this->prune_orphaned_segments($settings, $results['sitemaps_generated']);
2253
2254 return $results;
2255 }
2256
2257 /**
2258 * Delete published segment files that this run did not write.
2259 *
2260 * Only filenames this site could have published under its own url pattern
2261 * are considered, so another plugin's or core's sitemap in the web root is
2262 * never a candidate — the same reason cleanup does not glob 'sitemap-*.xml'.
2263 *
2264 * @since 1.31.0
2265 *
2266 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
2267 * @param array $generated Entries from $results['sitemaps_generated'].
2268 * @return string[] Basenames removed.
2269 */
2270 private function prune_orphaned_segments(array $settings, array $generated): array {
2271 $kept = [];
2272 foreach ($generated as $entry) {
2273 if (!empty($entry['filename'])) {
2274 $kept[strtolower((string) $entry['filename'])] = true;
2275 }
2276 }
2277
2278 // The index and the local business sitemap are written by their own
2279 // paths and are not segments, so they are never orphans here.
2280 $kept[strtolower(basename($this->get_primary_sitemap_filename($settings)))] = true;
2281 $kept['local-sitemap.xml'] = true;
2282 $kept['sitemap.xml'] = true;
2283 $kept['sitemap_index.xml'] = true;
2284
2285 $removed = [];
2286
2287 global $wp_filesystem;
2288 if (!$wp_filesystem) {
2289 require_once ABSPATH . 'wp-admin/includes/file.php';
2290 WP_Filesystem();
2291 }
2292 if (!$wp_filesystem) {
2293 return $removed;
2294 }
2295
2296 foreach ($this->publishable_segment_filenames($settings) as $candidate) {
2297 if (isset($kept[strtolower($candidate)])) {
2298 continue;
2299 }
2300
2301 if (!preg_match('/^(.*)\.xml$/i', $candidate, $m)) {
2302 continue;
2303 }
2304
2305 // The base file plus its numeric pagination pages.
2306 $paths = [ABSPATH . $candidate];
2307 foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
2308 if (preg_match('/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i', basename($paged))) {
2309 $paths[] = $paged;
2310 }
2311 }
2312
2313 foreach ($paths as $path) {
2314 if (!file_exists($path)) {
2315 continue;
2316 }
2317 if ($wp_filesystem->delete($path)) {
2318 $removed[] = basename($path);
2319 }
2320 }
2321 }
2322
2323 return $removed;
2324 }
2325
2326
2327 /**
2328 * Collect the full (un-paginated) entry list for a content sitemap type.
2329 *
2330 * @since 1.14.0
2331 *
2332 * @param string $type Sitemap config type (posts, pages, categories, …)
2333 * @param array $settings Sitemap settings
2334 * @return array{entries: array<string>, image_ns: bool}|null Null for an
2335 * unknown type (caller falls back to a general sitemap).
2336 */
2337 /**
2338 * Write (or remove) the physical local-sitemap.xml file.
2339 *
2340 * Called from every sitemap regeneration path — segmented generation, the
2341 * single-sitemap auto-regeneration, and the manual generate endpoint — so
2342 * the local sitemap works regardless of whether the site uses a sitemap
2343 * index. When no business identity is configured, any stale file is removed.
2344 *
2345 * @since 1.15.x
2346 * @param array|null $settings Sitemap settings (falls back to saved site settings).
2347 * @return bool True when the file was written, false when nothing was written.
2348 */
2349 public function regenerate_local_sitemap(?array $settings = null): bool {
2350 $settings = $settings ?? $this->get_settings('site');
2351 $entries = $this->collect_local_entries();
2352
2353 if (empty($entries)) {
2354 // Business identity was cleared — drop any file left from before.
2355 $path = ABSPATH . 'local-sitemap.xml';
2356 if (file_exists($path)) {
2357 wp_delete_file($path);
2358 }
2359 return false;
2360 }
2361
2362 $xml = $this->wrap_urlset($entries, $settings, false);
2363 return $this->save_sitemap_to_file($xml, 'local-sitemap.xml');
2364 }
2365
2366 /**
2367 * Build the local business sitemap entries.
2368 *
2369 * Returns a single URL entry pointing at the on-site page that carries the
2370 * business's LocalBusiness/Organization schema (the configured business URL
2371 * when it's on this site, otherwise the homepage). Gated — like Rank Math's
2372 * `local-sitemap.xml` — on a business (non-person) type being configured
2373 * with an actual location (address or geo coordinates). Returns an empty
2374 * array when no business location is set, so no empty local sitemap is
2375 * written or added to the index.
2376 *
2377 * @since 1.15.x
2378 * @return array Zero or one URL entry
2379 */
2380 private function collect_local_entries(): array {
2381 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2382 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2383 }
2384
2385 $identity = (new \ThinkRank\SEO\Site_Identity_Manager())->get_settings('site');
2386
2387 // Gate like Rank Math's local sitemap: a business (non-person) identity
2388 // is configured. We only emit a single URL (the location page), so a
2389 // full postal address is NOT required — requiring one wrongly excluded
2390 // migrated sites, since the Rank Math importer carries over business
2391 // type/name/phone but not the address. Personal sites, and sites with no
2392 // business identity at all, get no local sitemap.
2393 $business_type = strtolower((string) ($identity['business_type'] ?? ''));
2394 if ($business_type === 'person') {
2395 return [];
2396 }
2397 if ($business_type === '' && empty($identity['business_name'])) {
2398 return [];
2399 }
2400
2401 // Prefer the configured business URL when it points at this site;
2402 // otherwise fall back to the homepage (which outputs the schema).
2403 $location_url = home_url('/');
2404 if (!empty($identity['business_url'])) {
2405 $candidate = esc_url_raw((string) $identity['business_url']);
2406 if ($candidate && wp_parse_url($candidate, PHP_URL_HOST) === wp_parse_url(home_url(), PHP_URL_HOST)) {
2407 $location_url = $candidate;
2408 }
2409 }
2410
2411 // Omit lastmod — no real modification source for the local business URL.
2412 return [$this->generate_url_entry($location_url, '', 0.8, 'weekly')];
2413 }
2414
2415 /**
2416 * Resolve a streaming entry source for a sitemap type.
2417 *
2418 * Returns an iterable that yields the type's <url> entries one at a time
2419 * (so the paginator never materializes the whole type), the image-namespace
2420 * flag, or null when the type isn't one we build (caller falls back to a
2421 * single general sitemap). Entry order matches the previous array-based
2422 * collect_type_entries() exactly.
2423 *
2424 * @param string $type Sitemap type.
2425 * @param array $settings Sitemap settings.
2426 * @return array{entries: iterable<string>, image_ns: bool}|null
2427 */
2428 private function stream_type_entries(string $type, array $settings): ?array {
2429 switch ($type) {
2430 case 'posts':
2431 return ['entries' => $this->collect_post_entries_iter(['post'], $settings), 'image_ns' => true];
2432
2433 case 'pages':
2434 // The homepage lives in the pages sitemap (parity with prior
2435 // output) and is emitted first; collect_post_entries_iter()
2436 // already excludes the static front page, so there's no duplicate.
2437 $entries = (function () use ($settings) {
2438 yield $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
2439 yield from $this->collect_post_entries_iter(['page'], $settings);
2440 })();
2441 return ['entries' => $entries, 'image_ns' => true];
2442
2443 case 'categories':
2444 return ['entries' => $this->collect_taxonomy_entries_iter('category', $settings), 'image_ns' => false];
2445
2446 case 'tags':
2447 return ['entries' => $this->collect_taxonomy_entries_iter('post_tag', $settings), 'image_ns' => false];
2448
2449 case 'products':
2450 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
2451 return ['entries' => $entries, 'image_ns' => true];
2452
2453 case 'product_categories':
2454 $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
2455 return ['entries' => $entries, 'image_ns' => false];
2456
2457 default:
2458 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2459 if (in_array($type, $custom_post_types, true)) {
2460 return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
2461 }
2462 return null;
2463 }
2464 }
2465
2466 /**
2467 * Write one sitemap page file and record it in the results + index child list.
2468 *
2469 * @since 1.14.0
2470 *
2471 * @param string $url Sitemap page URL
2472 * @param string $xml Sitemap XML
2473 * @param string $type Sitemap type (for reporting)
2474 * @param int $url_count Number of URLs in this page
2475 * @param array $results Results accumulator (by reference)
2476 * @param array $index_children Index child list (by reference)
2477 * @return void
2478 */
2479 private function write_sitemap_page(string $url, string $xml, string $type, int $url_count, array &$results, array &$index_children): void {
2480 $filename = basename(wp_parse_url($url, PHP_URL_PATH));
2481
2482 if ($this->save_sitemap_to_file($xml, $filename)) {
2483 $results['sitemaps_generated'][] = [
2484 'url' => $url,
2485 'type' => $type,
2486 'filename' => $filename,
2487 'url_count' => $url_count,
2488 ];
2489 $results['total_urls'] += $url_count;
2490 $index_children[] = ['url' => $url];
2491 } else {
2492 $results['errors'][] = "Failed to save sitemap: {$filename}";
2493 $results['success'] = false;
2494 }
2495 }
2496
2497 /**
2498 * Delete pagination files left over when a type shrinks to fewer pages.
2499 *
2500 * For a base URL like /sitemap-posts.xml, removes sitemap-posts-N.xml files
2501 * whose page number N exceeds the current page count. Page 1 (the base file,
2502 * which has no -N suffix) is never touched.
2503 *
2504 * @since 1.14.0
2505 *
2506 * @param string $base_url Base (page 1) sitemap URL
2507 * @param int $current_pages Number of pages generated this run
2508 * @return void
2509 */
2510 private function cleanup_stale_pages(string $base_url, int $current_pages): void {
2511 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2512 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2513 return;
2514 }
2515 $stem = $m[1];
2516
2517 global $wp_filesystem;
2518 if (!$wp_filesystem) {
2519 require_once ABSPATH . 'wp-admin/includes/file.php';
2520 WP_Filesystem();
2521 }
2522 if (!$wp_filesystem) {
2523 return;
2524 }
2525
2526 $candidates = glob(ABSPATH . $stem . '-*.xml') ?: [];
2527 foreach ($candidates as $path) {
2528 // Only delete numeric-suffixed pages beyond the current count.
2529 if (preg_match('/-(\d+)\.xml$/', basename($path), $mm) && (int) $mm[1] > $current_pages) {
2530 $wp_filesystem->delete($path);
2531 }
2532 }
2533 }
2534
2535 /**
2536 * Generate sitemap index XML from the list of child sitemap files produced
2537 * during generation (each already resolved to its final, possibly paginated,
2538 * URL).
2539 *
2540 * @since 1.0.0 (signature updated 1.14.0)
2541 * @param array $children Array of ['url' => string] child sitemap entries
2542 * @param array $settings Sitemap settings
2543 * @return string Sitemap index XML
2544 */
2545 private function generate_sitemap_index(array $children, array $settings): string {
2546 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
2547
2548 // Add XSL stylesheet only if styling is enabled
2549 if (!empty($settings['enable_styling'])) {
2550 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/sitemap-index.xsl') . '"?>' . "\n";
2551 }
2552 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
2553
2554 $site_url = home_url();
2555
2556 foreach ($children as $child) {
2557 $sitemap_url = $child['url'] ?? '';
2558 if ($sitemap_url === '') {
2559 continue;
2560 }
2561 if (!str_starts_with($sitemap_url, 'http')) {
2562 $sitemap_url = $site_url . $sitemap_url;
2563 }
2564
2565 $xml .= " <sitemap>\n";
2566 $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
2567 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
2568 $xml .= " </sitemap>\n";
2569 }
2570
2571 $xml .= '</sitemapindex>';
2572
2573 return $xml;
2574 }
2575
2576 /**
2577 * Count URLs in sitemap XML content
2578 *
2579 * @since 1.0.0
2580 * @param string $sitemap_xml Sitemap XML content
2581 * @return int Number of URLs
2582 */
2583 private function count_urls_in_xml(string $sitemap_xml): int {
2584 return substr_count($sitemap_xml, '<url>');
2585 }
2586
2587 /**
2588 * Validate and sanitize exclude posts input
2589 *
2590 * @since 1.0.0
2591 * @param string $exclude_posts Comma-separated post IDs
2592 * @return array Validated post IDs
2593 * @throws InvalidArgumentException If validation fails
2594 */
2595 private function validate_exclude_posts(string $exclude_posts): array {
2596 if (empty($exclude_posts)) {
2597 return [];
2598 }
2599
2600 // Limit input length to prevent DoS
2601 if (strlen($exclude_posts) > 1000) {
2602 throw new InvalidArgumentException('Exclude posts list too long (max 1000 characters)');
2603 }
2604
2605 // Validate comma-separated integers only
2606 if (!preg_match('/^[\d,\s]+$/', $exclude_posts)) {
2607 throw new InvalidArgumentException('Exclude posts must contain only numbers and commas');
2608 }
2609
2610 $ids = array_map('intval', array_filter(explode(',', $exclude_posts)));
2611
2612 // Limit number of exclusions to prevent performance issues
2613 if (count($ids) > 100) {
2614 throw new InvalidArgumentException('Too many posts to exclude (max 100)');
2615 }
2616
2617 return array_filter($ids, function($id) {
2618 return $id > 0; // Only positive integers
2619 });
2620 }
2621
2622 /**
2623 * Validate and sanitize exclude terms input
2624 *
2625 * @since 1.0.0
2626 * @param string $exclude_terms Comma-separated term IDs
2627 * @return array Validated term IDs
2628 * @throws InvalidArgumentException If validation fails
2629 */
2630 private function validate_exclude_terms(string $exclude_terms): array {
2631 if (empty($exclude_terms)) {
2632 return [];
2633 }
2634
2635 // Limit input length to prevent DoS
2636 if (strlen($exclude_terms) > 1000) {
2637 throw new InvalidArgumentException('Exclude terms list too long (max 1000 characters)');
2638 }
2639
2640 // Validate comma-separated integers only
2641 if (!preg_match('/^[\d,\s]+$/', $exclude_terms)) {
2642 throw new InvalidArgumentException('Exclude terms must contain only numbers and commas');
2643 }
2644
2645 $ids = array_map('intval', array_filter(explode(',', $exclude_terms)));
2646
2647 // Limit number of exclusions to prevent performance issues
2648 if (count($ids) > 100) {
2649 throw new InvalidArgumentException('Too many terms to exclude (max 100)');
2650 }
2651
2652 return array_filter($ids, function($id) {
2653 return $id > 0; // Only positive integers
2654 });
2655 }
2656
2657 /**
2658 * Validate and sanitize custom URL pattern
2659 *
2660 * @since 1.0.0
2661 * @param string $pattern Custom URL pattern
2662 * @return string Validated and sanitized pattern
2663 * @throws InvalidArgumentException If validation fails
2664 */
2665 private function validate_custom_url_pattern(string $pattern): string {
2666 if (empty($pattern)) {
2667 return 'sitemap-{type}.xml';
2668 }
2669
2670 // Remove any HTML/script tags to prevent XSS
2671 $pattern = wp_strip_all_tags($pattern);
2672
2673 // Limit pattern length
2674 if (strlen($pattern) > 100) {
2675 throw new InvalidArgumentException('URL pattern too long (max 100 characters)');
2676 }
2677
2678 // Validate pattern format - only allow safe characters
2679 if (!preg_match('/^[a-zA-Z0-9\-_{}\.]+$/', $pattern)) {
2680 throw new InvalidArgumentException('URL pattern contains invalid characters. Only letters, numbers, hyphens, underscores, dots, and {type} are allowed');
2681 }
2682
2683 // Ensure it contains {type} placeholder
2684 if (strpos($pattern, '{type}') === false) {
2685 throw new InvalidArgumentException('URL pattern must contain {type} placeholder');
2686 }
2687
2688 // Ensure it ends with .xml
2689 if (!str_ends_with($pattern, '.xml')) {
2690 $pattern .= '.xml';
2691 }
2692
2693 return sanitize_file_name($pattern);
2694 }
2695
2696 /**
2697 * Validate sitemap URL
2698 *
2699 * @since 1.0.0
2700 * @param string $url Sitemap URL
2701 * @return string Validated and sanitized URL
2702 * @throws InvalidArgumentException If validation fails
2703 */
2704 private function validate_sitemap_url(string $url): string {
2705 if (empty($url)) {
2706 throw new InvalidArgumentException('Sitemap URL cannot be empty');
2707 }
2708
2709 // Remove leading/trailing whitespace
2710 $url = trim($url);
2711
2712 // Limit URL length
2713 if (strlen($url) > 200) {
2714 throw new InvalidArgumentException('Sitemap URL too long (max 200 characters)');
2715 }
2716
2717 // Ensure it starts with /
2718 if (!str_starts_with($url, '/')) {
2719 $url = '/' . $url;
2720 }
2721
2722 // Validate URL path format
2723 if (!preg_match('/^\/[a-zA-Z0-9\-_\/\.]+\.xml$/', $url)) {
2724 throw new InvalidArgumentException('Invalid sitemap URL format. Must be a valid path ending with .xml');
2725 }
2726
2727 // Prevent directory traversal
2728 if (strpos($url, '..') !== false) {
2729 throw new InvalidArgumentException('Directory traversal not allowed in sitemap URL');
2730 }
2731
2732 // Prevent multiple slashes
2733 $url = preg_replace('/\/+/', '/', $url);
2734
2735 return sanitize_url($url);
2736 }
2737
2738 /**
2739 * Validate links per sitemap setting
2740 *
2741 * @since 1.0.0
2742 * @param mixed $links_per_sitemap Links per sitemap value
2743 * @return int Validated links per sitemap
2744 * @throws InvalidArgumentException If validation fails
2745 */
2746 private function validate_links_per_sitemap($links_per_sitemap): int {
2747 $links = intval($links_per_sitemap);
2748
2749 if ($links < 1) {
2750 throw new InvalidArgumentException('Links per sitemap must be at least 1');
2751 }
2752
2753 if ($links > 50000) {
2754 throw new InvalidArgumentException('Links per sitemap cannot exceed 50,000');
2755 }
2756
2757 return $links;
2758 }
2759
2760 /**
2761 * Validate sitemap filename for security
2762 *
2763 * @since 1.0.0
2764 * @param string $filename Filename to validate
2765 * @return string Validated and sanitized filename
2766 * @throws InvalidArgumentException If validation fails
2767 */
2768 private function validate_sitemap_filename(string $filename): string {
2769 if (empty($filename)) {
2770 return 'sitemap.xml';
2771 }
2772
2773 // Remove any path components to prevent directory traversal
2774 $filename = basename($filename);
2775
2776 // Limit filename length
2777 if (strlen($filename) > 100) {
2778 throw new InvalidArgumentException('Filename too long (max 100 characters)');
2779 }
2780
2781 // Validate filename format - only allow safe characters
2782 if (!preg_match('/^[a-zA-Z0-9\-_\.]+$/', $filename)) {
2783 throw new InvalidArgumentException('Filename contains invalid characters. Only letters, numbers, hyphens, underscores, and dots are allowed');
2784 }
2785
2786 // Prevent directory traversal attempts
2787 if (strpos($filename, '..') !== false) {
2788 throw new InvalidArgumentException('Directory traversal not allowed in filename');
2789 }
2790
2791 // Ensure it ends with .xml
2792 if (!str_ends_with($filename, '.xml')) {
2793 $filename .= '.xml';
2794 }
2795
2796 // Additional sanitization
2797 $filename = sanitize_file_name($filename);
2798
2799 // Final security check - ensure it's still a valid XML filename
2800 if (!preg_match('/^[a-zA-Z0-9\-_]+\.xml$/', $filename)) {
2801 throw new InvalidArgumentException('Invalid XML filename after sanitization');
2802 }
2803
2804 return $filename;
2805 }
2806 }
2807