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

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