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

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

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