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

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

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