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

1,675 lines 55.6 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 /**
19 * Sitemap Generator Class
20 *
21 * Generates and manages XML sitemaps for search engine optimization.
22 * Provides dynamic sitemap generation with content filtering, priority
23 * calculation, and change frequency optimization.
24 *
25 * @since 1.0.0
26 */
27 class Sitemap_Generator extends Abstract_SEO_Manager {
28
29 /**
30 * Supported sitemap types
31 *
32 * @since 1.0.0
33 * @var array
34 */
35 private array $sitemap_types = [
36 'posts' => [
37 'name' => 'Posts',
38 'post_types' => ['post'],
39 'priority' => 0.8,
40 'changefreq' => 'weekly'
41 ],
42 'pages' => [
43 'name' => 'Pages',
44 'post_types' => ['page'],
45 'priority' => 0.9,
46 'changefreq' => 'monthly'
47 ],
48 'categories' => [
49 'name' => 'Categories',
50 'taxonomy' => 'category',
51 'priority' => 0.6,
52 'changefreq' => 'weekly'
53 ],
54 'tags' => [
55 'name' => 'Tags',
56 'taxonomy' => 'post_tag',
57 'priority' => 0.4,
58 'changefreq' => 'monthly'
59 ]
60 ];
61
62 /**
63 * Constructor
64 *
65 * @since 1.0.0
66 */
67 public function __construct() {
68 parent::__construct('sitemap');
69
70 // Initialize auto-generation hooks
71 $this->init_auto_generation_hooks();
72 }
73
74 /**
75 * Initialize WordPress hooks for auto-generation
76 *
77 * @since 1.0.0
78 * @return void
79 */
80 private function init_auto_generation_hooks(): void {
81 // Content change hooks - use priority 20 to run after other plugins
82 add_action('save_post', [$this, 'handle_content_change'], 20, 2);
83 add_action('delete_post', [$this, 'handle_content_deletion'], 20);
84 add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
85 add_action('untrash_post', [$this, 'handle_content_change_by_id'], 20);
86
87 // Taxonomy change hooks
88 add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
89 add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
90 add_action('delete_term', [$this, 'handle_taxonomy_change'], 20, 3);
91
92 // Debounced regeneration to prevent multiple rapid-fire generations
93 add_action('thinkrank_regenerate_sitemap', [$this, 'auto_regenerate_sitemap']);
94 }
95
96 /**
97 * Generate XML sitemap
98 *
99 * @since 1.0.0
100 *
101 * @param array $options Sitemap generation options
102 * @return string XML sitemap content
103 */
104 public function generate_sitemap(array $options = []): string {
105 $settings = $this->get_settings('site');
106
107 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
108
109 // Add XSL stylesheet only if styling is enabled
110 if (!empty($settings['enable_styling'])) {
111 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap.xsl') . '"?>' . "\n";
112 }
113
114 // Add image namespace if images are enabled
115 if (!empty($settings['include_images'])) {
116 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
117 } else {
118 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
119 }
120
121 // Add homepage
122 $xml .= $this->generate_url_entry(home_url('/'), gmdate('c'), 1.0, 'daily');
123
124 // MEMORY-EFFICIENT: Generate posts using chunked processing
125 $enabled_post_types = $this->get_enabled_post_types($settings);
126 if (!empty($enabled_post_types)) {
127 $xml .= $this->generate_posts_chunked($enabled_post_types, $settings);
128 }
129
130 // OPTIMIZED: Get all enabled taxonomies and fetch in single query
131 $enabled_taxonomies = $this->get_enabled_taxonomies($settings);
132 if (!empty($enabled_taxonomies)) {
133 $all_taxonomy_data = $this->fetch_taxonomies_optimized($enabled_taxonomies, $settings);
134
135 // Process each taxonomy's data
136 foreach ($enabled_taxonomies as $taxonomy) {
137 if (!empty($all_taxonomy_data[$taxonomy])) {
138 $xml .= $this->process_taxonomies_to_xml($all_taxonomy_data[$taxonomy], $taxonomy, $settings);
139 }
140 }
141 }
142
143 $xml .= '</urlset>';
144
145 return $xml;
146 }
147
148 /**
149 * Validate SEO settings (implements interface)
150 *
151 * @since 1.0.0
152 *
153 * @param array $settings Settings array to validate
154 * @return array Validation results
155 */
156 public function validate_settings(array $settings): array {
157 $validation = [
158 'valid' => true,
159 'errors' => [],
160 'warnings' => [],
161 'suggestions' => []
162 ];
163
164 try {
165 // Validate exclude_posts
166 if (!empty($settings['exclude_posts'])) {
167 $this->validate_exclude_posts($settings['exclude_posts']);
168 }
169
170 // Validate exclude_terms
171 if (!empty($settings['exclude_terms'])) {
172 $this->validate_exclude_terms($settings['exclude_terms']);
173 }
174
175 // Validate custom_url_pattern
176 if (!empty($settings['custom_url_pattern'])) {
177 $this->validate_custom_url_pattern($settings['custom_url_pattern']);
178 }
179
180 // Validate sitemap_urls
181 if (!empty($settings['sitemap_urls']) && is_array($settings['sitemap_urls'])) {
182 foreach ($settings['sitemap_urls'] as $sitemap) {
183 if (!empty($sitemap['url'])) {
184 $this->validate_sitemap_url($sitemap['url']);
185 }
186 }
187 }
188
189 // Validate numeric settings
190 if (isset($settings['links_per_sitemap'])) {
191 $this->validate_links_per_sitemap($settings['links_per_sitemap']);
192 }
193
194 } catch (InvalidArgumentException $e) {
195 $validation['valid'] = false;
196 $validation['errors'][] = $e->getMessage();
197 }
198
199 return $validation;
200 }
201
202 /**
203 * Get output data for frontend rendering (implements interface)
204 *
205 * @since 1.0.0
206 *
207 * @param string $context_type The context type
208 * @param int|null $context_id Optional. Context ID
209 * @return array Output data ready for frontend rendering
210 */
211 public function get_output_data(string $context_type, ?int $context_id): array {
212 $settings = $this->get_settings($context_type, $context_id);
213
214 return [
215 'sitemap_url' => home_url('/sitemap.xml'),
216 'enabled' => $settings['enabled'] ?? true,
217 'last_generated' => $settings['last_generated'] ?? '',
218 'total_urls' => $this->count_sitemap_urls($settings)
219 ];
220 }
221
222 /**
223 * Get default settings for a context type (implements interface)
224 *
225 * @since 1.0.0
226 *
227 * @param string $context_type The context type to get defaults for
228 * @return array Default settings array
229 */
230 public function get_default_settings(string $context_type): array {
231 return [
232 // Core settings
233 'enabled' => true,
234
235 // Multiple Sitemap URLs
236 'sitemap_urls' => [
237 [
238 'url' => '/sitemap.xml',
239 'type' => 'general',
240 'enabled' => true,
241 'last_checked' => null,
242 'status' => 'unknown'
243 ]
244 ],
245 'use_sitemap_index' => false,
246
247 // General Settings
248 'links_per_sitemap' => 1000,
249 'include_images' => true,
250 'include_featured_images' => false,
251 'auto_generate' => true,
252 'ping_search_engines' => true,
253
254 // Content Inclusion (backward compatibility)
255 'include_posts' => true,
256 'include_pages' => true,
257 'include_categories' => true,
258 'include_tags' => false,
259
260 // Content Filtering
261 'exclude_posts' => '',
262 'exclude_terms' => '',
263 'exclude_password_protected' => true,
264 'exclude_private_posts' => true,
265
266 // Advanced Options
267 'enable_styling' => true,
268 'custom_url_pattern' => 'sitemap-{type}.xml',
269
270 // Generation tracking
271 'last_generated' => ''
272 ];
273 }
274
275 /**
276 * Get settings schema definition (implements interface)
277 *
278 * @since 1.0.0
279 *
280 * @param string $context_type The context type to get schema for
281 * @return array Settings schema definition
282 */
283 public function get_settings_schema(string $context_type): array {
284 return [
285 'enabled' => [
286 'type' => 'boolean',
287 'title' => 'Enable Sitemap',
288 'description' => 'Generate XML sitemap for search engines',
289 'default' => true
290 ],
291 'include_posts' => [
292 'type' => 'boolean',
293 'title' => 'Include Posts',
294 'description' => 'Include blog posts in sitemap',
295 'default' => true
296 ],
297 'include_pages' => [
298 'type' => 'boolean',
299 'title' => 'Include Pages',
300 'description' => 'Include static pages in sitemap',
301 'default' => true
302 ],
303 'include_categories' => [
304 'type' => 'boolean',
305 'title' => 'Include Categories',
306 'description' => 'Include category pages in sitemap',
307 'default' => true
308 ],
309 'include_tags' => [
310 'type' => 'boolean',
311 'title' => 'Include Tags',
312 'description' => 'Include tag pages in sitemap',
313 'default' => false
314 ],
315
316 'auto_generate' => [
317 'type' => 'boolean',
318 'title' => 'Auto Generate',
319 'description' => 'Automatically regenerate sitemap when content changes',
320 'default' => true
321 ],
322 'ping_search_engines' => [
323 'type' => 'boolean',
324 'title' => 'Ping Search Engines',
325 'description' => 'Notify Google and Bing when sitemap is updated',
326 'default' => true
327 ],
328 'last_generated' => [
329 'type' => 'string',
330 'title' => 'Last Generated',
331 'description' => 'Timestamp of last sitemap generation',
332 'default' => ''
333 ]
334 ];
335 }
336
337 /**
338 * Generate URL entry for sitemap
339 *
340 * @since 1.0.0
341 *
342 * @param string $url URL
343 * @param string $lastmod Last modification date
344 * @param float $priority Priority (0.0 to 1.0)
345 * @param string $changefreq Change frequency
346 * @param array $images Optional array of image data
347 * @return string XML URL entry
348 */
349 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
350 $xml = " <url>\n";
351 $xml .= " <loc>" . esc_url($url) . "</loc>\n";
352 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
353 $xml .= " <priority>" . number_format($priority, 1) . "</priority>\n";
354 $xml .= " <changefreq>" . esc_html($changefreq) . "</changefreq>\n";
355
356 // Add image entries if provided
357 foreach ($images as $image) {
358 $xml .= " <image:image>\n";
359 $xml .= " <image:loc>" . esc_url($image['url']) . "</image:loc>\n";
360
361 if (!empty($image['title'])) {
362 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
363 }
364
365 if (!empty($image['alt'])) {
366 $xml .= " <image:caption>" . esc_html($image['alt']) . "</image:caption>\n";
367 }
368
369 $xml .= " </image:image>\n";
370 }
371
372 $xml .= " </url>\n";
373
374 return $xml;
375 }
376
377 /**
378 * Generate post entries for sitemap (MEMORY-EFFICIENT VERSION)
379 *
380 * @since 1.0.0
381 *
382 * @param string $post_type Post type
383 * @param array $settings Sitemap settings
384 * @return string XML entries
385 */
386 private function generate_post_entries(string $post_type, array $settings): string {
387 // Use memory-efficient chunked processing for single post type
388 return $this->generate_posts_chunked([$post_type], $settings);
389 }
390
391 /**
392 * Generate posts XML using memory-efficient chunked processing
393 *
394 * @since 1.0.0
395 *
396 * @param array $post_types Array of post types to fetch
397 * @param array $settings Sitemap settings
398 * @return string XML entries for all posts
399 */
400 private function generate_posts_chunked(array $post_types, array $settings): string {
401 if (empty($post_types)) {
402 return '';
403 }
404
405 // Parse and validate exclude_posts setting
406 $exclude_ids = [];
407 if (!empty($settings['exclude_posts'])) {
408 try {
409 $exclude_ids = $this->validate_exclude_posts($settings['exclude_posts']);
410 } catch (InvalidArgumentException $e) {
411 // Continue with empty array on validation failure - error details available in exception
412 }
413 }
414
415 // Use links_per_sitemap setting to limit posts per sitemap
416 $links_per_sitemap = !empty($settings['links_per_sitemap']) ?
417 intval($settings['links_per_sitemap']) : 1000;
418
419 // Ensure value is within reasonable bounds
420 $links_per_sitemap = max(1, min(50000, $links_per_sitemap));
421
422 $xml = '';
423 $chunk_size = 500; // Process 500 posts at a time
424 $offset = 0;
425 $total_processed = 0;
426
427 do {
428 // Fetch posts in chunks to prevent memory issues
429 $posts = get_posts([
430 'post_type' => $post_types,
431 'post_status' => 'publish',
432 'numberposts' => $chunk_size,
433 'offset' => $offset,
434 'exclude' => $exclude_ids,
435 'orderby' => 'post_type post_date',
436 'order' => 'ASC DESC'
437 ]);
438
439 // Process each post in the chunk
440 foreach ($posts as $post) {
441 if ($total_processed >= $links_per_sitemap) {
442 break 2; // Exit both loops when limit reached
443 }
444
445 if ($this->should_include_in_sitemap($post, $settings)) {
446 $url = get_permalink($post);
447 $lastmod = gmdate('c', strtotime($post->post_modified_gmt));
448 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
449 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
450 $images = $this->extract_post_images($post, $settings);
451
452 $xml .= $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
453 $total_processed++;
454 }
455 }
456
457 $offset += $chunk_size;
458
459 // Store count before freeing memory
460 $posts_count = count($posts);
461
462 // Free memory after each chunk
463 unset($posts);
464
465 } while ($posts_count === $chunk_size && $total_processed < $links_per_sitemap);
466
467 return $xml;
468 }
469
470
471
472 /**
473 * Extract images from post for sitemap
474 *
475 * @since 1.0.0
476 *
477 * @param \WP_Post $post Post object
478 * @param array $settings Sitemap settings
479 * @return array Array of image data
480 */
481 private function extract_post_images(\WP_Post $post, array $settings): array {
482 $images = [];
483
484 // Skip if images are disabled
485 if (empty($settings['include_images'])) {
486 return $images;
487 }
488
489 // Get featured image if enabled
490 if (!empty($settings['include_featured_images'])) {
491 $featured_image = $this->get_featured_image($post->ID);
492 if ($featured_image) {
493 $images[] = $featured_image;
494 }
495 }
496
497 // Extract images from content
498 $content_images = $this->extract_content_images($post->post_content);
499 $images = array_merge($images, $content_images);
500
501 // Remove duplicates based on URL
502 $unique_images = [];
503 $seen_urls = [];
504 foreach ($images as $image) {
505 if (!in_array($image['url'], $seen_urls)) {
506 $unique_images[] = $image;
507 $seen_urls[] = $image['url'];
508 }
509 }
510
511 return $unique_images;
512 }
513
514 /**
515 * Get featured image data
516 *
517 * @since 1.0.0
518 *
519 * @param int $post_id Post ID
520 * @return array|null Featured image data or null
521 */
522 private function get_featured_image(int $post_id): ?array {
523 $thumbnail_id = get_post_thumbnail_id($post_id);
524 if (!$thumbnail_id) {
525 return null;
526 }
527
528 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
529 if (!$image_url) {
530 return null;
531 }
532
533 $image_title = get_the_title($thumbnail_id);
534 $image_alt = get_post_meta($thumbnail_id, '_wp_attachment_image_alt', true);
535
536 return [
537 'url' => $image_url,
538 'title' => $image_title ?: '',
539 'alt' => $image_alt ?: ''
540 ];
541 }
542
543 /**
544 * Extract images from post content
545 *
546 * @since 1.0.0
547 *
548 * @param string $content Post content
549 * @return array Array of image data
550 */
551 private function extract_content_images(string $content): array {
552 $images = [];
553
554 // Find all img tags in content
555 preg_match_all('/<img[^>]+>/i', $content, $img_tags);
556
557 foreach ($img_tags[0] as $img_tag) {
558 // Extract src attribute
559 if (preg_match('/src=["\']([^"\']+)["\']/', $img_tag, $src_match)) {
560 $image_url = $src_match[1];
561
562 // Skip if not a valid URL or external image
563 if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
564 continue;
565 }
566
567 // Extract title and alt attributes
568 $title = '';
569 $alt = '';
570
571 if (preg_match('/title=["\']([^"\']*)["\']/', $img_tag, $title_match)) {
572 $title = $title_match[1];
573 }
574
575 if (preg_match('/alt=["\']([^"\']*)["\']/', $img_tag, $alt_match)) {
576 $alt = $alt_match[1];
577 }
578
579 $images[] = [
580 'url' => $image_url,
581 'title' => $title,
582 'alt' => $alt
583 ];
584 }
585 }
586
587 return $images;
588 }
589
590 /**
591 * Generate taxonomy entries for sitemap (OPTIMIZED VERSION)
592 *
593 * @since 1.0.0
594 *
595 * @param string $taxonomy Taxonomy name
596 * @param array $settings Sitemap settings
597 * @return string XML entries
598 */
599 private function generate_taxonomy_entries(string $taxonomy, array $settings): string {
600 // Use optimized batch fetching for single taxonomy
601 $taxonomy_data = $this->fetch_taxonomies_optimized([$taxonomy], $settings);
602
603 if (empty($taxonomy_data[$taxonomy])) {
604 return '';
605 }
606
607 return $this->process_taxonomies_to_xml($taxonomy_data[$taxonomy], $taxonomy, $settings);
608 }
609
610 /**
611 * Get enabled taxonomies based on settings
612 *
613 * @since 1.0.0
614 * @param array $settings Sitemap settings
615 * @return array Array of enabled taxonomies
616 */
617 private function get_enabled_taxonomies(array $settings): array {
618 $taxonomies = [];
619
620 // Core taxonomies based on settings
621 if (!empty($settings['include_categories'])) { // �
622 Evidence-based field name
623 $taxonomies[] = 'category';
624 }
625
626 if (!empty($settings['include_tags'])) { // �
627 Evidence-based field name
628 $taxonomies[] = 'post_tag';
629 }
630
631 // Auto-detect public custom taxonomies
632 $custom_taxonomies = get_taxonomies([
633 'public' => true,
634 '_builtin' => false
635 ], 'names');
636
637 foreach ($custom_taxonomies as $taxonomy) {
638 if ($this->should_include_taxonomy($taxonomy)) {
639 $taxonomies[] = $taxonomy;
640 }
641 }
642
643 return array_unique($taxonomies);
644 }
645
646
647
648 /**
649 * Fetch taxonomies using optimized combined query
650 *
651 * @since 1.0.0
652 *
653 * @param array $taxonomies Array of taxonomy names to fetch
654 * @param array $settings Sitemap settings
655 * @return array Grouped terms by taxonomy
656 */
657 private function fetch_taxonomies_optimized(array $taxonomies, array $settings): array {
658 if (empty($taxonomies)) {
659 return [];
660 }
661
662 // Parse and validate exclude_terms setting
663 $exclude_term_ids = [];
664 if (!empty($settings['exclude_terms'])) {
665 try {
666 $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
667 } catch (InvalidArgumentException $e) {
668 // Continue with empty array on validation failure - error details available in exception
669 }
670 }
671
672 // Determine hide_empty setting based on taxonomies
673 $hide_empty = true;
674 foreach ($taxonomies as $taxonomy) {
675 // For product categories, don't hide empty categories since they might not have products yet
676 if ($taxonomy === 'product_cat') {
677 $hide_empty = false;
678 break;
679 }
680 }
681
682 // OPTIMIZED: Single query for multiple taxonomies
683 $all_terms = get_terms([
684 'taxonomy' => $taxonomies, // �
685 Multiple taxonomies in single query
686 'hide_empty' => $hide_empty,
687 'exclude' => $exclude_term_ids,
688 'orderby' => 'taxonomy count',
689 'order' => 'ASC DESC' // Order by taxonomy ASC, then count DESC
690 ]);
691
692 if (is_wp_error($all_terms)) {
693 return [];
694 }
695
696 // Group terms by taxonomy
697 return $this->group_terms_by_taxonomy($all_terms);
698 }
699
700 /**
701 * Group terms by taxonomy
702 *
703 * @since 1.0.0
704 *
705 * @param array $terms Array of term objects
706 * @return array Grouped terms by taxonomy
707 */
708 private function group_terms_by_taxonomy(array $terms): array {
709 $grouped = [];
710
711 foreach ($terms as $term) {
712 $taxonomy = $term->taxonomy; // �
713 Evidence-based property name
714
715 if (!isset($grouped[$taxonomy])) {
716 $grouped[$taxonomy] = [];
717 }
718
719 $grouped[$taxonomy][] = $term;
720 }
721
722 return $grouped;
723 }
724
725 /**
726 * Process taxonomy terms to XML entries
727 *
728 * @since 1.0.0
729 *
730 * @param array $terms Array of term objects
731 * @param string $taxonomy Taxonomy name
732 * @param array $settings Sitemap settings
733 * @return string XML entries
734 */
735 private function process_taxonomies_to_xml(array $terms, string $taxonomy, array $settings): string {
736 $xml = '';
737
738 foreach ($terms as $term) {
739 $url = get_term_link($term);
740 if (!is_wp_error($url)) {
741 $lastmod = gmdate('c');
742 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
743 $changefreq = 'weekly';
744
745 $xml .= $this->generate_url_entry($url, $lastmod, $priority, $changefreq);
746 }
747 }
748
749 return $xml;
750 }
751
752 /**
753 * Calculate intelligent priority based on content factors
754 *
755 * @since 1.0.0
756 *
757 * @param \WP_Post $post Post object
758 * @param string $post_type Post type
759 * @return float Priority value between 0.1 and 1.0
760 */
761 private function calculate_intelligent_priority(\WP_Post $post, string $post_type): float {
762 $base_priority = $post_type === 'page' ? 0.9 : 0.8;
763
764 // Factors that can adjust priority
765 $adjustments = 0;
766
767 // Recent content gets higher priority
768 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
769 if ($days_old < 30) {
770 $adjustments += 0.1; // Recent content boost
771 } elseif ($days_old > 365) {
772 $adjustments -= 0.1; // Older content penalty
773 }
774
775 // Content length factor
776 $content_length = strlen(wp_strip_all_tags($post->post_content));
777 if ($content_length > 2000) {
778 $adjustments += 0.05; // Comprehensive content boost
779 } elseif ($content_length < 500) {
780 $adjustments -= 0.1; // Thin content penalty
781 }
782
783 // Special page types get higher priority
784 if ($post_type === 'page') {
785 $page_template = get_page_template_slug($post->ID);
786 if (in_array($page_template, ['page-home.php', 'front-page.php']) ||
787 $post->ID == get_option('page_on_front')) {
788 $base_priority = 1.0; // Homepage gets maximum priority
789 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'])) {
790 $adjustments += 0.05; // Important pages boost
791 }
792 }
793
794 // Ensure priority stays within valid range
795 $final_priority = max(0.1, min(1.0, $base_priority + $adjustments));
796
797 return round($final_priority, 1);
798 }
799
800 /**
801 * Calculate change frequency based on content type and age
802 *
803 * @since 1.0.0
804 *
805 * @param \WP_Post $post Post object
806 * @param string $post_type Post type
807 * @return string Change frequency
808 */
809 private function calculate_change_frequency(\WP_Post $post, string $post_type): string {
810 // Pages typically change less frequently
811 if ($post_type === 'page') {
812 $page_template = get_page_template_slug($post->ID);
813 if ($post->ID == get_option('page_on_front')) {
814 return 'daily'; // Homepage changes frequently
815 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'])) {
816 return 'monthly'; // Static pages change monthly
817 }
818 return 'yearly'; // Other pages change rarely
819 }
820
821 // Posts frequency based on age and type
822 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
823
824 if ($days_old < 7) {
825 return 'daily'; // Very recent posts
826 } elseif ($days_old < 30) {
827 return 'weekly'; // Recent posts
828 } elseif ($days_old < 365) {
829 return 'monthly'; // Older posts
830 }
831
832 return 'yearly'; // Very old posts
833 }
834
835 /**
836 * Determine if content should be included in sitemap
837 *
838 * @since 1.0.0
839 *
840 * @param \WP_Post $post Post object
841 * @param array $settings Sitemap settings
842 * @return bool Whether to include in sitemap
843 */
844 private function should_include_in_sitemap(\WP_Post $post, array $settings): bool {
845 // Respect user setting for password protected content
846 if (!empty($post->post_password) && !empty($settings['exclude_password_protected'])) {
847 return false;
848 }
849
850 // Respect user setting for private posts
851 if ($post->post_status === 'private' && !empty($settings['exclude_private_posts'])) {
852 return false;
853 }
854
855 // Skip very short content (likely test/placeholder)
856 $content_length = strlen(wp_strip_all_tags($post->post_content));
857 if ($content_length < 100) {
858 return false;
859 }
860
861 // Skip posts with test/demo indicators in title (refined filtering)
862 $title_indicators = ['test', 'demo', 'sample', 'placeholder'];
863 $title_lower = strtolower($post->post_title);
864
865 foreach ($title_indicators as $indicator) {
866 if (strpos($title_lower, $indicator) !== false) {
867 return false;
868 }
869 }
870
871 // Skip content with obvious placeholder text
872 $content_indicators = ['lorem ipsum'];
873 $content_lower = strtolower($post->post_content);
874
875 foreach ($content_indicators as $indicator) {
876 if (strpos($content_lower, $indicator) !== false) {
877 return false;
878 }
879 }
880
881 // Skip drafts (but allow private posts if user setting permits)
882 if (!in_array($post->post_status, ['publish', 'private'])) {
883 return false;
884 }
885
886 // Check if post is set to noindex via meta
887 $noindex = get_post_meta($post->ID, '_thinkrank_noindex', true);
888 if ($noindex === '1' || $noindex === 'true') {
889 return false;
890 }
891
892 return true;
893 }
894
895 /**
896 * Count total URLs in sitemap
897 *
898 * @since 1.0.0
899 *
900 * @param array $settings Sitemap settings
901 * @return int Total URL count
902 */
903 private function count_sitemap_urls(array $settings): int {
904 $count = 1; // Homepage
905
906 if (!empty($settings['include_posts'])) {
907 $count += wp_count_posts('post')->publish;
908 }
909
910 if (!empty($settings['include_pages'])) {
911 $count += wp_count_posts('page')->publish;
912 }
913
914 if (!empty($settings['include_categories'])) {
915 $count += wp_count_terms(['taxonomy' => 'category', 'hide_empty' => true]);
916 }
917
918 if (!empty($settings['include_tags'])) {
919 $count += wp_count_terms(['taxonomy' => 'post_tag', 'hide_empty' => true]);
920 }
921
922 return $count;
923 }
924
925 /**
926 * Handle content changes for auto-generation
927 *
928 * @since 1.0.0
929 * @param int $post_id Post ID
930 * @param \WP_Post $post Post object
931 * @return void
932 */
933 public function handle_content_change(int $post_id, \WP_Post $post): void {
934 // Skip if auto-generation is disabled
935 if (!$this->should_auto_generate()) {
936 return;
937 }
938
939 // Skip autosaves and revisions
940 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
941 return;
942 }
943
944 // Only process published content
945 if ($post->post_status !== 'publish') {
946 return;
947 }
948
949 // Check if this post type should trigger regeneration
950 if (!$this->should_include_post_type($post->post_type)) {
951 return;
952 }
953
954 // Schedule debounced regeneration
955 $this->schedule_debounced_regeneration();
956 }
957
958 /**
959 * Handle content deletion for auto-generation
960 *
961 * @since 1.0.0
962 * @param int $post_id Post ID
963 * @return void
964 */
965 public function handle_content_deletion(int $post_id): void {
966 // Skip if auto-generation is disabled
967 if (!$this->should_auto_generate()) {
968 return;
969 }
970
971 $post = get_post($post_id);
972 if (!$post) {
973 return;
974 }
975
976 // Check if this post type should trigger regeneration
977 if (!$this->should_include_post_type($post->post_type)) {
978 return;
979 }
980
981 // Schedule debounced regeneration
982 $this->schedule_debounced_regeneration();
983 }
984
985 /**
986 * Handle content change by ID (for untrash, etc.)
987 *
988 * @since 1.0.0
989 * @param int $post_id Post ID
990 * @return void
991 */
992 public function handle_content_change_by_id(int $post_id): void {
993 $post = get_post($post_id);
994 if ($post) {
995 $this->handle_content_change($post_id, $post);
996 }
997 }
998
999 /**
1000 * Handle taxonomy changes for auto-generation
1001 *
1002 * @since 1.0.0
1003 * @param int $term_id Term ID
1004 * @param int $tt_id Term taxonomy ID
1005 * @param string $taxonomy Taxonomy slug
1006 * @return void
1007 */
1008 public function handle_taxonomy_change(int $term_id, int $tt_id, string $taxonomy): void {
1009 // Skip if auto-generation is disabled
1010 if (!$this->should_auto_generate()) {
1011 return;
1012 }
1013
1014 // Check if this taxonomy should trigger regeneration
1015 if (!$this->should_include_taxonomy($taxonomy)) {
1016 return;
1017 }
1018
1019 // Schedule debounced regeneration
1020 $this->schedule_debounced_regeneration();
1021 }
1022
1023 /**
1024 * Check if auto-generation is enabled
1025 *
1026 * @since 1.0.0
1027 * @return bool True if auto-generation is enabled
1028 */
1029 private function should_auto_generate(): bool {
1030 $settings = $this->get_settings('site');
1031
1032 // Check if sitemap is enabled
1033 if (empty($settings['enabled'])) {
1034 return false;
1035 }
1036
1037 // Check if auto-generation is enabled
1038 return !empty($settings['auto_generate']);
1039 }
1040
1041 /**
1042 * Get enabled post types based on settings
1043 *
1044 * @since 1.0.0
1045 * @param array $settings Sitemap settings
1046 * @return array Array of enabled post types
1047 */
1048 private function get_enabled_post_types(array $settings): array {
1049 $post_types = [];
1050
1051 // Core post types based on settings
1052 if (!empty($settings['include_posts'])) {
1053 $post_types[] = 'post';
1054 }
1055
1056 if (!empty($settings['include_pages'])) {
1057 $post_types[] = 'page';
1058 }
1059
1060 // Auto-detect public custom post types that should be included
1061 $custom_post_types = get_post_types([
1062 'public' => true,
1063 '_builtin' => false
1064 ], 'names');
1065
1066 foreach ($custom_post_types as $post_type) {
1067 if ($this->should_include_post_type($post_type)) {
1068 $post_types[] = $post_type;
1069 }
1070 }
1071
1072 return array_unique($post_types);
1073 }
1074
1075 /**
1076 * Check if post type should trigger regeneration
1077 *
1078 * @since 1.0.0
1079 * @param string $post_type Post type
1080 * @return bool True if post type should trigger regeneration
1081 */
1082 private function should_include_post_type(string $post_type): bool {
1083 // Include all public post types (presets control which sitemaps are created)
1084 $post_type_obj = get_post_type_object($post_type);
1085 return $post_type_obj && $post_type_obj->public;
1086 }
1087
1088 /**
1089 * Check if taxonomy should trigger regeneration
1090 *
1091 * @since 1.0.0
1092 * @param string $taxonomy Taxonomy slug
1093 * @return bool True if taxonomy should trigger regeneration
1094 */
1095 private function should_include_taxonomy(string $taxonomy): bool {
1096 // Include all public taxonomies (presets control which sitemaps are created)
1097 $taxonomy_obj = get_taxonomy($taxonomy);
1098 return $taxonomy_obj && $taxonomy_obj->public;
1099 }
1100
1101 /**
1102 * Schedule debounced sitemap regeneration
1103 *
1104 * @since 1.0.0
1105 * @return void
1106 */
1107 private function schedule_debounced_regeneration(): void {
1108 // Clear any existing scheduled regeneration
1109 wp_clear_scheduled_hook('thinkrank_regenerate_sitemap');
1110
1111 // Schedule regeneration in 30 seconds to debounce rapid changes
1112 wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap');
1113 }
1114
1115 /**
1116 * Auto-regenerate sitemap (called by scheduled action)
1117 *
1118 * @since 1.0.0
1119 * @return void
1120 */
1121 public function auto_regenerate_sitemap(): void {
1122 try {
1123 // Double-check that auto-generation is still enabled
1124 if (!$this->should_auto_generate()) {
1125 return;
1126 }
1127
1128 // Generate sitemap with current settings
1129 $settings = $this->get_settings('site');
1130 $sitemap_xml = $this->generate_sitemap($settings);
1131
1132 // Save sitemap to file
1133 $this->save_sitemap_to_file($sitemap_xml);
1134
1135 // Update last generated timestamp
1136 $this->update_setting('last_generated', gmdate('c'), 'site');
1137
1138 // Ping search engines if enabled
1139 if (!empty($settings['ping_search_engines'])) {
1140 $this->ping_search_engines();
1141 }
1142
1143 // Sitemap auto-regenerated successfully
1144
1145 } catch (\Exception $e) {
1146 // Sitemap auto-regeneration failed - error details available in exception
1147 }
1148 }
1149
1150 /**
1151 * Save sitemap XML to file
1152 *
1153 * @since 1.0.0
1154 * @param string $sitemap_xml Sitemap XML content
1155 * @param string $filename Optional. Filename to save (defaults to 'sitemap.xml')
1156 * @return bool True on success, false on failure
1157 */
1158 private function save_sitemap_to_file(string $sitemap_xml, string $filename = 'sitemap.xml'): bool {
1159 // Validate and sanitize filename for security
1160 try {
1161 $filename = $this->validate_sitemap_filename($filename);
1162 } catch (InvalidArgumentException $e) {
1163 // File validation failed - error details available in exception
1164 return false;
1165 }
1166
1167 $sitemap_path = ABSPATH . $filename;
1168
1169 // Use WordPress filesystem API for better security
1170 global $wp_filesystem;
1171 if (!$wp_filesystem) {
1172 require_once ABSPATH . 'wp-admin/includes/file.php';
1173 WP_Filesystem();
1174 }
1175
1176 if ($wp_filesystem) {
1177 return $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
1178 }
1179
1180 // Fallback to file_put_contents if WP_Filesystem fails
1181 return file_put_contents($sitemap_path, $sitemap_xml) !== false;
1182 }
1183
1184 /**
1185 * Generate multiple sitemaps based on settings
1186 *
1187 * @since 1.0.0
1188 * @param array $settings Sitemap settings
1189 * @return array Results of sitemap generation
1190 */
1191 public function generate_multiple_sitemaps(array $settings): array {
1192 $results = [
1193 'success' => true,
1194 'sitemaps_generated' => [],
1195 'errors' => [],
1196 'total_urls' => 0
1197 ];
1198
1199 $sitemap_urls = $settings['sitemap_urls'] ?? [];
1200
1201 if (empty($sitemap_urls)) {
1202 $results['success'] = false;
1203 $results['errors'][] = 'No sitemap URLs configured';
1204 return $results;
1205 }
1206
1207 foreach ($sitemap_urls as $sitemap_config) {
1208 if (!$sitemap_config['enabled']) {
1209 continue;
1210 }
1211
1212 try {
1213 $sitemap_xml = '';
1214 $url_count = 0;
1215
1216 // Generate sitemap based on type
1217 switch ($sitemap_config['type']) {
1218 case 'index':
1219 $sitemap_xml = $this->generate_sitemap_index($sitemap_urls);
1220 $url_count = count(array_filter($sitemap_urls, fn($s) => $s['enabled'] && $s['type'] !== 'index'));
1221 break;
1222 case 'general':
1223 case 'wordpress':
1224 $sitemap_xml = $this->generate_sitemap($settings);
1225 $url_count = $this->count_urls_in_xml($sitemap_xml);
1226 break;
1227 case 'posts':
1228 $sitemap_xml = $this->generate_post_sitemap('post', $settings);
1229 $url_count = $this->count_urls_in_xml($sitemap_xml);
1230 break;
1231 case 'pages':
1232 $sitemap_xml = $this->generate_post_sitemap('page', $settings);
1233 $url_count = $this->count_urls_in_xml($sitemap_xml);
1234 break;
1235 case 'categories':
1236 $sitemap_xml = $this->generate_taxonomy_sitemap('category', $settings);
1237 $url_count = $this->count_urls_in_xml($sitemap_xml);
1238 break;
1239 case 'products':
1240 // Generate product-specific sitemap
1241 if (post_type_exists('product')) {
1242 $sitemap_xml = $this->generate_post_sitemap('product', $settings);
1243 } else {
1244 // Create empty sitemap if WooCommerce not installed
1245 $sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1246
1247 // Add XSL stylesheet only if styling is enabled
1248 if (!empty($settings['enable_styling'])) {
1249 $sitemap_xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap.xsl') . '"?>' . "\n";
1250 }
1251
1252 $sitemap_xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1253 $sitemap_xml .= '</urlset>';
1254 }
1255 $url_count = $this->count_urls_in_xml($sitemap_xml);
1256 break;
1257 case 'product_categories':
1258 // Generate product category sitemap
1259 if (taxonomy_exists('product_cat')) {
1260 $sitemap_xml = $this->generate_taxonomy_sitemap('product_cat', $settings);
1261 } else {
1262 // Create empty sitemap if WooCommerce not installed
1263 $sitemap_xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1264
1265 // Add XSL stylesheet only if styling is enabled
1266 if (!empty($settings['enable_styling'])) {
1267 $sitemap_xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap.xsl') . '"?>' . "\n";
1268 }
1269
1270 $sitemap_xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1271 $sitemap_xml .= '</urlset>';
1272 }
1273 $url_count = $this->count_urls_in_xml($sitemap_xml);
1274 break;
1275 default:
1276 // Check if it's a custom post type
1277 $post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
1278 if (in_array($sitemap_config['type'], $post_types)) {
1279 $sitemap_xml = $this->generate_post_sitemap($sitemap_config['type'], $settings);
1280 $url_count = $this->count_urls_in_xml($sitemap_xml);
1281 } else {
1282 // Fallback to general sitemap
1283 $sitemap_xml = $this->generate_sitemap($settings);
1284 $url_count = $this->count_urls_in_xml($sitemap_xml);
1285 }
1286 break;
1287 }
1288
1289 // Extract filename from URL
1290 $filename = basename(wp_parse_url($sitemap_config['url'], PHP_URL_PATH));
1291
1292 // Save sitemap to file
1293 if ($this->save_sitemap_to_file($sitemap_xml, $filename)) {
1294 $results['sitemaps_generated'][] = [
1295 'url' => $sitemap_config['url'],
1296 'type' => $sitemap_config['type'],
1297 'filename' => $filename,
1298 'url_count' => $url_count
1299 ];
1300 $results['total_urls'] += $url_count;
1301 } else {
1302 $error_message = "Failed to save sitemap: {$filename}";
1303 $results['errors'][] = $error_message;
1304 $results['success'] = false;
1305
1306 // File save error details available in results array
1307 }
1308
1309 } catch (\Exception $e) {
1310 $results['errors'][] = "Error generating {$sitemap_config['type']} sitemap: " . $e->getMessage();
1311 $results['success'] = false;
1312 }
1313 }
1314
1315 return $results;
1316 }
1317
1318 /**
1319 * Generate sitemap index XML
1320 *
1321 * @since 1.0.0
1322 * @param array $sitemap_urls Array of sitemap configurations
1323 * @return string Sitemap index XML
1324 */
1325 private function generate_sitemap_index(array $sitemap_urls): string {
1326 $settings = $this->get_settings('site');
1327 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1328
1329 // Add XSL stylesheet only if styling is enabled
1330 if (!empty($settings['enable_styling'])) {
1331 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap-index.xsl') . '"?>' . "\n";
1332 }
1333 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1334
1335 $site_url = home_url();
1336
1337 foreach ($sitemap_urls as $sitemap_config) {
1338 if (!$sitemap_config['enabled'] || $sitemap_config['type'] === 'index') {
1339 continue;
1340 }
1341
1342 $sitemap_url = $sitemap_config['url'];
1343 if (!str_starts_with($sitemap_url, 'http')) {
1344 $sitemap_url = $site_url . $sitemap_url;
1345 }
1346
1347 $xml .= " <sitemap>\n";
1348 $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
1349 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
1350 $xml .= " </sitemap>\n";
1351 }
1352
1353 $xml .= '</sitemapindex>';
1354
1355 return $xml;
1356 }
1357
1358 /**
1359 * Generate sitemap for specific post type
1360 *
1361 * @since 1.0.0
1362 * @param string $post_type Post type to generate sitemap for
1363 * @param array $settings Sitemap settings
1364 * @return string Sitemap XML
1365 */
1366 private function generate_post_sitemap(string $post_type, array $settings): string {
1367 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1368
1369 // Add XSL stylesheet only if styling is enabled
1370 if (!empty($settings['enable_styling'])) {
1371 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap.xsl') . '"?>' . "\n";
1372 }
1373
1374 // Add image namespace if images are enabled
1375 if (!empty($settings['include_images'])) {
1376 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
1377 } else {
1378 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1379 }
1380
1381 // Add homepage only for general sitemap
1382 if ($post_type === 'page') {
1383 $xml .= $this->generate_url_entry(home_url('/'), gmdate('c'), 1.0, 'daily');
1384 }
1385
1386 // Add posts/pages of specific type
1387 $xml .= $this->generate_post_entries($post_type, $settings);
1388
1389 $xml .= '</urlset>';
1390 return $xml;
1391 }
1392
1393 /**
1394 * Generate sitemap for specific taxonomy
1395 *
1396 * @since 1.0.0
1397 * @param string $taxonomy Taxonomy to generate sitemap for
1398 * @param array $settings Sitemap settings
1399 * @return string Sitemap XML
1400 */
1401 private function generate_taxonomy_sitemap(string $taxonomy, array $settings): string {
1402 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
1403
1404 // Add XSL stylesheet only if styling is enabled
1405 if (!empty($settings['enable_styling'])) {
1406 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/assets/sitemap.xsl') . '"?>' . "\n";
1407 }
1408 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
1409
1410 // Add taxonomy terms
1411 $xml .= $this->generate_taxonomy_entries($taxonomy, $settings);
1412
1413 $xml .= '</urlset>';
1414 return $xml;
1415 }
1416
1417 /**
1418 * Count URLs in sitemap XML content
1419 *
1420 * @since 1.0.0
1421 * @param string $sitemap_xml Sitemap XML content
1422 * @return int Number of URLs
1423 */
1424 private function count_urls_in_xml(string $sitemap_xml): int {
1425 return substr_count($sitemap_xml, '<url>');
1426 }
1427
1428 /**
1429 * Ping search engines about sitemap update
1430 *
1431 * @since 1.0.0
1432 * @return void
1433 */
1434 private function ping_search_engines(): void {
1435 $sitemap_url = home_url('/sitemap.xml');
1436
1437 $ping_urls = [
1438 'google' => 'https://www.google.com/ping?sitemap=' . urlencode($sitemap_url),
1439 'bing' => 'https://www.bing.com/ping?sitemap=' . urlencode($sitemap_url)
1440 ];
1441
1442 foreach ($ping_urls as $engine => $ping_url) {
1443 // Use wp_remote_get with timeout to prevent hanging
1444 $response = wp_remote_get($ping_url, [
1445 'timeout' => 10,
1446 'blocking' => false // Non-blocking to prevent delays
1447 ]);
1448
1449 if (is_wp_error($response)) {
1450 // Search engine ping failed - error details available in WP_Error response
1451 }
1452 }
1453 }
1454
1455 /**
1456 * Validate and sanitize exclude posts input
1457 *
1458 * @since 1.0.0
1459 * @param string $exclude_posts Comma-separated post IDs
1460 * @return array Validated post IDs
1461 * @throws InvalidArgumentException If validation fails
1462 */
1463 private function validate_exclude_posts(string $exclude_posts): array {
1464 if (empty($exclude_posts)) {
1465 return [];
1466 }
1467
1468 // Limit input length to prevent DoS
1469 if (strlen($exclude_posts) > 1000) {
1470 throw new InvalidArgumentException('Exclude posts list too long (max 1000 characters)');
1471 }
1472
1473 // Validate comma-separated integers only
1474 if (!preg_match('/^[\d,\s]+$/', $exclude_posts)) {
1475 throw new InvalidArgumentException('Exclude posts must contain only numbers and commas');
1476 }
1477
1478 $ids = array_map('intval', array_filter(explode(',', $exclude_posts)));
1479
1480 // Limit number of exclusions to prevent performance issues
1481 if (count($ids) > 100) {
1482 throw new InvalidArgumentException('Too many posts to exclude (max 100)');
1483 }
1484
1485 return array_filter($ids, function($id) {
1486 return $id > 0; // Only positive integers
1487 });
1488 }
1489
1490 /**
1491 * Validate and sanitize exclude terms input
1492 *
1493 * @since 1.0.0
1494 * @param string $exclude_terms Comma-separated term IDs
1495 * @return array Validated term IDs
1496 * @throws InvalidArgumentException If validation fails
1497 */
1498 private function validate_exclude_terms(string $exclude_terms): array {
1499 if (empty($exclude_terms)) {
1500 return [];
1501 }
1502
1503 // Limit input length to prevent DoS
1504 if (strlen($exclude_terms) > 1000) {
1505 throw new InvalidArgumentException('Exclude terms list too long (max 1000 characters)');
1506 }
1507
1508 // Validate comma-separated integers only
1509 if (!preg_match('/^[\d,\s]+$/', $exclude_terms)) {
1510 throw new InvalidArgumentException('Exclude terms must contain only numbers and commas');
1511 }
1512
1513 $ids = array_map('intval', array_filter(explode(',', $exclude_terms)));
1514
1515 // Limit number of exclusions to prevent performance issues
1516 if (count($ids) > 100) {
1517 throw new InvalidArgumentException('Too many terms to exclude (max 100)');
1518 }
1519
1520 return array_filter($ids, function($id) {
1521 return $id > 0; // Only positive integers
1522 });
1523 }
1524
1525 /**
1526 * Validate and sanitize custom URL pattern
1527 *
1528 * @since 1.0.0
1529 * @param string $pattern Custom URL pattern
1530 * @return string Validated and sanitized pattern
1531 * @throws InvalidArgumentException If validation fails
1532 */
1533 private function validate_custom_url_pattern(string $pattern): string {
1534 if (empty($pattern)) {
1535 return 'sitemap-{type}.xml';
1536 }
1537
1538 // Remove any HTML/script tags to prevent XSS
1539 $pattern = wp_strip_all_tags($pattern);
1540
1541 // Limit pattern length
1542 if (strlen($pattern) > 100) {
1543 throw new InvalidArgumentException('URL pattern too long (max 100 characters)');
1544 }
1545
1546 // Validate pattern format - only allow safe characters
1547 if (!preg_match('/^[a-zA-Z0-9\-_{}\.]+$/', $pattern)) {
1548 throw new InvalidArgumentException('URL pattern contains invalid characters. Only letters, numbers, hyphens, underscores, dots, and {type} are allowed');
1549 }
1550
1551 // Ensure it contains {type} placeholder
1552 if (strpos($pattern, '{type}') === false) {
1553 throw new InvalidArgumentException('URL pattern must contain {type} placeholder');
1554 }
1555
1556 // Ensure it ends with .xml
1557 if (!str_ends_with($pattern, '.xml')) {
1558 $pattern .= '.xml';
1559 }
1560
1561 return sanitize_file_name($pattern);
1562 }
1563
1564 /**
1565 * Validate sitemap URL
1566 *
1567 * @since 1.0.0
1568 * @param string $url Sitemap URL
1569 * @return string Validated and sanitized URL
1570 * @throws InvalidArgumentException If validation fails
1571 */
1572 private function validate_sitemap_url(string $url): string {
1573 if (empty($url)) {
1574 throw new InvalidArgumentException('Sitemap URL cannot be empty');
1575 }
1576
1577 // Remove leading/trailing whitespace
1578 $url = trim($url);
1579
1580 // Limit URL length
1581 if (strlen($url) > 200) {
1582 throw new InvalidArgumentException('Sitemap URL too long (max 200 characters)');
1583 }
1584
1585 // Ensure it starts with /
1586 if (!str_starts_with($url, '/')) {
1587 $url = '/' . $url;
1588 }
1589
1590 // Validate URL path format
1591 if (!preg_match('/^\/[a-zA-Z0-9\-_\/\.]+\.xml$/', $url)) {
1592 throw new InvalidArgumentException('Invalid sitemap URL format. Must be a valid path ending with .xml');
1593 }
1594
1595 // Prevent directory traversal
1596 if (strpos($url, '..') !== false) {
1597 throw new InvalidArgumentException('Directory traversal not allowed in sitemap URL');
1598 }
1599
1600 // Prevent multiple slashes
1601 $url = preg_replace('/\/+/', '/', $url);
1602
1603 return sanitize_url($url);
1604 }
1605
1606 /**
1607 * Validate links per sitemap setting
1608 *
1609 * @since 1.0.0
1610 * @param mixed $links_per_sitemap Links per sitemap value
1611 * @return int Validated links per sitemap
1612 * @throws InvalidArgumentException If validation fails
1613 */
1614 private function validate_links_per_sitemap($links_per_sitemap): int {
1615 $links = intval($links_per_sitemap);
1616
1617 if ($links < 1) {
1618 throw new InvalidArgumentException('Links per sitemap must be at least 1');
1619 }
1620
1621 if ($links > 50000) {
1622 throw new InvalidArgumentException('Links per sitemap cannot exceed 50,000');
1623 }
1624
1625 return $links;
1626 }
1627
1628 /**
1629 * Validate sitemap filename for security
1630 *
1631 * @since 1.0.0
1632 * @param string $filename Filename to validate
1633 * @return string Validated and sanitized filename
1634 * @throws InvalidArgumentException If validation fails
1635 */
1636 private function validate_sitemap_filename(string $filename): string {
1637 if (empty($filename)) {
1638 return 'sitemap.xml';
1639 }
1640
1641 // Remove any path components to prevent directory traversal
1642 $filename = basename($filename);
1643
1644 // Limit filename length
1645 if (strlen($filename) > 100) {
1646 throw new InvalidArgumentException('Filename too long (max 100 characters)');
1647 }
1648
1649 // Validate filename format - only allow safe characters
1650 if (!preg_match('/^[a-zA-Z0-9\-_\.]+$/', $filename)) {
1651 throw new InvalidArgumentException('Filename contains invalid characters. Only letters, numbers, hyphens, underscores, and dots are allowed');
1652 }
1653
1654 // Prevent directory traversal attempts
1655 if (strpos($filename, '..') !== false) {
1656 throw new InvalidArgumentException('Directory traversal not allowed in filename');
1657 }
1658
1659 // Ensure it ends with .xml
1660 if (!str_ends_with($filename, '.xml')) {
1661 $filename .= '.xml';
1662 }
1663
1664 // Additional sanitization
1665 $filename = sanitize_file_name($filename);
1666
1667 // Final security check - ensure it's still a valid XML filename
1668 if (!preg_match('/^[a-zA-Z0-9\-_]+\.xml$/', $filename)) {
1669 throw new InvalidArgumentException('Invalid XML filename after sanitization');
1670 }
1671
1672 return $filename;
1673 }
1674 }
1675