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

3,378 lines 127.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Sitemap Generator Class
4 *
5 * XML sitemap generation and management for search engine optimization.
6 * Implements 2025 SEO best practices with real sitemap specifications,
7 * dynamic content inclusion, and performance optimization.
8 *
9 * @package ThinkRank
10 * @subpackage SEO
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\SEO;
17
18 use InvalidArgumentException;
19
20 // Prevent direct access.
21 if ( ! defined( 'ABSPATH' ) ) {
22 exit;
23 }
24
25 // Filename derivation and web-root removal are shared with the deactivator and
26 // with uninstall.php, which runs without an autoloader — so they live in a
27 // plain function file both can require. See includes/cleanup-webroot.php.
28 require_once __DIR__ . '/../cleanup-webroot.php';
29
30 /**
31 * Sitemap Generator Class
32 *
33 * Generates and manages XML sitemaps for search engine optimization.
34 * Provides dynamic sitemap generation with content filtering, priority
35 * calculation, and change frequency optimization.
36 *
37 * @since 1.0.0
38 */
39 class Sitemap_Generator extends Abstract_SEO_Manager {
40
41 /**
42 * Memoised WooCommerce page IDs kept out of the sitemap. Null until resolved.
43 *
44 * @since 2.0.1
45 * @var int[]|null
46 */
47 private ?array $woocommerce_excluded_page_ids = null;
48
49 /**
50 * Inclusion flag -> the child sitemap it controls.
51 *
52 * Shared by the child-list builder and by the "did the caller change what
53 * the sitemap includes?" check in maybe_promote_to_index().
54 *
55 * @since 1.31.0
56 * @var array<string, string>
57 */
58 private const INCLUSION_CHILD_TYPES = [
59 'include_posts' => 'posts',
60 'include_pages' => 'pages',
61 'include_categories' => 'categories',
62 'include_tags' => 'tags',
63 ];
64
65 /**
66 * Supported sitemap types
67 *
68 * @since 1.0.0
69 * @var array
70 */
71 /**
72 * How many post IDs to hydrate at a time while walking the sitemap set.
73 *
74 * @since 2.0.1
75 * @var int
76 */
77 private const ID_WALK_CHUNK = 500;
78
79 /**
80 * How long a content or settings change is debounced before the sitemap is
81 * rebuilt, in seconds. Coalesces bulk edits into a single regeneration.
82 *
83 * @since 2.2.1
84 * @var int
85 */
86 private const REGENERATION_DEBOUNCE = 30;
87
88 /**
89 * How far past its due time the scheduled rebuild may sit before a request
90 * takes it over, in seconds.
91 *
92 * WP-Cron is request-driven, so on a site running DISABLE_WP_CRON, blocking
93 * loopback requests, or seeing very little traffic the event never fires and
94 * the sitemap silently stops updating (#629). The grace keeps the fast path
95 * (cron) in charge under normal conditions.
96 *
97 * @since 2.2.1
98 * @var int
99 */
100 private const REGENERATION_TAKEOVER_GRACE = 120;
101
102 /**
103 * Longest backoff between takeover attempts after a failed rebuild, so a
104 * persistently failing generation cannot run on every admin request.
105 *
106 * @since 2.2.1
107 * @var int
108 */
109 private const REGENERATION_MAX_BACKOFF = 3600;
110
111 /**
112 * Option holding the rebuild that content/settings changes are still waiting
113 * on: `since`, `source` ('content'|'settings'), `attempts`, `next_attempt`.
114 * Absent means the served sitemap is up to date with what triggered it.
115 *
116 * @since 2.2.1
117 * @var string
118 */
119 public const REGENERATION_PENDING_OPTION = 'thinkrank_sitemap_regeneration_pending';
120
121 /**
122 * Option holding the last automatic-regeneration failure (`message`,
123 * `source`, `time`), so the failure is visible instead of swallowed.
124 *
125 * @since 2.2.1
126 * @var string
127 */
128 public const REGENERATION_ERROR_OPTION = 'thinkrank_sitemap_regeneration_error';
129
130 /**
131 * Transient guarding against two generations running at once. Shared with
132 * Sitemap_Endpoint's manual generate route so an automatic rebuild and a
133 * manual one cannot write the same files concurrently.
134 *
135 * @since 2.2.1
136 * @var string
137 */
138 public const GENERATION_LOCK_TRANSIENT = 'thinkrank_sitemap_generation_lock';
139
140 /**
141 * How many term IDs to hydrate at a time while walking a taxonomy.
142 *
143 * @since 2.0.1
144 * @var int
145 */
146 private const TERM_WALK_CHUNK = 1000;
147
148 private array $sitemap_types = [
149 'posts' => [
150 'name' => 'Posts',
151 'post_types' => ['post'],
152 'priority' => 0.8,
153 'changefreq' => 'weekly'
154 ],
155 'pages' => [
156 'name' => 'Pages',
157 'post_types' => ['page'],
158 'priority' => 0.9,
159 'changefreq' => 'monthly'
160 ],
161 'categories' => [
162 'name' => 'Categories',
163 'taxonomy' => 'category',
164 'priority' => 0.6,
165 'changefreq' => 'weekly'
166 ],
167 'tags' => [
168 'name' => 'Tags',
169 'taxonomy' => 'post_tag',
170 'priority' => 0.4,
171 'changefreq' => 'monthly'
172 ]
173 ];
174
175 /**
176 * Constructor
177 *
178 * @since 1.0.0
179 *
180 * @param bool $register_hooks Optional. Whether to register the auto-generation
181 * hooks. Pass false for a read-only instance built
182 * solely to query settings — the hooks are bound to
183 * `$this`, so a second hook-registering instance
184 * would run `handle_content_change()` twice per save.
185 */
186 public function __construct(bool $register_hooks = true) {
187 parent::__construct('sitemap');
188
189 // Initialize auto-generation hooks
190 if ($register_hooks) {
191 $this->init_auto_generation_hooks();
192 }
193 }
194
195 /**
196 * Filter the args of a sitemap post query.
197 *
198 * Exists so integrations can widen what the sitemap sees — the multilingual
199 * manager uses it to include every language, since these queries otherwise
200 * run in whichever language happened to be active at generation time.
201 *
202 * @since 1.23.0
203 *
204 * @param array $args get_posts() arguments.
205 * @return array Filtered arguments.
206 */
207 private function filter_query_args(array $args): array {
208 /**
209 * Filter the arguments of a sitemap post query.
210 *
211 * @since 1.23.0
212 *
213 * @param array $args get_posts() arguments.
214 */
215 return (array) apply_filters('thinkrank_sitemap_query_args', $args);
216 }
217
218 /**
219 * Filter the args of a sitemap term query.
220 *
221 * @since 1.23.0
222 *
223 * @param array $args get_terms() arguments.
224 * @return array Filtered arguments.
225 */
226 private function filter_term_query_args(array $args): array {
227 /**
228 * Filter the arguments of a sitemap term query.
229 *
230 * @since 1.23.0
231 *
232 * @param array $args get_terms() arguments.
233 */
234 return (array) apply_filters('thinkrank_sitemap_term_query_args', $args);
235 }
236
237 /**
238 * Initialize WordPress hooks for auto-generation
239 *
240 * @since 1.0.0
241 * @return void
242 */
243 private function init_auto_generation_hooks(): void {
244 // Content change hooks - use priority 20 to run after other plugins
245 add_action('save_post', [$this, 'handle_content_change'], 20, 2);
246 add_action('delete_post', [$this, 'handle_content_deletion'], 20);
247 add_action('wp_trash_post', [$this, 'handle_content_deletion'], 20);
248 add_action('untrash_post', [$this, 'handle_content_change_by_id'], 20);
249
250 // Taxonomy change hooks
251 add_action('created_term', [$this, 'handle_taxonomy_change'], 20, 3);
252 add_action('edited_term', [$this, 'handle_taxonomy_change'], 20, 3);
253 add_action('delete_term', [$this, 'handle_taxonomy_change'], 20, 3);
254
255 // NOTE: the WP-Cron regeneration listeners (thinkrank_regenerate_sitemap
256 // and thinkrank_regenerate_sitemap_settings) are registered at plugin
257 // bootstrap (Plugin::register_sitemap_cron_listeners(), on plugins_loaded)
258 // rather than here. A cron run never builds this class via the REST
259 // endpoint (no rest_api_init), so registering them in the constructor
260 // would leave the scheduled events with no listener at cron time.
261 }
262
263 /**
264 * Generate XML sitemap
265 *
266 * @since 1.0.0
267 *
268 * @param array $options Sitemap generation options
269 * @return string XML sitemap content
270 */
271 public function generate_sitemap(array $options = []): string {
272 $settings = $this->get_settings('site');
273
274 $xml = $this->xml_prolog($settings, 'sitemap.xsl');
275
276 // Add image namespace if images are enabled
277 if (!empty($settings['include_images'])) {
278 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
279 } else {
280 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
281 }
282
283 // Add homepage. Use the static front page's real modified time when set,
284 // so lastmod reflects real changes rather than the generation time.
285 $xml .= $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
286
287 // MEMORY-EFFICIENT: Generate posts using chunked processing.
288 // Note: the single "general" sitemap includes every URL (no per-file cap);
289 // per-file chunking applies to the segmented/index model where each type
290 // gets its own paginated file (see generate_multiple_sitemaps).
291 $enabled_post_types = $this->get_enabled_post_types($settings);
292 if (!empty($enabled_post_types)) {
293 $xml .= implode('', $this->collect_post_entries($enabled_post_types, $settings));
294 }
295
296 // OPTIMIZED: Get all enabled taxonomies and fetch in single query
297 $enabled_taxonomies = $this->get_enabled_taxonomies($settings);
298 if (!empty($enabled_taxonomies)) {
299 $all_taxonomy_data = $this->fetch_taxonomies_optimized($enabled_taxonomies, $settings);
300
301 // Process each taxonomy's data
302 foreach ($enabled_taxonomies as $taxonomy) {
303 if (!empty($all_taxonomy_data[$taxonomy])) {
304 $xml .= $this->process_taxonomies_to_xml($all_taxonomy_data[$taxonomy], $taxonomy, $settings);
305 }
306 }
307 }
308
309 $xml .= '</urlset>';
310
311 return $xml;
312 }
313
314 /**
315 * Validate SEO settings (implements interface)
316 *
317 * @since 1.0.0
318 *
319 * @param array $settings Settings array to validate
320 * @return array Validation results
321 */
322 public function validate_settings(array $settings): array {
323 $validation = [
324 'valid' => true,
325 'errors' => [],
326 'warnings' => [],
327 'suggestions' => []
328 ];
329
330 try {
331 // Validate exclude_posts
332 if (!empty($settings['exclude_posts'])) {
333 $this->validate_exclude_posts($settings['exclude_posts']);
334 }
335
336 // Validate exclude_terms
337 if (!empty($settings['exclude_terms'])) {
338 $this->validate_exclude_terms($settings['exclude_terms']);
339 }
340
341 // Validate custom_url_pattern
342 if (!empty($settings['custom_url_pattern'])) {
343 $this->validate_custom_url_pattern($settings['custom_url_pattern']);
344 }
345
346 // Validate sitemap_urls
347 if (!empty($settings['sitemap_urls']) && is_array($settings['sitemap_urls'])) {
348 foreach ($settings['sitemap_urls'] as $sitemap) {
349 if (!empty($sitemap['url'])) {
350 $this->validate_sitemap_url($sitemap['url']);
351 }
352 }
353 }
354
355 // Validate numeric settings
356 if (isset($settings['links_per_sitemap'])) {
357 $this->validate_links_per_sitemap($settings['links_per_sitemap']);
358 }
359
360 } catch (InvalidArgumentException $e) {
361 $validation['valid'] = false;
362 $validation['errors'][] = $e->getMessage();
363 }
364
365 return $validation;
366 }
367
368 /**
369 * Get output data for frontend rendering (implements interface)
370 *
371 * @since 1.0.0
372 *
373 * @param string $context_type The context type
374 * @param int|null $context_id Optional. Context ID
375 * @return array Output data ready for frontend rendering
376 */
377 public function get_output_data(string $context_type, ?int $context_id): array {
378 $settings = $this->get_settings($context_type, $context_id);
379
380 return [
381 'sitemap_url' => home_url('/sitemap.xml'),
382 'enabled' => $settings['enabled'] ?? true,
383 'last_generated' => $settings['last_generated'] ?? '',
384 'total_urls' => $this->count_sitemap_urls($settings)
385 ];
386 }
387
388 /**
389 * Sitemap keys outside the defaults.
390 *
391 * @since 2.0.1
392 *
393 * @return string[]
394 */
395 protected function additional_setting_keys(): array {
396 return ['selected_preset'];
397 }
398
399 /**
400 * Inclusion flags are per post type and per taxonomy.
401 *
402 * A site registering a `product` post type stores `include_product`; an
403 * enumerated list would go stale on the next registration, so the family
404 * is matched instead.
405 *
406 * @since 2.0.1
407 *
408 * @return string[]
409 */
410 protected function dynamic_setting_key_patterns(): array {
411 return ['/^include_[a-z0-9_]+$/', '/^exclude_[a-z0-9_]+$/'];
412 }
413
414 /**
415 * Get default settings for a context type (implements interface)
416 *
417 * @since 1.0.0
418 *
419 * @param string $context_type The context type to get defaults for
420 * @return array Default settings array
421 */
422 public function get_default_settings(string $context_type): array {
423 return [
424 // Core settings
425 'enabled' => true,
426
427 // Multiple Sitemap URLs
428 'sitemap_urls' => [
429 [
430 'url' => '/sitemap.xml',
431 'type' => 'general',
432 'enabled' => true,
433 'last_checked' => null,
434 'status' => 'unknown'
435 ]
436 ],
437 'use_sitemap_index' => false,
438
439 // General Settings
440 'links_per_sitemap' => 1000,
441 'include_images' => true,
442 'include_featured_images' => false,
443 'auto_generate' => true,
444 'ping_search_engines' => true,
445
446 // Content Inclusion (backward compatibility)
447 'include_posts' => true,
448 'include_pages' => true,
449 'include_categories' => true,
450 'include_tags' => false,
451
452 // Content Filtering
453 'exclude_posts' => '',
454 'exclude_terms' => '',
455 'exclude_password_protected' => true,
456 'exclude_private_posts' => true,
457
458 // Advanced Options
459 'enable_styling' => true,
460 'custom_url_pattern' => 'sitemap-{type}.xml',
461
462 // Generation tracking
463 'last_generated' => ''
464 ];
465 }
466
467 /**
468 * Get settings schema definition (implements interface)
469 *
470 * @since 1.0.0
471 *
472 * @param string $context_type The context type to get schema for
473 * @return array Settings schema definition
474 */
475 public function get_settings_schema(string $context_type): array {
476 return [
477 'enabled' => [
478 'type' => 'boolean',
479 'title' => 'Enable Sitemap',
480 'description' => 'Generate XML sitemap for search engines',
481 'default' => true
482 ],
483 'include_posts' => [
484 'type' => 'boolean',
485 'title' => 'Include Posts',
486 'description' => 'Include blog posts in sitemap',
487 'default' => true
488 ],
489 'include_pages' => [
490 'type' => 'boolean',
491 'title' => 'Include Pages',
492 'description' => 'Include static pages in sitemap',
493 'default' => true
494 ],
495 'include_categories' => [
496 'type' => 'boolean',
497 'title' => 'Include Categories',
498 'description' => 'Include category pages in sitemap',
499 'default' => true
500 ],
501 'include_tags' => [
502 'type' => 'boolean',
503 'title' => 'Include Tags',
504 'description' => 'Include tag pages in sitemap',
505 'default' => false
506 ],
507
508 'auto_generate' => [
509 'type' => 'boolean',
510 'title' => 'Auto Generate',
511 'description' => 'Automatically regenerate sitemap when content changes',
512 'default' => true
513 ],
514 'ping_search_engines' => [
515 'type' => 'boolean',
516 'title' => 'Ping Search Engines',
517 'description' => 'Notify Google and Bing when sitemap is updated',
518 'default' => true
519 ],
520 'last_generated' => [
521 'type' => 'string',
522 'title' => 'Last Generated',
523 'description' => 'Timestamp of last sitemap generation',
524 'default' => ''
525 ]
526 ];
527 }
528
529 /**
530 * Generate URL entry for sitemap
531 *
532 * @since 1.0.0
533 *
534 * @param string $url URL
535 * @param string $lastmod Last modification date
536 * @param float $priority Priority (0.0 to 1.0)
537 * @param string $changefreq Change frequency
538 * @param array $images Optional array of image data
539 * @return string XML URL entry
540 */
541 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
542 $xml = " <url>\n";
543 $xml .= " <loc>" . esc_url($url) . "</loc>\n";
544 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
545 // than no timestamp, and an absent lastmod is valid per the spec.
546 if (!empty($lastmod)) {
547 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
548 }
549 $xml .= " <priority>" . number_format($priority, 1) . "</priority>\n";
550 $xml .= " <changefreq>" . esc_html($changefreq) . "</changefreq>\n";
551
552 // Add image entries if provided
553 foreach ($images as $image) {
554 $xml .= " <image:image>\n";
555 $xml .= " <image:loc>" . esc_url($image['url']) . "</image:loc>\n";
556
557 if (!empty($image['title'])) {
558 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
559 }
560
561 if (!empty($image['alt'])) {
562 $xml .= " <image:caption>" . esc_html($image['alt']) . "</image:caption>\n";
563 }
564
565 $xml .= " </image:image>\n";
566 }
567
568 $xml .= " </url>\n";
569
570 return $xml;
571 }
572
573 /**
574 * Collect every post <url> entry for the given post type(s) as an array of
575 * XML strings, using memory-efficient chunked fetching.
576 *
577 * Unlike the old capped generator, this returns ALL matching entries — the
578 * links-per-sitemap limit is applied later by paginating this array into
579 * separate files (see generate_multiple_sitemaps), matching how Rank Math
580 * splits large sitemaps instead of truncating them.
581 *
582 * @since 1.14.0
583 *
584 * @param array $post_types Array of post types to fetch
585 * @param array $settings Sitemap settings
586 * @return array<string> Individual <url>…</url> entry strings
587 */
588 /**
589 * lastmod for the homepage <url> entry: the static front page's real
590 * modification time when one is set, otherwise the generation time.
591 *
592 * @return string ISO-8601 date.
593 */
594 private function get_homepage_lastmod(): string {
595 if (get_option('show_on_front') === 'page') {
596 $front_id = (int) get_option('page_on_front');
597 if ($front_id) {
598 $modified = get_post_field('post_modified_gmt', $front_id);
599 if (!empty($modified) && $modified !== '0000-00-00 00:00:00') {
600 return gmdate('c', strtotime($modified));
601 }
602 }
603 }
604 return gmdate('c');
605 }
606
607 private function collect_post_entries(array $post_types, array $settings): array {
608 // Materialize the streaming source for callers that need the full set
609 // (e.g. the single un-paginated "general" sitemap). The paginated index
610 // path streams collect_post_entries_iter() directly to bound memory.
611 return iterator_to_array($this->collect_post_entries_iter($post_types, $settings), false);
612 }
613
614 /**
615 * Stream <url> entries for the given post types, yielding one at a time.
616 *
617 * Same chunked query, filtering, and ordering as before, but yields each
618 * entry instead of accumulating the whole set — so the paginated index
619 * generator never holds every URL of a large post type in memory at once.
620 *
621 * @param array $post_types Post types to include.
622 * @param array $settings Sitemap settings.
623 * @return \Generator<string> <url> entry strings.
624 */
625 private function collect_post_entries_iter(array $post_types, array $settings): \Generator {
626 if (empty($post_types)) {
627 return;
628 }
629
630 // Parse and validate exclude_posts setting
631 $exclude_ids = [];
632 if (!empty($settings['exclude_posts'])) {
633 try {
634 $exclude_ids = $this->validate_exclude_posts($settings['exclude_posts']);
635 } catch (InvalidArgumentException $e) {
636 // Continue with empty array on validation failure - error details available in exception
637 }
638 }
639
640 // The static front page is emitted once as the explicit homepage entry,
641 // so exclude it here to avoid a duplicate <loc> (its permalink equals
642 // home_url('/')).
643 if (get_option('show_on_front') === 'page') {
644 $front_id = (int) get_option('page_on_front');
645 if ($front_id) {
646 $exclude_ids[] = $front_id;
647 }
648 }
649
650 // Resolve the ordered ID list in one indexed query, then hydrate in
651 // chunks via post__in. This avoids large OFFSET windows (which MySQL
652 // must scan-and-discard, making a full walk O(n^2)) while still loading
653 // only one chunk of full post objects into memory at a time. The
654 // original order is preserved (the id query and post__in hydration both
655 // use it).
656 $all_ids = get_posts($this->filter_query_args([
657 'post_type' => $post_types,
658 'post_status' => 'publish',
659 'numberposts' => -1,
660 'exclude' => $exclude_ids,
661 'orderby' => 'post_type post_date',
662 'order' => 'ASC DESC',
663 'fields' => 'ids',
664 ]));
665
666 if (empty($all_ids)) {
667 return;
668 }
669
670 // Walk the ID list with a moving window rather than array_chunk().
671 // array_chunk() builds a second array holding every element again, so
672 // peak memory was twice the ID list — on a 100k-post site that is ~16MB
673 // where ~8MB is needed, and this walk is the one part of an otherwise
674 // well-bounded routine with no ceiling (#402).
675 $total = count($all_ids);
676
677 for ($offset = 0; $offset < $total; $offset += self::ID_WALK_CHUNK) {
678 $chunk = array_slice($all_ids, $offset, self::ID_WALK_CHUNK);
679
680 $posts = get_posts($this->filter_query_args([
681 'post_type' => $post_types,
682 'post_status' => 'publish',
683 'numberposts' => count($chunk),
684 'post__in' => $chunk,
685 'orderby' => 'post__in', // preserve the resolved order
686 ]));
687
688 foreach ($posts as $post) {
689 if ($this->should_include_in_sitemap($post, $settings)) {
690 /**
691 * Filter a sitemap entry's permalink.
692 *
693 * The multilingual manager uses this to generate each
694 * translation's URL in its OWN language: the sitemap query
695 * deliberately runs with suppress_filters, and the cron
696 * rebuild runs with no language context at all, so a bare
697 * get_permalink() resolved every translation to the
698 * default-language URL — N entries sharing one <loc> (#409).
699 *
700 * @since 2.0.1
701 * @param string $url Permalink as WordPress resolved it.
702 * @param \WP_Post $post Post the entry describes.
703 */
704 $url = apply_filters('thinkrank_sitemap_post_permalink', get_permalink($post), $post);
705 $lastmod = gmdate('c', strtotime($post->post_modified_gmt));
706 $priority = $this->calculate_intelligent_priority($post, $post->post_type);
707 $changefreq = $this->calculate_change_frequency($post, $post->post_type);
708 $images = $this->extract_post_images($post, $settings);
709
710 yield $this->generate_url_entry($url, $lastmod, $priority, $changefreq, $images);
711 }
712 }
713
714 // Free the hydrated chunk before loading the next one.
715 unset($posts);
716 }
717 }
718
719 /**
720 * Resolve and bound the configured links-per-sitemap limit.
721 *
722 * @since 1.14.0
723 *
724 * @param array $settings Sitemap settings
725 * @return int Links per sitemap file (1–50000)
726 */
727 private function get_links_per_sitemap(array $settings): int {
728 $limit = !empty($settings['links_per_sitemap']) ? intval($settings['links_per_sitemap']) : 1000;
729
730 return max(1, min(50000, $limit));
731 }
732
733 /**
734 * Stream <url> entries for a taxonomy's terms, yielding one at a time and
735 * fetching terms in bounded chunks (number/offset) — so the paginated index
736 * generator never holds every term of a large taxonomy in memory at once.
737 *
738 * @param string $taxonomy Taxonomy name.
739 * @param array $settings Sitemap settings.
740 * @return \Generator<string> <url> entry strings.
741 */
742 private function collect_taxonomy_entries_iter(string $taxonomy, array $settings): \Generator {
743 $exclude_term_ids = [];
744 if (!empty($settings['exclude_terms'])) {
745 try {
746 $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
747 } catch (InvalidArgumentException $e) {
748 // Continue with an empty exclude list on validation failure.
749 }
750 }
751
752 // product_cat may legitimately have empty terms (products added later);
753 // every other taxonomy hides empties — matching fetch_taxonomies_optimized().
754 $hide_empty = $taxonomy !== 'product_cat';
755 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
756
757 // Resolve the ordered term IDs in one query, then hydrate in chunks via
758 // include. Avoids large OFFSET windows (O(n^2) over a full walk) while
759 // holding only one chunk of full term objects at a time.
760 $all_ids = get_terms($this->filter_term_query_args([
761 'taxonomy' => $taxonomy,
762 'hide_empty' => $hide_empty,
763 'exclude' => $exclude_term_ids,
764 'orderby' => 'count',
765 'order' => 'DESC',
766 'fields' => 'ids',
767 ]));
768
769 if (is_wp_error($all_ids) || empty($all_ids)) {
770 return;
771 }
772
773 // Same moving window as the post walk above, for the same reason.
774 $total = count($all_ids);
775
776 for ($offset = 0; $offset < $total; $offset += self::TERM_WALK_CHUNK) {
777 $chunk = array_slice($all_ids, $offset, self::TERM_WALK_CHUNK);
778
779 $terms = get_terms($this->filter_term_query_args([
780 'taxonomy' => $taxonomy,
781 'include' => $chunk,
782 'orderby' => 'include', // preserve the resolved order
783 'hide_empty' => false, // already filtered by the id query
784 ]));
785
786 if (is_wp_error($terms) || empty($terms)) {
787 continue;
788 }
789
790 foreach ($terms as $term) {
791 // A term the user marked noindex must not be advertised in the
792 // sitemap: the robots tag now honours term meta, so listing it
793 // here would have the sitemap contradict the page's own tag.
794 if ($this->term_is_noindexed((int) $term->term_id)) {
795 continue;
796 }
797
798 $url = get_term_link($term);
799 if (!is_wp_error($url)) {
800 // Omit lastmod for terms — the generation time is not a real
801 // modification time and would mislabel every term as just-changed.
802 yield $this->generate_url_entry($url, '', $priority, 'weekly');
803 }
804 }
805
806 unset($terms);
807 }
808 }
809
810 /**
811 * The XML declaration, ownership marker and optional stylesheet every
812 * sitemap document opens with.
813 *
814 * The marker is written unconditionally, and that is the point: removal on
815 * deactivate and uninstall deletes a web-root sitemap only when the file
816 * says it is ours, and our filenames are the canonical ones another SEO
817 * plugin writes too (#515). Tying the proof to `enable_styling` — the one
818 * marker older versions left — would mean a site with styling off either
819 * kept a shadowing file behind (#510) or had a competitor's deleted.
820 *
821 * @since 2.1.1
822 *
823 * @param array $settings Sitemap settings (read for `enable_styling`).
824 * @param string $stylesheet Stylesheet basename in static/xsl/.
825 * @return string Prolog lines, newline-terminated.
826 */
827 private function xml_prolog(array $settings, string $stylesheet): string {
828 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
829 $xml .= THINKRANK_SITEMAP_MARKER . "\n";
830
831 // The stylesheet is presentation only, so it stays opt-in.
832 if (!empty($settings['enable_styling'])) {
833 $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/' . $stylesheet) . '"?>' . "\n";
834 }
835
836 return $xml;
837 }
838
839 /**
840 * Wrap a set of <url> entry strings in a complete <urlset> document.
841 *
842 * @since 1.14.0
843 *
844 * @param array<string> $entries Entry strings
845 * @param array $settings Sitemap settings
846 * @param bool $with_image_ns Include the image sitemap namespace
847 * @return string Full sitemap XML
848 */
849 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
850 $xml = $this->xml_prolog($settings, 'sitemap.xsl');
851
852 if ($with_image_ns && !empty($settings['include_images'])) {
853 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
854 } else {
855 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
856 }
857
858 $xml .= implode('', $entries);
859 $xml .= '</urlset>';
860
861 return $xml;
862 }
863
864 /**
865 * Derive the filename/URL for a given pagination page.
866 *
867 * Page 1 keeps the base URL (e.g. /sitemap-posts.xml); pages 2+ insert the
868 * page number before the extension (e.g. /sitemap-posts-2.xml).
869 *
870 * @since 1.14.0
871 *
872 * @param string $url Base sitemap URL
873 * @param int $page 1-based page number
874 * @return string Paginated URL
875 */
876 private function paginate_url(string $url, int $page): string {
877 if ($page <= 1) {
878 return $url;
879 }
880
881 return (string) preg_replace('/\.xml$/i', '-' . $page . '.xml', $url);
882 }
883
884
885
886 /**
887 * Extract images from post for sitemap
888 *
889 * @since 1.0.0
890 *
891 * @param \WP_Post $post Post object
892 * @param array $settings Sitemap settings
893 * @return array Array of image data
894 */
895 private function extract_post_images(\WP_Post $post, array $settings): array {
896 $images = [];
897
898 // Skip if images are disabled
899 if (empty($settings['include_images'])) {
900 return $images;
901 }
902
903 // Get featured image if enabled
904 if (!empty($settings['include_featured_images'])) {
905 $featured_image = $this->get_featured_image($post->ID);
906 if ($featured_image) {
907 $images[] = $featured_image;
908 }
909 }
910
911 // Extract images from content
912 $content_images = $this->extract_content_images($post->post_content);
913 $images = array_merge($images, $content_images);
914
915 // Remove duplicates based on URL
916 $unique_images = [];
917 $seen_urls = [];
918 foreach ($images as $image) {
919 if (!in_array($image['url'], $seen_urls, true)) {
920 $unique_images[] = $image;
921 $seen_urls[] = $image['url'];
922 }
923 }
924
925 return $unique_images;
926 }
927
928 /**
929 * Get featured image data
930 *
931 * @since 1.0.0
932 *
933 * @param int $post_id Post ID
934 * @return array|null Featured image data or null
935 */
936 private function get_featured_image(int $post_id): ?array {
937 $thumbnail_id = get_post_thumbnail_id($post_id);
938 if (!$thumbnail_id) {
939 return null;
940 }
941
942 $image_url = wp_get_attachment_image_url($thumbnail_id, 'full');
943 if (!$image_url) {
944 return null;
945 }
946
947 $image_title = get_the_title($thumbnail_id);
948 $image_alt = get_post_meta($thumbnail_id, '_wp_attachment_image_alt', true);
949
950 return [
951 'url' => $image_url,
952 'title' => $image_title ?: '',
953 'alt' => $image_alt ?: ''
954 ];
955 }
956
957 /**
958 * Extract images from post content
959 *
960 * @since 1.0.0
961 *
962 * @param string $content Post content
963 * @return array Array of image data
964 */
965 private function extract_content_images(string $content): array {
966 $images = [];
967
968 // Find all img tags in content
969 preg_match_all('/<img[^>]+>/i', $content, $img_tags);
970
971 foreach ($img_tags[0] as $img_tag) {
972 // Extract src attribute
973 if (preg_match('/src=["\']([^"\']+)["\']/', $img_tag, $src_match)) {
974 $image_url = $src_match[1];
975
976 // Skip if not a valid URL or external image
977 if (!filter_var($image_url, FILTER_VALIDATE_URL)) {
978 continue;
979 }
980
981 // Extract title and alt attributes
982 $title = '';
983 $alt = '';
984
985 if (preg_match('/title=["\']([^"\']*)["\']/', $img_tag, $title_match)) {
986 $title = $title_match[1];
987 }
988
989 if (preg_match('/alt=["\']([^"\']*)["\']/', $img_tag, $alt_match)) {
990 $alt = $alt_match[1];
991 }
992
993 $images[] = [
994 'url' => $image_url,
995 'title' => $title,
996 'alt' => $alt
997 ];
998 }
999 }
1000
1001 return $images;
1002 }
1003
1004 /**
1005 * Get enabled taxonomies based on settings
1006 *
1007 * @since 1.0.0
1008 * @param array $settings Sitemap settings
1009 * @return array Array of enabled taxonomies
1010 */
1011 private function get_enabled_taxonomies(array $settings): array {
1012 $taxonomies = [];
1013
1014 // Core taxonomies based on settings
1015 if (!empty($settings['include_categories'])) { // �
1016 Evidence-based field name
1017 $taxonomies[] = 'category';
1018 }
1019
1020 if (!empty($settings['include_tags'])) { // �
1021 Evidence-based field name
1022 $taxonomies[] = 'post_tag';
1023 }
1024
1025 // Auto-detect public custom taxonomies
1026 $custom_taxonomies = get_taxonomies([
1027 'public' => true,
1028 '_builtin' => false
1029 ], 'names');
1030
1031 foreach ($custom_taxonomies as $taxonomy) {
1032 if (!$this->should_include_taxonomy($taxonomy)) {
1033 continue;
1034 }
1035
1036 // Same as the post-type walk above: an explicit per-taxonomy flag
1037 // now decides, and an unset flag keeps the previous "included"
1038 // behaviour (#660).
1039 if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $settings)) {
1040 $taxonomies[] = $taxonomy;
1041 }
1042 }
1043
1044 return array_unique($taxonomies);
1045 }
1046
1047
1048
1049 /**
1050 * Fetch taxonomies using optimized combined query
1051 *
1052 * @since 1.0.0
1053 *
1054 * @param array $taxonomies Array of taxonomy names to fetch
1055 * @param array $settings Sitemap settings
1056 * @return array Grouped terms by taxonomy
1057 */
1058 private function fetch_taxonomies_optimized(array $taxonomies, array $settings): array {
1059 if (empty($taxonomies)) {
1060 return [];
1061 }
1062
1063 // Parse and validate exclude_terms setting
1064 $exclude_term_ids = [];
1065 if (!empty($settings['exclude_terms'])) {
1066 try {
1067 $exclude_term_ids = $this->validate_exclude_terms($settings['exclude_terms']);
1068 } catch (InvalidArgumentException $e) {
1069 // Continue with empty array on validation failure - error details available in exception
1070 }
1071 }
1072
1073 // Determine hide_empty setting based on taxonomies
1074 $hide_empty = true;
1075 foreach ($taxonomies as $taxonomy) {
1076 // For product categories, don't hide empty categories since they might not have products yet
1077 if ($taxonomy === 'product_cat') {
1078 $hide_empty = false;
1079 break;
1080 }
1081 }
1082
1083 // OPTIMIZED: Single query for multiple taxonomies
1084 $all_terms = get_terms($this->filter_term_query_args([
1085 'taxonomy' => $taxonomies, // �
1086 Multiple taxonomies in single query
1087 'hide_empty' => $hide_empty,
1088 'exclude' => $exclude_term_ids,
1089 'orderby' => 'taxonomy count',
1090 'order' => 'ASC DESC' // Order by taxonomy ASC, then count DESC
1091 ]));
1092
1093 if (is_wp_error($all_terms)) {
1094 return [];
1095 }
1096
1097 // Drop terms the user marked noindex. This path feeds the single general
1098 // sitemap while collect_taxonomy_entries_iter() feeds the segmented ones,
1099 // so both need the filter or the two disagree about the same term.
1100 $all_terms = array_values(array_filter(
1101 $all_terms,
1102 fn($term) => !$this->term_is_noindexed((int) $term->term_id)
1103 ));
1104
1105 // Group terms by taxonomy
1106 return $this->group_terms_by_taxonomy($all_terms);
1107 }
1108
1109 /**
1110 * Group terms by taxonomy
1111 *
1112 * @since 1.0.0
1113 *
1114 * @param array $terms Array of term objects
1115 * @return array Grouped terms by taxonomy
1116 */
1117 private function group_terms_by_taxonomy(array $terms): array {
1118 $grouped = [];
1119
1120 foreach ($terms as $term) {
1121 $taxonomy = $term->taxonomy; // �
1122 Evidence-based property name
1123
1124 if (!isset($grouped[$taxonomy])) {
1125 $grouped[$taxonomy] = [];
1126 }
1127
1128 $grouped[$taxonomy][] = $term;
1129 }
1130
1131 return $grouped;
1132 }
1133
1134 /**
1135 * Process taxonomy terms to XML entries
1136 *
1137 * @since 1.0.0
1138 *
1139 * @param array $terms Array of term objects
1140 * @param string $taxonomy Taxonomy name
1141 * @param array $settings Sitemap settings
1142 * @return string XML entries
1143 */
1144 private function process_taxonomies_to_xml(array $terms, string $taxonomy, array $settings): string {
1145 $xml = '';
1146
1147 foreach ($terms as $term) {
1148 $url = get_term_link($term);
1149 if (!is_wp_error($url)) {
1150 // Omit lastmod for terms (generation time is not a real
1151 // modification time).
1152 $lastmod = '';
1153 $priority = $taxonomy === 'category' ? 0.6 : 0.4;
1154 $changefreq = 'weekly';
1155
1156 $xml .= $this->generate_url_entry($url, $lastmod, $priority, $changefreq);
1157 }
1158 }
1159
1160 return $xml;
1161 }
1162
1163 /**
1164 * Calculate intelligent priority based on content factors
1165 *
1166 * @since 1.0.0
1167 *
1168 * @param \WP_Post $post Post object
1169 * @param string $post_type Post type
1170 * @return float Priority value between 0.1 and 1.0
1171 */
1172 private function calculate_intelligent_priority(\WP_Post $post, string $post_type): float {
1173 $base_priority = $post_type === 'page' ? 0.9 : 0.8;
1174
1175 // Factors that can adjust priority
1176 $adjustments = 0;
1177
1178 // Recent content gets higher priority
1179 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
1180 if ($days_old < 30) {
1181 $adjustments += 0.1; // Recent content boost
1182 } elseif ($days_old > 365) {
1183 $adjustments -= 0.1; // Older content penalty
1184 }
1185
1186 // Content length factor
1187 $content_length = strlen(wp_strip_all_tags($post->post_content));
1188 if ($content_length > 2000) {
1189 $adjustments += 0.05; // Comprehensive content boost
1190 } elseif ($content_length < 500) {
1191 $adjustments -= 0.1; // Thin content penalty
1192 }
1193
1194 // Special page types get higher priority
1195 if ($post_type === 'page') {
1196 $page_template = get_page_template_slug($post->ID);
1197 if (in_array($page_template, ['page-home.php', 'front-page.php'], true) ||
1198 (int) $post->ID === (int) get_option('page_on_front')) {
1199 $base_priority = 1.0; // Homepage gets maximum priority
1200 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1201 $adjustments += 0.05; // Important pages boost
1202 }
1203 }
1204
1205 // Ensure priority stays within valid range
1206 $final_priority = max(0.1, min(1.0, $base_priority + $adjustments));
1207
1208 return round($final_priority, 1);
1209 }
1210
1211 /**
1212 * Calculate change frequency based on content type and age
1213 *
1214 * @since 1.0.0
1215 *
1216 * @param \WP_Post $post Post object
1217 * @param string $post_type Post type
1218 * @return string Change frequency
1219 */
1220 private function calculate_change_frequency(\WP_Post $post, string $post_type): string {
1221 // Pages typically change less frequently
1222 if ($post_type === 'page') {
1223 $page_template = get_page_template_slug($post->ID);
1224 if ((int) $post->ID === (int) get_option('page_on_front')) {
1225 return 'daily'; // Homepage changes frequently
1226 } elseif (in_array($page_template, ['page-contact.php', 'page-about.php'], true)) {
1227 return 'monthly'; // Static pages change monthly
1228 }
1229 return 'yearly'; // Other pages change rarely
1230 }
1231
1232 // Posts frequency based on age and type
1233 $days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
1234
1235 if ($days_old < 7) {
1236 return 'daily'; // Very recent posts
1237 } elseif ($days_old < 30) {
1238 return 'weekly'; // Recent posts
1239 } elseif ($days_old < 365) {
1240 return 'monthly'; // Older posts
1241 }
1242
1243 return 'yearly'; // Very old posts
1244 }
1245
1246 /**
1247 * Determine if content should be included in sitemap
1248 *
1249 * @since 1.0.0
1250 *
1251 * @param \WP_Post $post Post object
1252 * @param array $settings Sitemap settings
1253 * @return bool Whether to include in sitemap
1254 */
1255 private function should_include_in_sitemap(\WP_Post $post, array $settings): bool {
1256 // The WooCommerce cart, checkout and account pages are transactional,
1257 // never indexable, and generate_default_robots_rules() already emits a
1258 // Disallow for each of them. Listing them here submitted URLs our own
1259 // robots.txt blocks, which Search Console reports as "Submitted URL
1260 // blocked by robots.txt". Yoast and Rank Math exclude the same three.
1261 if (in_array($post->ID, $this->woocommerce_excluded_page_ids(), true)) {
1262 return false;
1263 }
1264
1265 // Respect user setting for password protected content
1266 if (!empty($post->post_password) && !empty($settings['exclude_password_protected'])) {
1267 return false;
1268 }
1269
1270 // Respect user setting for private posts
1271 if ($post->post_status === 'private' && !empty($settings['exclude_private_posts'])) {
1272 return false;
1273 }
1274
1275 // Only published (and, per setting, private) content belongs in the
1276 // sitemap. Content-quality heuristics (length, "demo"/"test"/"sample"
1277 // in the title, "lorem ipsum" text) were intentionally removed: an XML
1278 // sitemap should list every indexable published URL. Filtering by
1279 // description length silently dropped legitimate WooCommerce products
1280 // with short descriptions, and the substring title match excluded real
1281 // pages such as "Demo" or "Product Samples". Indexability is governed
1282 // by noindex directives below, not by heuristics.
1283 if (!in_array($post->post_status, ['publish', 'private'], true)) {
1284 return false;
1285 }
1286
1287 // Check if post overrides robots and sets noindex.
1288 if ((bool) get_post_meta($post->ID, '_thinkrank_robots_meta_enabled', true)) {
1289 $raw = get_post_meta($post->ID, '_thinkrank_robots_meta', true);
1290 if (is_string($raw) && $raw !== '') {
1291 $robots = json_decode($raw, true);
1292 if (is_array($robots) && !empty($robots['noindex'])) {
1293 return false;
1294 }
1295 }
1296 }
1297
1298 return true;
1299 }
1300
1301 /**
1302 * WooCommerce pages that must never reach the sitemap.
1303 *
1304 * Resolved through wc_get_page_id() so a store that moved or renamed its
1305 * cart/checkout/account pages is still matched. Returns an empty list when
1306 * WooCommerce is not active. Memoised — should_include_in_sitemap() runs
1307 * once per post.
1308 *
1309 * @since 2.0.1
1310 *
1311 * @return int[] Page IDs to exclude.
1312 */
1313 private function woocommerce_excluded_page_ids(): array {
1314 if ($this->woocommerce_excluded_page_ids !== null) {
1315 return $this->woocommerce_excluded_page_ids;
1316 }
1317
1318 $ids = [];
1319
1320 if (function_exists('wc_get_page_id')) {
1321 foreach (['cart', 'checkout', 'myaccount'] as $page) {
1322 $id = (int) wc_get_page_id($page);
1323 // wc_get_page_id() returns -1 when the page is not configured.
1324 if ($id > 0) {
1325 $ids[] = $id;
1326 }
1327 }
1328 }
1329
1330 $this->woocommerce_excluded_page_ids = $ids;
1331
1332 return $ids;
1333 }
1334
1335 /**
1336 * Whether a term carries an explicit noindex override.
1337 *
1338 * Mirrors the post-side check in should_include_post(); terms store the same
1339 * `_thinkrank_robots_meta_enabled` / `_thinkrank_robots_meta` keys, written
1340 * by the update-term-seo ability and by the SEO importer.
1341 *
1342 * @since 1.31.0
1343 *
1344 * @param int $term_id Term to test.
1345 * @return bool True when the term is marked noindex.
1346 */
1347 private function term_is_noindexed(int $term_id): bool {
1348 if (!(bool) get_term_meta($term_id, '_thinkrank_robots_meta_enabled', true)) {
1349 return false;
1350 }
1351
1352 $raw = get_term_meta($term_id, '_thinkrank_robots_meta', true);
1353 if (!is_string($raw) || $raw === '') {
1354 return false;
1355 }
1356
1357 $robots = json_decode($raw, true);
1358
1359 return is_array($robots) && !empty($robots['noindex']);
1360 }
1361
1362 /**
1363 * Count total URLs in sitemap
1364 *
1365 * @since 1.0.0
1366 *
1367 * @param array $settings Sitemap settings
1368 * @return int Total URL count
1369 */
1370 public function count_sitemap_urls(array $settings): int {
1371 $count = 1; // Homepage
1372
1373 if (!empty($settings['include_posts'])) {
1374 $count += wp_count_posts('post')->publish;
1375 }
1376
1377 if (!empty($settings['include_pages'])) {
1378 $count += wp_count_posts('page')->publish;
1379 }
1380
1381 if (!empty($settings['include_categories'])) {
1382 $count += wp_count_terms(['taxonomy' => 'category', 'hide_empty' => true]);
1383 }
1384
1385 if (!empty($settings['include_tags'])) {
1386 $count += wp_count_terms(['taxonomy' => 'post_tag', 'hide_empty' => true]);
1387 }
1388
1389 return $count;
1390 }
1391
1392 /**
1393 * Handle content changes for auto-generation
1394 *
1395 * @since 1.0.0
1396 * @param int $post_id Post ID
1397 * @param \WP_Post $post Post object
1398 * @return void
1399 */
1400 public function handle_content_change(int $post_id, \WP_Post $post): void {
1401 // Skip if auto-generation is disabled
1402 if (!$this->should_auto_generate()) {
1403 return;
1404 }
1405
1406 // Skip autosaves and revisions
1407 if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
1408 return;
1409 }
1410
1411 // Only process published content
1412 if ($post->post_status !== 'publish') {
1413 return;
1414 }
1415
1416 // Check if this post type should trigger regeneration
1417 if (!$this->should_include_post_type($post->post_type)) {
1418 return;
1419 }
1420
1421 // Schedule debounced regeneration
1422 $this->schedule_debounced_regeneration();
1423 }
1424
1425 /**
1426 * Handle content deletion for auto-generation
1427 *
1428 * @since 1.0.0
1429 * @param int $post_id Post ID
1430 * @return void
1431 */
1432 public function handle_content_deletion(int $post_id): void {
1433 // Skip if auto-generation is disabled
1434 if (!$this->should_auto_generate()) {
1435 return;
1436 }
1437
1438 $post = get_post($post_id);
1439 if (!$post) {
1440 return;
1441 }
1442
1443 // Check if this post type should trigger regeneration
1444 if (!$this->should_include_post_type($post->post_type)) {
1445 return;
1446 }
1447
1448 // Schedule debounced regeneration
1449 $this->schedule_debounced_regeneration();
1450 }
1451
1452 /**
1453 * Handle content change by ID (for untrash, etc.)
1454 *
1455 * @since 1.0.0
1456 * @param int $post_id Post ID
1457 * @return void
1458 */
1459 public function handle_content_change_by_id(int $post_id): void {
1460 $post = get_post($post_id);
1461 if ($post) {
1462 $this->handle_content_change($post_id, $post);
1463 }
1464 }
1465
1466 /**
1467 * Handle taxonomy changes for auto-generation
1468 *
1469 * @since 1.0.0
1470 * @param int $term_id Term ID
1471 * @param int $tt_id Term taxonomy ID
1472 * @param string $taxonomy Taxonomy slug
1473 * @return void
1474 */
1475 public function handle_taxonomy_change(int $term_id, int $tt_id, string $taxonomy): void {
1476 // Skip if auto-generation is disabled
1477 if (!$this->should_auto_generate()) {
1478 return;
1479 }
1480
1481 // Check if this taxonomy should trigger regeneration
1482 if (!$this->should_include_taxonomy($taxonomy)) {
1483 return;
1484 }
1485
1486 // Schedule debounced regeneration
1487 $this->schedule_debounced_regeneration();
1488 }
1489
1490 /**
1491 * Check if auto-generation is enabled
1492 *
1493 * @since 1.0.0
1494 * @return bool True if auto-generation is enabled
1495 */
1496 private function should_auto_generate(): bool {
1497 $settings = $this->get_settings('site');
1498
1499 // Check if sitemap is enabled
1500 if (empty($settings['enabled'])) {
1501 return false;
1502 }
1503
1504 // Check if auto-generation is enabled
1505 return !empty($settings['auto_generate']);
1506 }
1507
1508 /**
1509 * Get enabled post types based on settings
1510 *
1511 * @since 1.0.0
1512 * @param array $settings Sitemap settings
1513 * @return array Array of enabled post types
1514 */
1515 private function get_enabled_post_types(array $settings): array {
1516 $post_types = [];
1517
1518 // Core post types based on settings
1519 if (!empty($settings['include_posts'])) {
1520 $post_types[] = 'post';
1521 }
1522
1523 if (!empty($settings['include_pages'])) {
1524 $post_types[] = 'page';
1525 }
1526
1527 // Auto-detect public custom post types that should be included.
1528 //
1529 // A custom type's `include_<slug>` / `exclude_<slug>` flag is honoured
1530 // here (#660). It was previously stored — additional_setting_keys()
1531 // has always let those keys through — but never read, so a CPT was in
1532 // the sitemap whatever the setting said. Unset still means included, so
1533 // a site that never touched the flag is unaffected.
1534 $custom_post_types = get_post_types([
1535 'public' => true,
1536 '_builtin' => false
1537 ], 'names');
1538
1539 foreach ($custom_post_types as $post_type) {
1540 if (!$this->should_include_post_type($post_type)) {
1541 continue;
1542 }
1543
1544 if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $post_type, $settings)) {
1545 $post_types[] = $post_type;
1546 }
1547 }
1548
1549 return array_unique($post_types);
1550 }
1551
1552 /**
1553 * Check if post type should trigger regeneration
1554 *
1555 * @since 1.0.0
1556 * @param string $post_type Post type
1557 * @return bool True if post type should trigger regeneration
1558 */
1559 private function should_include_post_type(string $post_type): bool {
1560 // Match Rank Math: only publicly viewable post types belong in the
1561 // sitemap (public && publicly_queryable). Additionally skip post types
1562 // that opt out of front-end search (exclude_from_search => true) — e.g.
1563 // Templately's internal `templately_library` store — which are template
1564 // records, not standalone indexable URLs. BetterDocs `docs` and
1565 // WooCommerce `product` register exclude_from_search => false, so they
1566 // remain included.
1567 // The predicate lives in Content_Type_Settings so the matrix can ask
1568 // the same question before offering a switch for this post type.
1569 return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_post_type($post_type);
1570 }
1571
1572 /**
1573 * Check if taxonomy should trigger regeneration
1574 *
1575 * @since 1.0.0
1576 * @param string $taxonomy Taxonomy slug
1577 * @return bool True if taxonomy should trigger regeneration
1578 */
1579 private function should_include_taxonomy(string $taxonomy): bool {
1580 // Public taxonomies only, and only the ones this generator can actually
1581 // emit — the same predicate the content-type matrix asks before it
1582 // offers a sitemap switch for one (presets still control which
1583 // sitemaps are created).
1584 return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_taxonomy($taxonomy);
1585 }
1586
1587 /**
1588 * Schedule debounced sitemap regeneration
1589 *
1590 * @since 1.0.0
1591 * @return void
1592 */
1593 private function schedule_debounced_regeneration(): void {
1594 $this->mark_regeneration_pending('content');
1595 $this->debounce_event('thinkrank_regenerate_sitemap');
1596 }
1597
1598 /**
1599 * Schedule (or keep) the debounced single event behind a regeneration hook.
1600 *
1601 * An event that is already due is left alone. WP-Cron only runs when a
1602 * request arrives, so on a site with DISABLE_WP_CRON, a blocked loopback or
1603 * little traffic an overdue event can sit in the queue for a long time —
1604 * clearing and re-scheduling it on every save pushed the rebuild
1605 * permanently 30 seconds into the future and the sitemap never updated
1606 * (#629). Debouncing only against an event that has not come due yet keeps
1607 * the bulk-edit coalescing without starving the rebuild.
1608 *
1609 * @since 2.2.1
1610 * @param string $hook Regeneration hook to debounce.
1611 * @return void
1612 */
1613 private function debounce_event(string $hook): void {
1614 $next = wp_next_scheduled($hook);
1615
1616 if ($next !== false) {
1617 if ($next <= time()) {
1618 return;
1619 }
1620
1621 wp_clear_scheduled_hook($hook);
1622 }
1623
1624 wp_schedule_single_event(time() + self::REGENERATION_DEBOUNCE, $hook);
1625 }
1626
1627 /**
1628 * Record that a rebuild is outstanding, so an overdue one can be taken over
1629 * by a later request and its staleness surfaced in the UI.
1630 *
1631 * `since` is the *oldest* outstanding change: it is what the takeover grace
1632 * and the admin staleness warning are measured from, so successive edits
1633 * must not push it forward. A settings change outranks a content change —
1634 * it rebuilds regardless of the auto_generate toggle and handles a sitemap
1635 * that has just been disabled — so once one is outstanding it stays the
1636 * recorded source until the rebuild lands.
1637 *
1638 * @since 2.2.1
1639 * @param string $source Either 'content' or 'settings'.
1640 * @return void
1641 */
1642 private function mark_regeneration_pending(string $source): void {
1643 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1644 $pending = is_array($pending) ? $pending : [];
1645
1646 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1647 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1648 $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1649
1650 update_option(
1651 self::REGENERATION_PENDING_OPTION,
1652 [
1653 'since' => $since,
1654 'source' => $source,
1655 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
1656 'next_attempt' => !empty($pending['next_attempt'])
1657 ? (int) $pending['next_attempt']
1658 : time() + self::REGENERATION_TAKEOVER_GRACE,
1659 // Bumped on every change so a rebuild can tell whether the edit
1660 // it started for is still the newest one outstanding.
1661 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
1662 ],
1663 true
1664 );
1665 }
1666
1667 /**
1668 * The revision of the outstanding rebuild, for
1669 * {@see mark_regeneration_complete()} to compare against once it is done.
1670 *
1671 * @since 2.2.1
1672 * @return int Current revision, 0 when nothing is outstanding.
1673 */
1674 private function current_regeneration_revision(): int {
1675 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1676
1677 return (is_array($pending) && !empty($pending['revision'])) ? (int) $pending['revision'] : 0;
1678 }
1679
1680 /**
1681 * Clear the outstanding-rebuild marker and any recorded failure.
1682 *
1683 * Public because a manual generation satisfies whatever the automatic path
1684 * was still waiting to write.
1685 *
1686 * @since 2.2.1
1687 * @return void
1688 */
1689 public function mark_regeneration_complete(?int $revision = null): void {
1690 // The write succeeded, so whatever failure was on record is history.
1691 if (get_option(self::REGENERATION_ERROR_OPTION, null) !== null) {
1692 delete_option(self::REGENERATION_ERROR_OPTION);
1693 }
1694
1695 $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
1696
1697 if ($pending === null) {
1698 return;
1699 }
1700
1701 // A change that landed while this rebuild was running is not covered by
1702 // the files it just wrote, so it has to stay outstanding — otherwise, on
1703 // a site where WP-Cron never fires, clearing the marker would strand it
1704 // exactly the way #629 stranded everything.
1705 if (
1706 $revision !== null
1707 && is_array($pending)
1708 && (int) ($pending['revision'] ?? 0) !== $revision
1709 ) {
1710 return;
1711 }
1712
1713 delete_option(self::REGENERATION_PENDING_OPTION);
1714 }
1715
1716 /**
1717 * Record a failed regeneration instead of discarding it.
1718 *
1719 * Keeps the pending marker in place so the rebuild is retried, but backs the
1720 * next attempt off exponentially (capped) so a persistently failing
1721 * generation cannot run on every admin request.
1722 *
1723 * @since 2.2.1
1724 * @param string $message Failure detail.
1725 * @param string $source Either 'content' or 'settings'.
1726 * @return void
1727 */
1728 private function record_regeneration_failure(string $message, string $source): void {
1729 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1730 $pending = is_array($pending) ? $pending : [];
1731 $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
1732
1733 $backoff = min(
1734 self::REGENERATION_TAKEOVER_GRACE * (2 ** min($attempts, 10)),
1735 self::REGENERATION_MAX_BACKOFF
1736 );
1737
1738 // Same precedence mark_regeneration_pending() enforces: a settings
1739 // rebuild outranks a content one and must not be downgraded by a failed
1740 // attempt. Overwriting it routed the retry back through the content
1741 // path, where should_auto_generate() can be false and the completion
1742 // marker then discards the settings rebuild entirely. Only the
1743 // outstanding rebuild is upgraded — the recorded error keeps reporting
1744 // whichever attempt actually failed.
1745 $current = isset($pending['source']) ? (string) $pending['source'] : '';
1746 $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1747
1748 update_option(
1749 self::REGENERATION_PENDING_OPTION,
1750 [
1751 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
1752 'source' => $pending_source,
1753 'attempts' => $attempts,
1754 'next_attempt' => time() + $backoff,
1755 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
1756 ],
1757 true
1758 );
1759
1760 update_option(
1761 self::REGENERATION_ERROR_OPTION,
1762 [
1763 'message' => $message,
1764 'source' => $source,
1765 'attempts' => $attempts,
1766 'time' => time(),
1767 ],
1768 false
1769 );
1770
1771 if (defined('WP_DEBUG') && WP_DEBUG) {
1772 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1773 error_log(sprintf('ThinkRank: sitemap %s regeneration failed — %s', $source, $message));
1774 }
1775 }
1776
1777 /**
1778 * Is a rebuild outstanding and past the point where WP-Cron should have run
1779 * it?
1780 *
1781 * Deliberately cheap — one autoloaded option read — because it is consulted
1782 * on every admin request to decide whether the takeover is needed.
1783 *
1784 * @since 2.2.1
1785 * @return bool True when a request should rebuild the sitemap itself.
1786 */
1787 public static function has_overdue_regeneration(): bool {
1788 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1789
1790 if (!is_array($pending) || empty($pending['since'])) {
1791 return false;
1792 }
1793
1794 $due = !empty($pending['next_attempt'])
1795 ? (int) $pending['next_attempt']
1796 : (int) $pending['since'] + self::REGENERATION_TAKEOVER_GRACE;
1797
1798 return time() >= $due;
1799 }
1800
1801 /**
1802 * Rebuild the sitemap in-request when WP-Cron has not delivered.
1803 *
1804 * Hooked on `shutdown` for admin, REST and CLI requests only (see
1805 * Plugin::register_sitemap_cron_listeners()), so the work happens after the
1806 * response has been sent and never adds latency to a visitor page view.
1807 *
1808 * @since 2.2.1
1809 * @return void
1810 */
1811 public function run_overdue_regeneration(): void {
1812 if (!self::has_overdue_regeneration()) {
1813 return;
1814 }
1815
1816 // Cron is running: it is about to do exactly this work.
1817 if (wp_doing_cron()) {
1818 return;
1819 }
1820
1821 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1822 $source = (is_array($pending) && isset($pending['source'])) ? (string) $pending['source'] : 'content';
1823
1824 if ($source === 'settings') {
1825 $this->regenerate_sitemap_from_settings();
1826 return;
1827 }
1828
1829 $this->auto_regenerate_sitemap();
1830 }
1831
1832 /**
1833 * Acquire the shared generation lock.
1834 *
1835 * @since 2.2.1
1836 * @return bool True when this process may generate.
1837 */
1838 private function acquire_generation_lock(): bool {
1839 if (get_transient(self::GENERATION_LOCK_TRANSIENT)) {
1840 return false;
1841 }
1842
1843 set_transient(self::GENERATION_LOCK_TRANSIENT, time(), 5 * MINUTE_IN_SECONDS);
1844
1845 return true;
1846 }
1847
1848 /**
1849 * Release the shared generation lock.
1850 *
1851 * @since 2.2.1
1852 * @return void
1853 */
1854 private function release_generation_lock(): void {
1855 delete_transient(self::GENERATION_LOCK_TRANSIENT);
1856 }
1857
1858 /**
1859 * Report how automatic regeneration is faring, for the admin UI.
1860 *
1861 * The feature used to fail invisibly: `last_generated` simply stopped
1862 * advancing and nothing drew attention to it (#629).
1863 *
1864 * @since 2.2.1
1865 * @return array Health payload.
1866 */
1867 public function get_regeneration_health(): array {
1868 $settings = $this->get_settings('site');
1869 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1870 $pending = is_array($pending) ? $pending : [];
1871 $error = get_option(self::REGENERATION_ERROR_OPTION, []);
1872 $error = is_array($error) ? $error : [];
1873
1874 $since = !empty($pending['since']) ? (int) $pending['since'] : 0;
1875
1876 $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap');
1877 if ($next_scheduled === false) {
1878 $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap_settings');
1879 }
1880
1881 return [
1882 'auto_generate' => !empty($settings['auto_generate']),
1883 'last_generated' => $settings['last_generated'] ?? '',
1884 'pending_since' => $since ? gmdate('c', $since) : null,
1885 'pending_seconds' => $since ? max(0, time() - $since) : 0,
1886 'next_scheduled' => $next_scheduled ? gmdate('c', (int) $next_scheduled) : null,
1887 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON,
1888 'stale' => $this->is_sitemap_stale($settings),
1889 'last_error' => !empty($error['message'])
1890 ? [
1891 'message' => (string) $error['message'],
1892 'source' => isset($error['source']) ? (string) $error['source'] : 'content',
1893 'time' => !empty($error['time']) ? gmdate('c', (int) $error['time']) : null,
1894 ]
1895 : null,
1896 ];
1897 }
1898
1899 /**
1900 * Has published content changed since the served sitemap was last written?
1901 *
1902 * Uses core's cached last-modified lookup, which only considers published
1903 * posts — the same content the sitemap covers.
1904 *
1905 * @since 2.2.1
1906 * @param array $settings Sitemap settings.
1907 * @return bool True when the sitemap is behind the content.
1908 */
1909 private function is_sitemap_stale(array $settings): bool {
1910 if (empty($settings['enabled']) || empty($settings['last_generated'])) {
1911 // Never generated is already reported separately by the UI.
1912 return false;
1913 }
1914
1915 $generated = strtotime((string) $settings['last_generated']);
1916 if (!$generated) {
1917 return false;
1918 }
1919
1920 $modified = get_lastpostmodified('gmt');
1921 if (!$modified) {
1922 return false;
1923 }
1924
1925 $modified = strtotime($modified . ' UTC');
1926 if (!$modified) {
1927 return false;
1928 }
1929
1930 // A minute of slack keeps a rebuild that ran alongside the edit from
1931 // reporting itself as stale.
1932 return $modified > ($generated + MINUTE_IN_SECONDS);
1933 }
1934
1935 /**
1936 * Public entry point to debounce-rebuild the sitemap after a settings change
1937 * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
1938 * file reflects the new settings instead of going stale until a content edit.
1939 *
1940 * @return void
1941 */
1942 public function schedule_regeneration(): void {
1943 // Debounce against rapid successive saves, but use the settings-specific
1944 // hook so the rebuild runs regardless of the auto_generate toggle (which
1945 // only governs content-change-triggered regeneration).
1946 $this->mark_regeneration_pending('settings');
1947 $this->debounce_event('thinkrank_regenerate_sitemap_settings');
1948 }
1949
1950 /**
1951 * Rebuild the served sitemap after an explicit settings change.
1952 *
1953 * Unlike {@see auto_regenerate_sitemap()}, this is NOT gated on the
1954 * auto_generate setting: the user deliberately changed inclusion rules and
1955 * expects the served file to reflect them even if content-triggered
1956 * auto-generation is turned off. Still respects the master `enabled` flag.
1957 *
1958 * @return void
1959 */
1960 public function regenerate_sitemap_from_settings(): void {
1961 if (!$this->acquire_generation_lock()) {
1962 // A manual generation (or another request's takeover) is already
1963 // writing the files; the pending marker survives so this rebuild is
1964 // retried rather than lost.
1965 return;
1966 }
1967
1968 try {
1969 $settings = $this->get_settings('site');
1970 if (empty($settings['enabled'])) {
1971 // The sitemap was disabled: remove the previously generated static
1972 // files so the web server stops serving a stale sitemap that
1973 // crawlers would otherwise keep fetching.
1974 $this->delete_published_sitemaps();
1975 $this->mark_regeneration_complete();
1976 return;
1977 }
1978
1979 $revision = $this->current_regeneration_revision();
1980
1981 if ($this->generate_and_save($settings)) {
1982 $this->mark_regeneration_complete($revision);
1983 } else {
1984 $this->record_regeneration_failure(
1985 __('The sitemap files could not be written to the site root.', 'thinkrank'),
1986 'settings'
1987 );
1988 }
1989 } catch (\Throwable $e) {
1990 $this->record_regeneration_failure($e->getMessage(), 'settings');
1991 } finally {
1992 $this->release_generation_lock();
1993 }
1994 }
1995
1996 /**
1997 * Remove every static sitemap file ThinkRank publishes to the web root.
1998 *
1999 * Called when the sitemap feature is disabled, by the cleanup route, and by
2000 * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
2001 * children (incl. paginated -N pages), and /local-sitemap.xml stop being
2002 * served. Only ThinkRank's own filenames are targeted; WordPress core's
2003 * wp-sitemap.xml and any other plugin's sitemap in the web root are left
2004 * untouched.
2005 *
2006 * @since 1.31.0 Returns the filenames removed, and accepts the settings to
2007 * derive them from, so a caller that already read them (and
2008 * needs to report what went) does not have to re-read or
2009 * re-derive the name list.
2010 * @since 2.1.0 Delegates to thinkrank_webroot_delete_sitemaps(). Uninstall
2011 * needs the same removal but has no autoloader to reach this
2012 * class, so the logic moved to includes/cleanup-webroot.php
2013 * and this stays as the in-plugin entry point.
2014 *
2015 * @param array|null $settings Optional. Sitemap settings; defaults to the
2016 * saved site settings.
2017 * @return array{deleted: string[], failed: string[]} Basenames removed, and
2018 * those that existed but could not be removed.
2019 */
2020 public function delete_published_sitemaps(?array $settings = null): array {
2021 return thinkrank_webroot_delete_sitemaps($settings ?? $this->get_settings('site'));
2022 }
2023
2024 /**
2025 * Every child-sitemap filename this site could have published.
2026 *
2027 * @since 1.31.0
2028 * @since 2.1.0 Delegates to thinkrank_webroot_segment_filenames().
2029 *
2030 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
2031 * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
2032 */
2033 private function publishable_segment_filenames(array $settings): array {
2034 return thinkrank_webroot_segment_filenames($settings);
2035 }
2036
2037 /**
2038 * Can this web-root file be shown to be a sitemap ThinkRank wrote?
2039 *
2040 * The generator deletes as often as cleanup does — a segment that dropped
2041 * out of the set, a pagination page beyond the new count, the local sitemap
2042 * after the business identity was cleared — and until 2.1.1 it did all
2043 * three by filename alone. That is the #515 bug on a far more frequent
2044 * trigger: our names are the canonical ones, so an ordinary regeneration
2045 * (post save, term change, settings save) destroyed RankMath's
2046 * `sitemap-tags.xml` and `local-sitemap.xml` with no deactivation involved.
2047 *
2048 * Every name the generator derives comes from the current settings — the
2049 * url pattern, the configured `sitemap_urls`, `local-sitemap.xml` — but the
2050 * ownership test is still asked with `$name_derived = false`, which switches
2051 * off the legacy fallback for the whole generator side.
2052 *
2053 * The fallback exists to recover a pre-2.1.1 file written with
2054 * `enable_styling` off, which carries neither marker. That recovery belongs
2055 * to the once-off cleanup paths. Here it can only do harm: this method runs
2056 * on every post save, and everything this version writes carries
2057 * THINKRANK_SITEMAP_MARKER, so after the site's first regeneration an
2058 * unmarked file at one of our names is by definition somebody else's — and
2059 * deleting it on an ordinary regeneration is #515 through the more common
2060 * door. The cost is a stale unmarked segment left on disk until deactivation
2061 * picks it up, which is the safe direction to fail in.
2062 *
2063 * @since 2.1.1
2064 *
2065 * @param string $path Absolute path to a file in the web root.
2066 * @param array $settings Sitemap settings.
2067 * @return bool True when the file may be deleted.
2068 */
2069 private function webroot_sitemap_is_ours(string $path, array $settings): bool {
2070 return thinkrank_webroot_sitemap_is_ours($path, $settings, false);
2071 }
2072
2073 /**
2074 * Auto-regenerate sitemap (called by scheduled action)
2075 *
2076 * @since 1.0.0
2077 * @return void
2078 */
2079 public function auto_regenerate_sitemap(): void {
2080 if (!$this->acquire_generation_lock()) {
2081 // A manual generation (or another request's takeover) is already
2082 // writing the files; the pending marker survives so this rebuild is
2083 // retried rather than lost.
2084 return;
2085 }
2086
2087 try {
2088 // Double-check that auto-generation is still enabled
2089 if (!$this->should_auto_generate()) {
2090 // Nothing outstanding can be delivered while the feature is off,
2091 // so drop the marker rather than let the takeover retry forever.
2092 $this->mark_regeneration_complete();
2093 return;
2094 }
2095
2096 $revision = $this->current_regeneration_revision();
2097
2098 if ($this->generate_and_save($this->get_settings('site'))) {
2099 $this->mark_regeneration_complete($revision);
2100 } else {
2101 // Previously this returned quietly and last_generated simply
2102 // stopped advancing, leaving the site owner with no way to learn
2103 // the sitemap had stopped updating (#629).
2104 $this->record_regeneration_failure(
2105 __('The sitemap files could not be written to the site root.', 'thinkrank'),
2106 'content'
2107 );
2108 }
2109 } catch (\Throwable $e) {
2110 $this->record_regeneration_failure($e->getMessage(), 'content');
2111 } finally {
2112 $this->release_generation_lock();
2113 }
2114 }
2115
2116 /**
2117 * Build and write the sitemap files for the given settings.
2118 *
2119 * Segmented files plus an index when the settings configure one, a single
2120 * file otherwise, and the standalone local business sitemap either way.
2121 * Records `last_generated` so the UI's "View Generated Sitemaps" links
2122 * unlock, mirroring the manual generate endpoint.
2123 *
2124 * @since 1.17.0
2125 * @param array $settings Sitemap settings.
2126 * @return bool True when the sitemap files were written.
2127 */
2128 public function generate_and_save(array $settings): bool {
2129 // Index mode is driven by the use_sitemap_index toggle (not merely by how
2130 // many sitemap_urls happen to be configured). When the toggle is on but
2131 // no child sitemaps are set up yet, synthesize the per-type segmented set
2132 // so we emit a real <sitemapindex> with paginated children instead of a
2133 // flat urlset misnamed sitemap_index.xml.
2134 $settings = $this->maybe_promote_to_index($settings);
2135
2136 if (!empty($settings['use_sitemap_index']) || count((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : [])) > 1) {
2137 $results = $this->generate_multiple_sitemaps($settings);
2138 $written = !empty($results['success']);
2139 } else {
2140 $xml = $this->generate_sitemap($settings);
2141 $primary = $this->get_primary_sitemap_filename($settings);
2142 $written = $this->save_sitemap_to_file($xml, $primary);
2143
2144 // Local business sitemap is a standalone file, regenerated on the
2145 // single-sitemap path too (this is the default mode).
2146 $this->regenerate_local_sitemap($settings);
2147
2148 // Switching out of index mode leaves sitemap_index.xml and every
2149 // child on disk, still served and never refreshed again. The index
2150 // path already prunes what it no longer owns; this path never did,
2151 // so the site kept serving two sitemap trees (#563). Ownership is
2152 // still tested per file, so another plugin's sitemap at one of our
2153 // names is never touched (#515).
2154 $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
2155 }
2156
2157 if ($written) {
2158 $settings['last_generated'] = gmdate('c');
2159 $this->save_settings('site', null, $settings);
2160 }
2161
2162 return $written;
2163 }
2164
2165 /**
2166 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
2167 *
2168 * - When use_sitemap_index is on but no child sitemaps are configured, build
2169 * the per-type segmented set so a real <sitemapindex> is produced (#127).
2170 * - When the toggle is off but a single flat file would exceed the per-file
2171 * URL cap, auto-promote to a paginated index instead of one oversized file
2172 * that can cross Google's 50k-URL/50MB limits (#129).
2173 *
2174 * @param array $settings Sitemap settings.
2175 * @return array Possibly-updated settings.
2176 */
2177 public function maybe_promote_to_index(array $settings): array {
2178 $saved = $this->get_settings('site');
2179
2180 // Read from the *payload*, before the merge below folds the saved values
2181 // in: "the caller named this" and "this has a value" are different
2182 // questions, and the mode resolution turns on the former.
2183 $mode_supplied = array_key_exists('use_sitemap_index', $settings);
2184 $urls_supplied = is_array($settings['sitemap_urls'] ?? null);
2185 $has_children = count($urls_supplied ? $settings['sitemap_urls'] : []) > 1;
2186
2187 // Which inclusion flags did the caller actually name? The child list is
2188 // the only thing that reads them, and inheriting a saved one skipped
2189 // that — so on an index-mode site the include_* flags were enforced
2190 // nowhere but in the browser, where SitemapGeneration.js recomputes
2191 // sitemap_urls itself. Every non-UI client, the shipped
2192 // `update-sitemap-settings` ability included, saved the flag and changed
2193 // nothing (#398). Read from the payload for the same reason as above:
2194 // after the merge every saved flag would look like one the caller named.
2195 $named_inclusions = array_intersect(
2196 array_keys(self::INCLUSION_CHILD_TYPES),
2197 array_keys($settings)
2198 );
2199 $inclusions_supplied = (bool) $named_inclusions;
2200
2201 // Inclusion flags may be absent from a partial payload (e.g. the manual
2202 // generate endpoint) — fall back to saved settings so synthesized child
2203 // sitemaps reflect the real include_posts/pages/categories choices.
2204 $inclusions = array_merge($saved, $settings);
2205
2206 // Hand the generators a *complete* settings array. Only the mode was
2207 // resolved before, so every other unnamed key reached them missing: a
2208 // bare `{}` from a REST/MCP client republished the sitemap with
2209 // enable_styling and include_images read as off, overwriting the live
2210 // files with output that had lost its XSL stylesheet, its image
2211 // namespace and its image entries. Presentation and inclusion settings
2212 // are not something a generate call opts into — they are the site's
2213 // configuration, and only a value actually present in the payload
2214 // overrides them.
2215 $settings = $inclusions;
2216
2217 // The two keys that drive mode keep their own resolution rules below,
2218 // so they must go back to "not specified" when the caller omitted them.
2219 if (!$mode_supplied) {
2220 unset($settings['use_sitemap_index']);
2221 }
2222 if (!$urls_supplied) {
2223 unset($settings['sitemap_urls']);
2224 }
2225
2226 // An absent use_sitemap_index means "not specified", which is not the
2227 // same as "single file". Reading it as the latter meant a partial payload
2228 // — `{}` from a REST/MCP client, or anything short of the full settings
2229 // object the admin bundle sends — republished one flat sitemap.xml on an
2230 // index-mode site and left sitemap_index.xml and its children stale or
2231 // missing. Inherit the saved mode instead; only a value actually present
2232 // in the payload decides the mode.
2233 if (!array_key_exists('use_sitemap_index', $settings)) {
2234 $settings['use_sitemap_index'] = $saved['use_sitemap_index'] ?? '';
2235
2236 // Inheriting the mode means inheriting its children too, unless the
2237 // caller named its own set.
2238 $saved_children = (is_array($saved['sitemap_urls'] ?? null) ? $saved['sitemap_urls'] : []);
2239 if (!empty($settings['use_sitemap_index'])
2240 && !$urls_supplied
2241 && count($saved_children) > 1) {
2242 // A named inclusion flag is applied *to* the inherited list, not
2243 // used to regenerate it. build_segmented_sitemap_urls() also adds
2244 // a child for every public custom post type, so rebuilding here
2245 // would make `{include_pages: false}` — one thing off — silently
2246 // switch on children the saved list never had (an Elementor
2247 // internal CPT, a WooCommerce product feed). Only the flags the
2248 // caller actually named change anything.
2249 $settings['sitemap_urls'] = $inclusions_supplied
2250 ? $this->apply_inclusion_flags_to_children($saved_children, $named_inclusions, $inclusions)
2251 : $saved_children;
2252
2253 // The children are resolved either way — including when the
2254 // caller switched the last one off, which leaves a bare index and
2255 // is what they asked for.
2256 $has_children = true;
2257 }
2258 }
2259
2260 if (!empty($settings['use_sitemap_index'])) {
2261 if (!$has_children) {
2262 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
2263 }
2264 return $settings;
2265 }
2266
2267 if (!$has_children) {
2268 $limit = $this->get_links_per_sitemap($settings);
2269 if ($limit > 0 && $this->estimate_total_sitemap_urls($inclusions) > $limit) {
2270 $settings['use_sitemap_index'] = true;
2271 $settings['sitemap_urls'] = $this->build_segmented_sitemap_urls($inclusions);
2272 }
2273 }
2274
2275 return $settings;
2276 }
2277
2278 /**
2279 * Rough count of URLs a single-file sitemap would contain, used only to
2280 * decide whether to auto-promote to a paginated index. Cheap COUNT queries;
2281 * intentionally approximate (homepage + published posts of enabled types +
2282 * terms of enabled taxonomies).
2283 *
2284 * @param array $settings Sitemap settings.
2285 * @return int
2286 */
2287 private function estimate_total_sitemap_urls(array $settings): int {
2288 $total = 1; // homepage
2289
2290 foreach ($this->get_enabled_post_types($settings) as $post_type) {
2291 $counts = wp_count_posts($post_type);
2292 $total += isset($counts->publish) ? (int) $counts->publish : 0;
2293 }
2294
2295 foreach ($this->get_enabled_taxonomies($settings) as $taxonomy) {
2296 $count = wp_count_terms(['taxonomy' => $taxonomy, 'hide_empty' => true]);
2297 if (!is_wp_error($count)) {
2298 $total += (int) $count;
2299 }
2300 }
2301
2302 return $total;
2303 }
2304
2305 /**
2306 * Resolve the sitemap file the site publishes for the given settings.
2307 *
2308 * Index mode serves the index (`sitemap_index.xml`); the default single-file
2309 * mode serves `sitemap.xml`. Callers use this to link to — or check for —
2310 * the file the site actually serves, since ThinkRank's sitemap is a static
2311 * file in the web root rather than a route.
2312 *
2313 * @since 1.17.0
2314 * @param array $settings Sitemap settings.
2315 * @return string Sitemap filename.
2316 */
2317 public function get_primary_sitemap_filename(array $settings): string {
2318 return thinkrank_webroot_primary_sitemap_filename($settings);
2319 }
2320
2321 /**
2322 * Public URL of the sitemap the site serves.
2323 *
2324 * @since 1.17.0
2325 * @param array|null $settings Sitemap settings (falls back to saved site settings).
2326 * @return string Absolute sitemap URL.
2327 */
2328 public function get_primary_sitemap_url(?array $settings = null): string {
2329 $settings = $settings ?? $this->get_settings('site');
2330
2331 return home_url('/' . $this->get_primary_sitemap_filename($settings));
2332 }
2333
2334 /**
2335 * Whether the sitemap file this site publishes exists on disk.
2336 *
2337 * @since 1.17.0
2338 * @param array $settings Sitemap settings.
2339 * @return bool True when the file is present.
2340 */
2341 public function primary_sitemap_file_exists(array $settings): bool {
2342 return file_exists(ABSPATH . $this->get_primary_sitemap_filename($settings));
2343 }
2344
2345 /**
2346 * Save sitemap XML to file
2347 *
2348 * @since 1.0.0
2349 * @param string $sitemap_xml Sitemap XML content
2350 * @param string $filename Optional. Filename to save (defaults to 'sitemap.xml')
2351 * @return bool True on success, false on failure
2352 */
2353 private function save_sitemap_to_file(string $sitemap_xml, string $filename = 'sitemap.xml'): bool {
2354 // Validate and sanitize filename for security
2355 try {
2356 $filename = $this->validate_sitemap_filename($filename);
2357 } catch (InvalidArgumentException $e) {
2358 // File validation failed - error details available in exception
2359 return false;
2360 }
2361
2362 $sitemap_path = ABSPATH . $filename;
2363
2364 // Use WordPress filesystem API for better security
2365 global $wp_filesystem;
2366 if (!$wp_filesystem) {
2367 require_once ABSPATH . 'wp-admin/includes/file.php';
2368 WP_Filesystem();
2369 }
2370
2371 if ($wp_filesystem) {
2372 $written = $wp_filesystem->put_contents($sitemap_path, $sitemap_xml, FS_CHMOD_FILE);
2373
2374 if ($written) {
2375 // Every sitemap this version writes carries the ownership
2376 // marker, so once one has been written an unmarked file at one
2377 // of our names cannot be ours. Recording that retires the
2378 // legacy fallback for this install — see
2379 // thinkrank_webroot_sitemap_is_ours().
2380 if (get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1') {
2381 update_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION, '1', false);
2382 }
2383 }
2384
2385 return $written;
2386 }
2387
2388 // WP_Filesystem initialization failed
2389 return false;
2390 }
2391
2392 /**
2393 * Build a segmented, index-based sitemap_urls list from inclusion settings.
2394 *
2395 * Produces the index entry plus one child sitemap per enabled content type
2396 * (posts/pages/categories) and one per public custom post type — the shape
2397 * the "complete" preset creates and that generate_multiple_sitemaps() expects.
2398 * Used when enabling the index during import so the index has real children
2399 * instead of being empty.
2400 *
2401 * @since 1.14.0
2402 *
2403 * @param array $inclusions Inclusion flags (include_posts/pages/categories)
2404 * and optionally custom_url_pattern.
2405 * @return array Sitemap URL configs
2406 */
2407 public function build_segmented_sitemap_urls(array $inclusions): array {
2408 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
2409
2410 $urls = [$this->sitemap_child_entry('/sitemap_index.xml', 'index')];
2411
2412 foreach (self::INCLUSION_CHILD_TYPES as $flag => $type) {
2413 if (!empty($inclusions[$flag])) {
2414 $urls[] = $this->build_child_sitemap_entry($type, $pattern);
2415 }
2416 }
2417
2418 // Public custom post types each get a child sitemap (parity with the
2419 // "complete" preset and with Rank Math, which lists every public CPT).
2420 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
2421 if (!$this->should_include_post_type($cpt)) {
2422 continue;
2423 }
2424
2425 // ...and the per-content-type sitemap switch (#660).
2426 // get_enabled_post_types() already honours it, but this list is what
2427 // index mode builds its children from — so without the same test a
2428 // CPT the user had switched off still got its own child sitemap,
2429 // created and streamed in full. The flags live in $inclusions, which
2430 // is the settings array these children are derived from.
2431 if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $cpt, $inclusions)) {
2432 continue;
2433 }
2434
2435 $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
2436 }
2437
2438 return $urls;
2439 }
2440
2441 /**
2442 * Apply only the inclusion flags the caller named to an existing child list.
2443 *
2444 * The narrow counterpart to build_segmented_sitemap_urls(): that one
2445 * regenerates the whole set from scratch, which is right when there is no set
2446 * yet and wrong when there is. Rebuilding an existing list would add a child
2447 * for every public custom post type it had never contained, so a payload that
2448 * switches one thing off would switch others on. Here a flag adds or removes
2449 * exactly its own child and leaves every other entry — custom post types,
2450 * hand-added URLs, per-child enabled/status state — untouched (#398).
2451 *
2452 * @since 1.31.0
2453 *
2454 * @param array $children Existing child sitemap entries.
2455 * @param string[] $named_inclusions Inclusion flag keys present in the payload.
2456 * @param array $inclusions Merged settings, for the resolved flag
2457 * values and custom_url_pattern.
2458 * @return array Updated child sitemap entries.
2459 */
2460 private function apply_inclusion_flags_to_children(
2461 array $children,
2462 array $named_inclusions,
2463 array $inclusions
2464 ): array {
2465 $pattern = $inclusions['custom_url_pattern'] ?? 'sitemap-{type}.xml';
2466
2467 foreach ($named_inclusions as $flag) {
2468 $type = self::INCLUSION_CHILD_TYPES[$flag];
2469
2470 $present = false;
2471 foreach ($children as $entry) {
2472 if (($entry['type'] ?? '') === $type) {
2473 $present = true;
2474 break;
2475 }
2476 }
2477
2478 if (empty($inclusions[$flag])) {
2479 if ($present) {
2480 $children = array_values(array_filter(
2481 $children,
2482 static function ($entry) use ($type): bool {
2483 return (is_array($entry) ? ($entry['type'] ?? '') : '') !== $type;
2484 }
2485 ));
2486 }
2487 continue;
2488 }
2489
2490 if (!$present) {
2491 $children[] = $this->build_child_sitemap_entry($type, $pattern);
2492 }
2493 }
2494
2495 return $children;
2496 }
2497
2498 /**
2499 * Build one child sitemap entry, resolving its filename from the url pattern.
2500 *
2501 * @since 1.31.0
2502 *
2503 * @param string $type Child sitemap type (posts, pages, a post type name).
2504 * @param string $pattern Filename pattern containing {type}.
2505 * @return array Sitemap URL config.
2506 */
2507 private function build_child_sitemap_entry(string $type, string $pattern): array {
2508 $file = str_replace('{type}', $type, $pattern);
2509 if (strpos($file, '/') !== 0) {
2510 $file = '/' . $file;
2511 }
2512
2513 return $this->sitemap_child_entry($file, $type);
2514 }
2515
2516 /**
2517 * The shape generate_multiple_sitemaps() expects of a sitemap_urls entry.
2518 *
2519 * @since 1.31.0
2520 *
2521 * @param string $url Sitemap path.
2522 * @param string $type Entry type.
2523 * @return array Sitemap URL config.
2524 */
2525 private function sitemap_child_entry(string $url, string $type): array {
2526 return [
2527 'url' => $url,
2528 'type' => $type,
2529 'enabled' => true,
2530 'last_checked' => null,
2531 'status' => 'unknown',
2532 ];
2533 }
2534
2535 /**
2536 * Generate multiple sitemaps based on settings
2537 *
2538 * @since 1.0.0
2539 * @param array $settings Sitemap settings
2540 * @return array Results of sitemap generation
2541 */
2542 public function generate_multiple_sitemaps(array $settings): array {
2543 $results = [
2544 'success' => true,
2545 'sitemaps_generated' => [],
2546 'errors' => [],
2547 'total_urls' => 0
2548 ];
2549
2550 $sitemap_urls = (is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []);
2551
2552 if (empty($sitemap_urls)) {
2553 $results['success'] = false;
2554 $results['errors'][] = 'No sitemap URLs configured';
2555 return $results;
2556 }
2557
2558 $limit = $this->get_links_per_sitemap($settings);
2559
2560 // Defer the index until every child sitemap has been generated, so it can
2561 // list the actual files produced (including pagination pages).
2562 $index_config = null;
2563 $index_children = [];
2564
2565 foreach ($sitemap_urls as $sitemap_config) {
2566 if (empty($sitemap_config['enabled'])) {
2567 continue;
2568 }
2569
2570 $type = $sitemap_config['type'];
2571
2572 if ($type === 'index') {
2573 $index_config = $sitemap_config;
2574 continue;
2575 }
2576
2577 // Skip CPT children that no longer qualify (e.g. an internal store
2578 // like Templately's `templately_library`) even when a previously
2579 // saved config still lists them. Built-in aggregate types ('posts',
2580 // 'pages', 'general', etc.) are not post type names, so this only
2581 // affects real custom post types.
2582 if (post_type_exists($type) && !$this->should_include_post_type($type)) {
2583 continue;
2584 }
2585
2586 // Same for the matrix switch: a child list saved before the user
2587 // excluded this content type still names it, and regenerating from
2588 // that list would rewrite the file they asked not to have. Built-in
2589 // aggregates ('posts', 'pages', ...) are not post type names, so
2590 // post_type_exists() keeps this to real custom post types.
2591 if (post_type_exists($type)
2592 && !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $type, $settings)) {
2593 continue;
2594 }
2595
2596 // The built-in aggregates carry their switch in the inclusion flag
2597 // rather than under a post type name, and they are the four most
2598 // people actually use. build_segmented_sitemap_urls() drops a child
2599 // whose flag is empty; regenerating from a list saved while it was
2600 // still on has to make the same decision, or turning Posts, Pages,
2601 // Categories or Tags off in the matrix rewrites and re-lists the
2602 // very file it was asked to remove.
2603 // An absent flag means "not configured", which every other reader
2604 // treats as included; only a flag that is present and off excludes.
2605 $aggregate_flag = array_search($type, self::INCLUSION_CHILD_TYPES, true);
2606 if ($aggregate_flag !== false
2607 && array_key_exists($aggregate_flag, $settings)
2608 && empty($settings[$aggregate_flag])) {
2609 continue;
2610 }
2611
2612 try {
2613 // The single "general"/"WordPress" sitemap is one un-paginated file
2614 // (there is no index to reference extra pages); it no longer drops
2615 // overflow URLs.
2616 if ($type === 'general' || $type === 'wordpress') { // phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- lowercase on purpose: this is the stored type slug.
2617 $xml = $this->generate_sitemap($settings);
2618 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
2619 continue;
2620 }
2621
2622 $source = $this->stream_type_entries($type, $settings);
2623
2624 // Unknown type — fall back to a single general sitemap file.
2625 if ($source === null) {
2626 $xml = $this->generate_sitemap($settings);
2627 $this->write_sitemap_page($sitemap_config['url'], $xml, $type, $this->count_urls_in_xml($xml), $results, $index_children);
2628 continue;
2629 }
2630
2631 // Stream the entries into files of at most $limit URLs, matching
2632 // Rank Math: page 1 keeps the base filename, pages 2+ get a -N
2633 // suffix, and every page is listed in the index. Buffering only one
2634 // page at a time keeps peak memory bounded to $limit entries rather
2635 // than every URL of the type.
2636 $image_ns = $source['image_ns'];
2637 $buffer = [];
2638 $page = 0;
2639
2640 foreach ($source['entries'] as $entry) {
2641 $buffer[] = $entry;
2642 if (count($buffer) >= $limit) {
2643 $page++;
2644 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
2645 $buffer = [];
2646 }
2647 }
2648
2649 // Flush the trailing partial page, or a single empty page when the
2650 // type had no entries at all (parity with the previous behavior of
2651 // always writing at least one page per configured child).
2652 if (!empty($buffer) || $page === 0) {
2653 $page++;
2654 $this->write_sitemap_page($this->paginate_url($sitemap_config['url'], $page), $this->wrap_urlset($buffer, $settings, $image_ns), $type, count($buffer), $results, $index_children);
2655 }
2656
2657 // Remove pages left over from a previous, larger generation.
2658 $this->cleanup_stale_pages($sitemap_config['url'], $page, $settings);
2659
2660 } catch (\Exception $e) {
2661 $results['errors'][] = "Error generating {$type} sitemap: " . $e->getMessage();
2662 $results['success'] = false;
2663 }
2664 }
2665
2666 // Local business sitemap (parity with Rank Math's local-sitemap.xml).
2667 // The physical file is written whenever a business identity is
2668 // configured — independent of segmented vs single mode — and is also
2669 // listed in the sitemap index when one exists.
2670 try {
2671 if ($this->regenerate_local_sitemap($settings) && $index_config !== null) {
2672 $index_children[] = ['url' => '/local-sitemap.xml'];
2673 }
2674 } catch (\Exception $e) {
2675 $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
2676 $results['success'] = false;
2677 }
2678
2679 // Build the index last, from the child files actually generated, plus the
2680 // sitemaps other plugins own: those serve their own URLs and write no
2681 // file here, so they are appended to the index only (#104).
2682 if ($index_config !== null) {
2683 foreach (self::additional_sitemaps() as $extra) {
2684 $index_children[] = ['url' => $extra];
2685 }
2686
2687 try {
2688 $index_xml = $this->generate_sitemap_index($index_children, $settings);
2689 $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
2690
2691 if ($this->save_sitemap_to_file($index_xml, $filename)) {
2692 $results['sitemaps_generated'][] = [
2693 'url' => $index_config['url'],
2694 'type' => 'index',
2695 'filename' => $filename,
2696 'url_count' => count($index_children),
2697 ];
2698 } else {
2699 $results['errors'][] = "Failed to save sitemap: {$filename}";
2700 $results['success'] = false;
2701 }
2702 } catch (\Exception $e) {
2703 $results['errors'][] = 'Error generating index sitemap: ' . $e->getMessage();
2704 $results['success'] = false;
2705 }
2706 }
2707
2708 // Remove segments that are no longer part of the set. Publishing was
2709 // purely additive: a type that dropped out (Categories unticked, a CPT
2710 // that stopped qualifying) simply stopped being overwritten, so its file
2711 // kept serving and — until the index happened to be rebuilt — kept being
2712 // listed in it. The index above is built from the children actually
2713 // generated, so pruning here leaves disk and index agreeing.
2714 $this->prune_orphaned_segments($settings, $results['sitemaps_generated']);
2715
2716 return $results;
2717 }
2718
2719 /**
2720 * Delete published segment files that this run did not write.
2721 *
2722 * Only filenames this site could have published under its own url pattern
2723 * are considered, so another plugin's or core's sitemap in the web root is
2724 * never a candidate — the same reason cleanup does not glob 'sitemap-*.xml'.
2725 *
2726 * @since 1.31.0
2727 *
2728 * @param array $settings Sitemap settings (read for `custom_url_pattern`).
2729 * @param array $generated Entries from $results['sitemaps_generated'].
2730 * @return string[] Basenames removed.
2731 */
2732 private function prune_orphaned_segments(array $settings, array $generated): array {
2733 $kept = [];
2734 foreach ($generated as $entry) {
2735 if (!empty($entry['filename'])) {
2736 $kept[strtolower((string) $entry['filename'])] = true;
2737 }
2738 }
2739
2740 // The current mode's primary and the local business sitemap are written
2741 // by their own paths and are never orphans here.
2742 $primary = strtolower(basename($this->get_primary_sitemap_filename($settings)));
2743 $kept[$primary] = true;
2744 $kept['local-sitemap.xml'] = true;
2745
2746 // The OTHER mode's primary is an orphan the moment the mode changes:
2747 // index mode leaves sitemap.xml behind, flat mode leaves
2748 // sitemap_index.xml and its children. Both used to be kept
2749 // unconditionally, so the site served two sitemap trees and only ever
2750 // refreshed one (#563). The children are already covered by the segment
2751 // sweep below, which now sees them because the index is no longer kept.
2752 $stale_primaries = array_diff(['sitemap.xml', 'sitemap_index.xml'], [$primary]);
2753
2754 $removed = [];
2755
2756 global $wp_filesystem;
2757 if (!$wp_filesystem) {
2758 require_once ABSPATH . 'wp-admin/includes/file.php';
2759 WP_Filesystem();
2760 }
2761 if (!$wp_filesystem) {
2762 return $removed;
2763 }
2764
2765 $candidates = array_merge($this->publishable_segment_filenames($settings), $stale_primaries);
2766
2767 foreach ($candidates as $candidate) {
2768 if (isset($kept[strtolower($candidate)])) {
2769 continue;
2770 }
2771
2772 if (!preg_match('/^(.*)\.xml$/i', $candidate, $m)) {
2773 continue;
2774 }
2775
2776 // The base file plus its numeric pagination pages.
2777 $paths = [ABSPATH . $candidate];
2778 foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
2779 if (preg_match('/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i', basename($paged))) {
2780 $paths[] = $paged;
2781 }
2782 }
2783
2784 foreach ($paths as $path) {
2785 if (!file_exists($path)) {
2786 continue;
2787 }
2788 // A name we could have published is not proof we published
2789 // this file: RankMath and core write at the same paths (#515).
2790 if (!$this->webroot_sitemap_is_ours($path, $settings)) {
2791 continue;
2792 }
2793 if ($wp_filesystem->delete($path)) {
2794 $removed[] = basename($path);
2795 }
2796 }
2797 }
2798
2799 return $removed;
2800 }
2801
2802
2803 /**
2804 * Collect the full (un-paginated) entry list for a content sitemap type.
2805 *
2806 * @since 1.14.0
2807 *
2808 * @param string $type Sitemap config type (posts, pages, categories, …)
2809 * @param array $settings Sitemap settings
2810 * @return array{entries: array<string>, image_ns: bool}|null Null for an
2811 * unknown type (caller falls back to a general sitemap).
2812 */
2813 /**
2814 * Write (or remove) the physical local-sitemap.xml file.
2815 *
2816 * Called from every sitemap regeneration path — segmented generation, the
2817 * single-sitemap auto-regeneration, and the manual generate endpoint — so
2818 * the local sitemap works regardless of whether the site uses a sitemap
2819 * index. When no business identity is configured, any stale file is removed.
2820 *
2821 * @since 1.15.x
2822 * @param array|null $settings Sitemap settings (falls back to saved site settings).
2823 * @return bool True when the file was written, false when nothing was written.
2824 */
2825 public function regenerate_local_sitemap(?array $settings = null): bool {
2826 $settings = $settings ?? $this->get_settings('site');
2827 $entries = $this->collect_local_entries();
2828
2829 if (empty($entries)) {
2830 // Business identity was cleared — drop the file we left from
2831 // before, but only ours. `local-sitemap.xml` is the name Rank Math
2832 // publishes under too (this method mirrors it deliberately), so on
2833 // a migrated site the file at that path may never have been ours
2834 // to delete (#515).
2835 $path = ABSPATH . 'local-sitemap.xml';
2836 if (file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
2837 wp_delete_file($path);
2838 }
2839 return false;
2840 }
2841
2842 $xml = $this->wrap_urlset($entries, $settings, false);
2843 return $this->save_sitemap_to_file($xml, 'local-sitemap.xml');
2844 }
2845
2846 /**
2847 * Build the local business sitemap entries.
2848 *
2849 * Returns a single URL entry pointing at the on-site page that carries the
2850 * business's LocalBusiness/Organization schema (the configured business URL
2851 * when it's on this site, otherwise the homepage). Gated — like Rank Math's
2852 * `local-sitemap.xml` — on a business (non-person) type being configured
2853 * with an actual location (address or geo coordinates). Returns an empty
2854 * array when no business location is set, so no empty local sitemap is
2855 * written or added to the index.
2856 *
2857 * @since 1.15.x
2858 * @return array Zero or one URL entry
2859 */
2860 private function collect_local_entries(): array {
2861 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2862 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2863 }
2864
2865 $identity = (new \ThinkRank\SEO\Site_Identity_Manager())->get_settings('site');
2866
2867 // Gate like Rank Math's local sitemap: a business (non-person) identity
2868 // is configured. We only emit a single URL (the location page), so a
2869 // full postal address is NOT required — requiring one wrongly excluded
2870 // migrated sites, since the Rank Math importer carries over business
2871 // type/name/phone but not the address. Personal sites, and sites with no
2872 // business identity at all, get no local sitemap.
2873 $business_type = strtolower((string) ($identity['business_type'] ?? ''));
2874 if ($business_type === 'person') {
2875 return [];
2876 }
2877 if ($business_type === '' && empty($identity['business_name'])) {
2878 return [];
2879 }
2880
2881 // Prefer the configured business URL when it points at this site;
2882 // otherwise fall back to the homepage (which outputs the schema).
2883 $location_url = home_url('/');
2884 if (!empty($identity['business_url'])) {
2885 $candidate = esc_url_raw((string) $identity['business_url']);
2886 if ($candidate && wp_parse_url($candidate, PHP_URL_HOST) === wp_parse_url(home_url(), PHP_URL_HOST)) {
2887 $location_url = $candidate;
2888 }
2889 }
2890
2891 // Omit lastmod — no real modification source for the local business URL.
2892 return [$this->generate_url_entry($location_url, '', 0.8, 'weekly')];
2893 }
2894
2895 /**
2896 * Resolve a streaming entry source for a sitemap type.
2897 *
2898 * Returns an iterable that yields the type's <url> entries one at a time
2899 * (so the paginator never materializes the whole type), the image-namespace
2900 * flag, or null when the type isn't one we build (caller falls back to a
2901 * single general sitemap). Entry order matches the previous array-based
2902 * collect_type_entries() exactly.
2903 *
2904 * @param string $type Sitemap type.
2905 * @param array $settings Sitemap settings.
2906 * @return array{entries: iterable<string>, image_ns: bool}|null
2907 */
2908 private function stream_type_entries(string $type, array $settings): ?array {
2909 switch ($type) {
2910 case 'posts':
2911 return ['entries' => $this->collect_post_entries_iter(['post'], $settings), 'image_ns' => true];
2912
2913 case 'pages':
2914 // The homepage lives in the pages sitemap (parity with prior
2915 // output) and is emitted first; collect_post_entries_iter()
2916 // already excludes the static front page, so there's no duplicate.
2917 $entries = (function () use ($settings) {
2918 yield $this->generate_url_entry(home_url('/'), $this->get_homepage_lastmod(), 1.0, 'daily');
2919 yield from $this->collect_post_entries_iter(['page'], $settings);
2920 })();
2921 return ['entries' => $entries, 'image_ns' => true];
2922
2923 case 'categories':
2924 return ['entries' => $this->collect_taxonomy_entries_iter('category', $settings), 'image_ns' => false];
2925
2926 case 'tags':
2927 return ['entries' => $this->collect_taxonomy_entries_iter('post_tag', $settings), 'image_ns' => false];
2928
2929 case 'products':
2930 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
2931 return ['entries' => $entries, 'image_ns' => true];
2932
2933 case 'product_categories':
2934 $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
2935 return ['entries' => $entries, 'image_ns' => false];
2936
2937 default:
2938 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2939 if (in_array($type, $custom_post_types, true)) {
2940 return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
2941 }
2942 return null;
2943 }
2944 }
2945
2946 /**
2947 * Write one sitemap page file and record it in the results + index child list.
2948 *
2949 * @since 1.14.0
2950 *
2951 * @param string $url Sitemap page URL
2952 * @param string $xml Sitemap XML
2953 * @param string $type Sitemap type (for reporting)
2954 * @param int $url_count Number of URLs in this page
2955 * @param array $results Results accumulator (by reference)
2956 * @param array $index_children Index child list (by reference)
2957 * @return void
2958 */
2959 private function write_sitemap_page(string $url, string $xml, string $type, int $url_count, array &$results, array &$index_children): void {
2960 $filename = basename(wp_parse_url($url, PHP_URL_PATH));
2961
2962 if ($this->save_sitemap_to_file($xml, $filename)) {
2963 $results['sitemaps_generated'][] = [
2964 'url' => $url,
2965 'type' => $type,
2966 'filename' => $filename,
2967 'url_count' => $url_count,
2968 ];
2969 $results['total_urls'] += $url_count;
2970 $index_children[] = ['url' => $url];
2971 } else {
2972 $results['errors'][] = "Failed to save sitemap: {$filename}";
2973 $results['success'] = false;
2974 }
2975 }
2976
2977 /**
2978 * Delete pagination files left over when a type shrinks to fewer pages.
2979 *
2980 * For a base URL like /sitemap-posts.xml, removes sitemap-posts-N.xml files
2981 * whose page number N exceeds the current page count. Page 1 (the base file,
2982 * which has no -N suffix) is never touched.
2983 *
2984 * @since 1.14.0
2985 *
2986 * @since 2.1.1 Each candidate must pass the content ownership test — a
2987 * `-N.xml` page of another plugin's sitemap paginates our
2988 * stem exactly as ours does (#515).
2989 *
2990 * @param string $base_url Base (page 1) sitemap URL
2991 * @param int $current_pages Number of pages generated this run
2992 * @param array $settings Sitemap settings, for the ownership test.
2993 * @return void
2994 */
2995 private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
2996 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2997 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2998 return;
2999 }
3000 $stem = $m[1];
3001
3002 global $wp_filesystem;
3003 if (!$wp_filesystem) {
3004 require_once ABSPATH . 'wp-admin/includes/file.php';
3005 WP_Filesystem();
3006 }
3007 if (!$wp_filesystem) {
3008 return;
3009 }
3010
3011 $candidates = glob(ABSPATH . $stem . '-*.xml') ?: [];
3012 foreach ($candidates as $path) {
3013 // Only delete numeric-suffixed pages beyond the current count.
3014 if (preg_match('/-(\d+)\.xml$/', basename($path), $mm)
3015 && (int) $mm[1] > $current_pages
3016 && $this->webroot_sitemap_is_ours($path, $settings)) {
3017 $wp_filesystem->delete($path);
3018 }
3019 }
3020 }
3021
3022 /**
3023 * Sitemap URLs contributed by other plugins.
3024 *
3025 * ThinkRank owns the sitemap index and the robots.txt `Sitemap:` lines, so a
3026 * companion plugin that serves its own sitemap — Pro's news and video
3027 * sitemaps, for instance — had no way to be discovered: it appeared in
3028 * neither, leaving manual Search Console submission as the only route in
3029 * (#104). Registering here puts a sitemap in the index when one exists, and
3030 * in robots.txt when it does not.
3031 *
3032 * Callers get root-relative paths. Entries are normalised to a leading
3033 * slash, de-duplicated, and anything that is not a non-empty string is
3034 * dropped, so one badly-behaved callback cannot produce a malformed index.
3035 *
3036 * @since 2.3.1
3037 *
3038 * @return string[] Root-relative sitemap paths, e.g. ['/news-sitemap.xml'].
3039 */
3040 public static function additional_sitemaps(): array {
3041 /**
3042 * Filters the sitemaps contributed by other plugins.
3043 *
3044 * @since 2.3.1
3045 *
3046 * @param string[] $sitemaps Root-relative sitemap paths.
3047 */
3048 $sitemaps = apply_filters('thinkrank_additional_sitemaps', []);
3049
3050 if (!is_array($sitemaps)) {
3051 return [];
3052 }
3053
3054 // Both consumers resolve an entry with home_url(), which prefixes the
3055 // install's own directory. Everything below is measured against that so
3056 // an absolute URL is reduced to what home_url() will put back.
3057 $home = wp_parse_url(home_url('/'));
3058 $home_host = strtolower((string) ($home['host'] ?? ''));
3059 $home_path = '/' . trim((string) ($home['path'] ?? ''), '/');
3060
3061 $clean = [];
3062 foreach ($sitemaps as $sitemap) {
3063 if (!is_string($sitemap)) {
3064 continue;
3065 }
3066
3067 $sitemap = trim($sitemap);
3068 if ('' === $sitemap) {
3069 continue;
3070 }
3071
3072 // A full URL on this site is accepted and reduced to the part
3073 // home_url() does not already supply, so a caller that reached for
3074 // home_url() still lands in the right place — including on a
3075 // subdirectory install, where keeping the whole path would repeat
3076 // the directory. A URL on another host is dropped rather than
3077 // rewritten: the sitemaps protocol will not accept a cross-host
3078 // child anyway, and reusing its path would advertise a URL on this
3079 // site that does not exist.
3080 if (preg_match('#^(https?:)?//#i', $sitemap)) {
3081 $parts = wp_parse_url('//' === substr($sitemap, 0, 2) ? 'https:' . $sitemap : $sitemap);
3082 if (!is_array($parts)) {
3083 continue;
3084 }
3085
3086 if (strtolower((string) ($parts['host'] ?? '')) !== $home_host) {
3087 continue;
3088 }
3089
3090 $path = (string) ($parts['path'] ?? '');
3091 if ('' === $path) {
3092 continue;
3093 }
3094
3095 if ('/' !== $home_path && ($path === $home_path || 0 === strpos($path, $home_path . '/'))) {
3096 $path = substr($path, strlen($home_path));
3097 }
3098
3099 // A sitemap served from a query string keeps it; dropping the
3100 // query would point at a different document.
3101 $query = (string) ($parts['query'] ?? '');
3102 $sitemap = $path . ('' !== $query ? '?' . $query : '');
3103 }
3104
3105 $clean[] = '/' . ltrim($sitemap, '/');
3106 }
3107
3108 return array_values(array_unique($clean));
3109 }
3110
3111 /**
3112 * Generate sitemap index XML from the list of child sitemap files produced
3113 * during generation (each already resolved to its final, possibly paginated,
3114 * URL).
3115 *
3116 * @since 1.0.0 (signature updated 1.14.0)
3117 * @param array $children Array of ['url' => string] child sitemap entries
3118 * @param array $settings Sitemap settings
3119 * @return string Sitemap index XML
3120 */
3121 private function generate_sitemap_index(array $children, array $settings): string {
3122 $xml = $this->xml_prolog($settings, 'sitemap-index.xsl');
3123 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
3124
3125 $site_url = home_url();
3126
3127 foreach ($children as $child) {
3128 $sitemap_url = $child['url'] ?? '';
3129 if ($sitemap_url === '') {
3130 continue;
3131 }
3132 if (!str_starts_with($sitemap_url, 'http')) {
3133 $sitemap_url = $site_url . $sitemap_url;
3134 }
3135
3136 $xml .= " <sitemap>\n";
3137 $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
3138 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
3139 $xml .= " </sitemap>\n";
3140 }
3141
3142 $xml .= '</sitemapindex>';
3143
3144 return $xml;
3145 }
3146
3147 /**
3148 * Count URLs in sitemap XML content
3149 *
3150 * @since 1.0.0
3151 * @param string $sitemap_xml Sitemap XML content
3152 * @return int Number of URLs
3153 */
3154 private function count_urls_in_xml(string $sitemap_xml): int {
3155 return substr_count($sitemap_xml, '<url>');
3156 }
3157
3158 /**
3159 * Validate and sanitize exclude posts input
3160 *
3161 * @since 1.0.0
3162 * @param string $exclude_posts Comma-separated post IDs
3163 * @return array Validated post IDs
3164 * @throws InvalidArgumentException If validation fails
3165 */
3166 private function validate_exclude_posts(string $exclude_posts): array {
3167 if (empty($exclude_posts)) {
3168 return [];
3169 }
3170
3171 // Limit input length to prevent DoS
3172 if (strlen($exclude_posts) > 1000) {
3173 throw new InvalidArgumentException('Exclude posts list too long (max 1000 characters)');
3174 }
3175
3176 // Validate comma-separated integers only
3177 if (!preg_match('/^[\d,\s]+$/', $exclude_posts)) {
3178 throw new InvalidArgumentException('Exclude posts must contain only numbers and commas');
3179 }
3180
3181 $ids = array_map('intval', array_filter(explode(',', $exclude_posts)));
3182
3183 // Limit number of exclusions to prevent performance issues
3184 if (count($ids) > 100) {
3185 throw new InvalidArgumentException('Too many posts to exclude (max 100)');
3186 }
3187
3188 return array_filter($ids, function($id) {
3189 return $id > 0; // Only positive integers
3190 });
3191 }
3192
3193 /**
3194 * Validate and sanitize exclude terms input
3195 *
3196 * @since 1.0.0
3197 * @param string $exclude_terms Comma-separated term IDs
3198 * @return array Validated term IDs
3199 * @throws InvalidArgumentException If validation fails
3200 */
3201 private function validate_exclude_terms(string $exclude_terms): array {
3202 if (empty($exclude_terms)) {
3203 return [];
3204 }
3205
3206 // Limit input length to prevent DoS
3207 if (strlen($exclude_terms) > 1000) {
3208 throw new InvalidArgumentException('Exclude terms list too long (max 1000 characters)');
3209 }
3210
3211 // Validate comma-separated integers only
3212 if (!preg_match('/^[\d,\s]+$/', $exclude_terms)) {
3213 throw new InvalidArgumentException('Exclude terms must contain only numbers and commas');
3214 }
3215
3216 $ids = array_map('intval', array_filter(explode(',', $exclude_terms)));
3217
3218 // Limit number of exclusions to prevent performance issues
3219 if (count($ids) > 100) {
3220 throw new InvalidArgumentException('Too many terms to exclude (max 100)');
3221 }
3222
3223 return array_filter($ids, function($id) {
3224 return $id > 0; // Only positive integers
3225 });
3226 }
3227
3228 /**
3229 * Validate and sanitize custom URL pattern
3230 *
3231 * @since 1.0.0
3232 * @param string $pattern Custom URL pattern
3233 * @return string Validated and sanitized pattern
3234 * @throws InvalidArgumentException If validation fails
3235 */
3236 private function validate_custom_url_pattern(string $pattern): string {
3237 if (empty($pattern)) {
3238 return 'sitemap-{type}.xml';
3239 }
3240
3241 // Remove any HTML/script tags to prevent XSS
3242 $pattern = wp_strip_all_tags($pattern);
3243
3244 // Limit pattern length
3245 if (strlen($pattern) > 100) {
3246 throw new InvalidArgumentException('URL pattern too long (max 100 characters)');
3247 }
3248
3249 // Validate pattern format - only allow safe characters
3250 if (!preg_match('/^[a-zA-Z0-9\-_{}\.]+$/', $pattern)) {
3251 throw new InvalidArgumentException('URL pattern contains invalid characters. Only letters, numbers, hyphens, underscores, dots, and {type} are allowed');
3252 }
3253
3254 // Ensure it contains {type} placeholder
3255 if (strpos($pattern, '{type}') === false) {
3256 throw new InvalidArgumentException('URL pattern must contain {type} placeholder');
3257 }
3258
3259 // Ensure it ends with .xml
3260 if (!str_ends_with($pattern, '.xml')) {
3261 $pattern .= '.xml';
3262 }
3263
3264 return sanitize_file_name($pattern);
3265 }
3266
3267 /**
3268 * Validate sitemap URL
3269 *
3270 * @since 1.0.0
3271 * @param string $url Sitemap URL
3272 * @return string Validated and sanitized URL
3273 * @throws InvalidArgumentException If validation fails
3274 */
3275 private function validate_sitemap_url(string $url): string {
3276 if (empty($url)) {
3277 throw new InvalidArgumentException('Sitemap URL cannot be empty');
3278 }
3279
3280 // Remove leading/trailing whitespace
3281 $url = trim($url);
3282
3283 // Limit URL length
3284 if (strlen($url) > 200) {
3285 throw new InvalidArgumentException('Sitemap URL too long (max 200 characters)');
3286 }
3287
3288 // Ensure it starts with /
3289 if (!str_starts_with($url, '/')) {
3290 $url = '/' . $url;
3291 }
3292
3293 // Validate URL path format
3294 if (!preg_match('/^\/[a-zA-Z0-9\-_\/\.]+\.xml$/', $url)) {
3295 throw new InvalidArgumentException('Invalid sitemap URL format. Must be a valid path ending with .xml');
3296 }
3297
3298 // Prevent directory traversal
3299 if (strpos($url, '..') !== false) {
3300 throw new InvalidArgumentException('Directory traversal not allowed in sitemap URL');
3301 }
3302
3303 // Prevent multiple slashes
3304 $url = preg_replace('/\/+/', '/', $url);
3305
3306 return sanitize_url($url);
3307 }
3308
3309 /**
3310 * Validate links per sitemap setting
3311 *
3312 * @since 1.0.0
3313 * @param mixed $links_per_sitemap Links per sitemap value
3314 * @return int Validated links per sitemap
3315 * @throws InvalidArgumentException If validation fails
3316 */
3317 private function validate_links_per_sitemap($links_per_sitemap): int {
3318 $links = intval($links_per_sitemap);
3319
3320 if ($links < 1) {
3321 throw new InvalidArgumentException('Links per sitemap must be at least 1');
3322 }
3323
3324 if ($links > 50000) {
3325 throw new InvalidArgumentException('Links per sitemap cannot exceed 50,000');
3326 }
3327
3328 return $links;
3329 }
3330
3331 /**
3332 * Validate sitemap filename for security
3333 *
3334 * @since 1.0.0
3335 * @param string $filename Filename to validate
3336 * @return string Validated and sanitized filename
3337 * @throws InvalidArgumentException If validation fails
3338 */
3339 private function validate_sitemap_filename(string $filename): string {
3340 if (empty($filename)) {
3341 return 'sitemap.xml';
3342 }
3343
3344 // Remove any path components to prevent directory traversal
3345 $filename = basename($filename);
3346
3347 // Limit filename length
3348 if (strlen($filename) > 100) {
3349 throw new InvalidArgumentException('Filename too long (max 100 characters)');
3350 }
3351
3352 // Validate filename format - only allow safe characters
3353 if (!preg_match('/^[a-zA-Z0-9\-_\.]+$/', $filename)) {
3354 throw new InvalidArgumentException('Filename contains invalid characters. Only letters, numbers, hyphens, underscores, and dots are allowed');
3355 }
3356
3357 // Prevent directory traversal attempts
3358 if (strpos($filename, '..') !== false) {
3359 throw new InvalidArgumentException('Directory traversal not allowed in filename');
3360 }
3361
3362 // Ensure it ends with .xml
3363 if (!str_ends_with($filename, '.xml')) {
3364 $filename .= '.xml';
3365 }
3366
3367 // Additional sanitization
3368 $filename = sanitize_file_name($filename);
3369
3370 // Final security check - ensure it's still a valid XML filename
3371 if (!preg_match('/^[a-zA-Z0-9\-_]+\.xml$/', $filename)) {
3372 throw new InvalidArgumentException('Invalid XML filename after sanitization');
3373 }
3374
3375 return $filename;
3376 }
3377 }
3378