PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.11.0 2.10.0 2.9.0 2.8.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 All 52 releases
← All changes | includes/seo/class-sitemap-generator.php +1462 -52 2.3.0 → 2.10.0 View file →
@@ -62,8 +62,25 @@
62 62 'include_tags' => 'tags',
63 63 ];
64 64
65 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 + /**
66 83 * Supported sitemap types
67 84 *
68 85 * @since 1.0.0
69 86 * @var array
@@ -76,8 +93,159 @@
76 93 */
77 94 private const ID_WALK_CHUNK = 500;
78 95
79 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 + * Delivery modes accepted by the `delivery_mode` setting.
149 + *
150 + * Mirrors LLMs_Txt_Manager::DELIVERY_MODES, which solved the same problem
151 + * for llms.txt. `auto` is the only value a site should normally need.
152 + *
153 + * @since 2.9.0
154 + * @var string[]
155 + */
156 + public const DELIVERY_MODES = ['auto', 'static', 'dynamic'];
157 +
158 + /**
159 + * Cache group for documents rendered on the dynamic path.
160 + *
161 + * @since 2.9.0
162 + * @var string
163 + */
164 + private const DYNAMIC_CACHE_PREFIX = 'thinkrank_sitemap_doc_';
165 +
166 + /**
167 + * How long a dynamically rendered document is cached.
168 + *
169 + * Invalidated by content and settings changes through
170 + * {@see self::flush_dynamic_cache()}, so this is only the backstop for a
171 + * change nothing hooked.
172 + *
173 + * @since 2.9.0
174 + * @var int
175 + */
176 + private const DYNAMIC_CACHE_TTL = 12 * HOUR_IN_SECONDS;
177 +
178 + /**
179 + * How long the render lock is held before it is assumed abandoned.
180 + *
181 + * Long enough for a large site's full build, short enough that a request
182 + * killed mid-build does not lock the endpoint out for meaningfully long.
183 + *
184 + * @since 2.9.0
185 + * @var int
186 + */
187 + private const RENDER_LOCK_TTL = 60;
188 +
189 + /**
190 + * Cached stand-in for "this site does not publish that name".
191 + *
192 + * published_document_names() lists what the configuration *could* produce,
193 + * but a child whose type is excluded produces nothing. Without a negative
194 + * entry those names miss the cache forever, so every request for one
195 + * rebuilt the entire sitemap — the same cost the positive cache exists to
196 + * avoid, on a public endpoint (#754 review).
197 + *
198 + * @since 2.9.0
199 + * @var string
200 + */
201 + private const ABSENT_MARKER = "\0thinkrank-absent";
202 +
203 + /**
204 + * How many times, and how long, a losing request waits for the winner.
205 + *
206 + * Bounded at roughly a second in total: past that, building a second copy
207 + * costs less than making a crawler wait.
208 + *
209 + * @since 2.9.0
210 + * @var int
211 + */
212 + private const RENDER_LOCK_WAIT_ATTEMPTS = 4;
213 +
214 + /**
215 + * @since 2.9.0
216 + * @var int
217 + */
218 + private const RENDER_LOCK_WAIT_MICROSECONDS = 250000;
219 +
220 + /**
221 + * Where generated documents go instead of disk, when set.
222 + *
223 + * Every sitemap document this class produces — segments, the index, the
224 + * single flat file and local-sitemap.xml — is published through the one
225 + * writer, {@see self::save_sitemap_to_file()}. Swapping that writer for a
226 + * collector is therefore all it takes to render the same bytes without a
227 + * filesystem, which is what dynamic delivery needs (#752). Doing it here
228 + * rather than duplicating the build pipeline is deliberate: a second
229 + * pipeline would drift from this one, and the index in particular is
230 + * assembled from whatever the children actually produced.
231 + *
232 + * @since 2.9.0
233 + * @var callable|null
234 + */
235 + private $document_sink = null;
236 +
237 + /**
238 + * Transient guarding against two generations running at once. Shared with
239 + * Sitemap_Endpoint's manual generate route so an automatic rebuild and a
240 + * manual one cannot write the same files concurrently.
241 + *
242 + * @since 2.2.1
243 + * @var string
244 + */
245 + public const GENERATION_LOCK_TRANSIENT = 'thinkrank_sitemap_generation_lock';
246 +
247 + /**
80 248 * How many term IDs to hydrate at a time while walking a taxonomy.
81 249 *
82 250 * @since 2.0.1
83 251 * @var int
@@ -209,9 +377,9 @@
209 377 */
210 378 public function generate_sitemap(array $options = []): string {
211 379 $settings = $this->get_settings('site');
212 380
213 - $xml = $this->xml_prolog($settings, 'sitemap.xsl');
381 + $xml = $this->xml_prolog($settings, 'sitemap');
214 382
215 383 // Add image namespace if images are enabled
216 384 if (!empty($settings['include_images'])) {
217 385 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
@@ -374,8 +542,15 @@
374 542 ]
375 543 ],
376 544 'use_sitemap_index' => false,
377 545
546 + // How the sitemap reaches crawlers. 'auto' keeps the historical
547 + // behaviour wherever the web root is writable, and only falls back
548 + // to serving the sitemap from PHP where writing a file is
549 + // impossible — previously a hard failure with nothing served
550 + // (#752).
551 + 'delivery_mode' => 'auto',
552 +
378 553 // General Settings
379 554 'links_per_sitemap' => 1000,
380 555 'include_images' => true,
381 556 'include_featured_images' => false,
@@ -397,8 +572,17 @@
397 572 // Advanced Options
398 573 'enable_styling' => true,
399 574 'custom_url_pattern' => 'sitemap-{type}.xml',
400 575
576 + // Stylesheet branding (#639). Both colours default to empty, not
577 + // to the stock hexes: empty means the stylesheet's own value
578 + // stands, so a site that never opens this screen renders exactly
579 + // as it did before the setting existed.
580 + 'styling_logo' => false,
581 + 'styling_logo_url' => '',
582 + 'styling_color_main' => '',
583 + 'styling_color_accent' => '',
584 +
401 585 // Generation tracking
402 586 'last_generated' => ''
403 587 ];
404 588 }
@@ -403,8 +587,49 @@
403 587 ];
404 588 }
405 589
406 590 /**
591 + * Normalize the stylesheet branding values on the way into the store.
592 + *
593 + * The generic sanitizer only runs `sanitize_text_field()` over a string,
594 + * which happily keeps "red" or "rebeccapurple" as a colour. Nothing
595 + * downstream can use those — {@see Sitemap_Stylesheet::render()} skips any
596 + * value it cannot read as a hex colour — so storing them would report a
597 + * successful save of a setting that changes nothing, and `get-sitemap-
598 + * settings` would hand an agent back a colour the sitemap does not use.
599 + * Reducing here instead keeps the store and the rendering in agreement.
600 + *
601 + * @since 2.7.0
602 + *
603 + * @param array $settings Settings to sanitize.
604 + * @param string $context_type Context the save is for.
605 + * @return array
606 + */
607 + protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
608 + $sanitized = parent::sanitize_settings($settings, $context_type);
609 +
610 + foreach (['styling_color_main', 'styling_color_accent'] as $key) {
611 + if (array_key_exists($key, $sanitized)) {
612 + $sanitized[$key] = Sitemap_Stylesheet::hex($sanitized[$key]);
613 + }
614 + }
615 +
616 + if (array_key_exists('styling_logo_url', $sanitized)) {
617 + $sanitized['styling_logo_url'] = esc_url_raw((string) $sanitized['styling_logo_url']);
618 + }
619 +
620 + // A mode this build cannot act on has to be stored as the fallback
621 + // rather than kept verbatim, or get-sitemap-settings reports a delivery
622 + // mode the site does not actually apply.
623 + if (array_key_exists('delivery_mode', $sanitized)) {
624 + $mode = sanitize_key((string) $sanitized['delivery_mode']);
625 + $sanitized['delivery_mode'] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
626 + }
627 +
628 + return $sanitized;
629 + }
630 +
631 + /**
407 632 * Get settings schema definition (implements interface)
408 633 *
409 634 * @since 1.0.0
410 635 *
@@ -418,8 +643,15 @@
418 643 'title' => 'Enable Sitemap',
419 644 'description' => 'Generate XML sitemap for search engines',
420 645 'default' => true
421 646 ],
647 + 'delivery_mode' => [
648 + 'type' => 'string',
649 + 'title' => 'Sitemap Delivery',
650 + 'description' => 'How the sitemap is served: auto picks static when the WordPress root is writable and dynamic when it is not, static writes files to the web root, dynamic serves the sitemap from WordPress with no files written',
651 + 'enum' => self::DELIVERY_MODES,
652 + 'default' => 'auto'
653 + ],
422 654 'include_posts' => [
423 655 'type' => 'boolean',
424 656 'title' => 'Include Posts',
425 657 'description' => 'Include blog posts in sitemap',
@@ -460,8 +692,32 @@
460 692 'type' => 'string',
461 693 'title' => 'Last Generated',
462 694 'description' => 'Timestamp of last sitemap generation',
463 695 'default' => ''
696 + ],
697 + 'styling_logo' => [
698 + 'type' => 'boolean',
699 + 'title' => 'Show Logo On Sitemap',
700 + 'description' => 'Show a logo above the sitemap heading',
701 + 'default' => false
702 + ],
703 + 'styling_logo_url' => [
704 + 'type' => 'string',
705 + 'title' => 'Sitemap Logo',
706 + 'description' => 'Logo image URL. Empty falls back to the site icon',
707 + 'default' => ''
708 + ],
709 + 'styling_color_main' => [
710 + 'type' => 'string',
711 + 'title' => 'Sitemap Main Color',
712 + 'description' => 'Hex color for the sitemap header, links and table head. Empty keeps the stock palette',
713 + 'default' => ''
714 + ],
715 + 'styling_color_accent' => [
716 + 'type' => 'string',
717 + 'title' => 'Sitemap Accent Color',
718 + 'description' => 'Hex color for the header gradient and link hovers. Empty keeps the stock palette',
719 + 'default' => ''
464 720 ]
465 721 ];
466 722 }
467 723
@@ -478,9 +734,14 @@
478 734 * @return string XML URL entry
479 735 */
480 736 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
481 737 $xml = " <url>\n";
482 - $xml .= " <loc>" . esc_url($url) . "</loc>\n";
738 + // Every <loc> in every sitemap passes through here, which is why the
739 + // scheme preference is applied at this one point rather than at each
740 + // of the dozen collectors that build URLs (#638). An http sitemap on
741 + // an https site hands search engines the wrong address for the whole
742 + // site at once.
743 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($url)) . "</loc>\n";
483 744 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
484 745 // than no timestamp, and an absent lastmod is valid per the spec.
485 746 if (!empty($lastmod)) {
486 747 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
@@ -490,9 +751,9 @@
490 751
491 752 // Add image entries if provided
492 753 foreach ($images as $image) {
493 754 $xml .= " <image:image>\n";
494 - $xml .= " <image:loc>" . esc_url($image['url']) . "</image:loc>\n";
755 + $xml .= " <image:loc>" . esc_url(Url_Scheme::apply((string) $image['url'])) . "</image:loc>\n";
495 756
496 757 if (!empty($image['title'])) {
497 758 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
498 759 }
@@ -758,19 +1019,25 @@
758 1019 * kept a shadowing file behind (#510) or had a competitor's deleted.
759 1020 *
760 1021 * @since 2.1.1
761 1022 *
762 - * @param array $settings Sitemap settings (read for `enable_styling`).
763 - * @param string $stylesheet Stylesheet basename in static/xsl/.
1023 + * The stylesheet URL is served by {@see Sitemap_Stylesheet}, not read off
1024 + * disk by the web server, because a static file cannot carry the site's own
1025 + * logo and colours (#639). It is a fixed URL: the palette is applied per
1026 + * request, so changing a brand colour needs no regeneration and shows up on
1027 + * sitemaps published long before.
1028 + *
1029 + * @param array $settings Sitemap settings (read for `enable_styling`).
1030 + * @param string $variant Stylesheet variant, `sitemap` or `index`.
764 1031 * @return string Prolog lines, newline-terminated.
765 1032 */
766 - private function xml_prolog(array $settings, string $stylesheet): string {
1033 + private function xml_prolog(array $settings, string $variant): string {
767 1034 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
768 1035 $xml .= THINKRANK_SITEMAP_MARKER . "\n";
769 1036
770 1037 // The stylesheet is presentation only, so it stays opt-in.
771 1038 if (!empty($settings['enable_styling'])) {
772 - $xml .= '<?xml-stylesheet type="text/xsl" href="' . home_url('/wp-content/plugins/thinkrank/static/xsl/' . $stylesheet) . '"?>' . "\n";
1039 + $xml .= '<?xml-stylesheet type="text/xsl" href="' . esc_url(Sitemap_Stylesheet::url($variant)) . '"?>' . "\n";
773 1040 }
774 1041
775 1042 return $xml;
776 1043 }
@@ -785,9 +1052,9 @@
785 1052 * @param bool $with_image_ns Include the image sitemap namespace
786 1053 * @return string Full sitemap XML
787 1054 */
788 1055 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
789 - $xml = $this->xml_prolog($settings, 'sitemap.xsl');
1056 + $xml = $this->xml_prolog($settings, 'sitemap');
790 1057
791 1058 if ($with_image_ns && !empty($settings['include_images'])) {
792 1059 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
793 1060 } else {
@@ -965,9 +1232,16 @@
965 1232 '_builtin' => false
966 1233 ], 'names');
967 1234
968 1235 foreach ($custom_taxonomies as $taxonomy) {
969 - if ($this->should_include_taxonomy($taxonomy)) {
1236 + if (!$this->should_include_taxonomy($taxonomy)) {
1237 + continue;
1238 + }
1239 +
1240 + // Same as the post-type walk above: an explicit per-taxonomy flag
1241 + // now decides, and an unset flag keeps the previous "included"
1242 + // behaviour (#660).
1243 + if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $settings)) {
970 1244 $taxonomies[] = $taxonomy;
971 1245 }
972 1246 }
973 1247
@@ -1451,9 +1725,15 @@
1451 1725 if (!empty($settings['include_pages'])) {
1452 1726 $post_types[] = 'page';
1453 1727 }
1454 1728
1455 - // Auto-detect public custom post types that should be included
1729 + // Auto-detect public custom post types that should be included.
1730 + //
1731 + // A custom type's `include_<slug>` / `exclude_<slug>` flag is honoured
1732 + // here (#660). It was previously stored — additional_setting_keys()
1733 + // has always let those keys through — but never read, so a CPT was in
1734 + // the sitemap whatever the setting said. Unset still means included, so
1735 + // a site that never touched the flag is unaffected.
1456 1736 $custom_post_types = get_post_types([
1457 1737 'public' => true,
1458 1738 '_builtin' => false
1459 1739 ], 'names');
@@ -1458,9 +1738,13 @@
1458 1738 '_builtin' => false
1459 1739 ], 'names');
1460 1740
1461 1741 foreach ($custom_post_types as $post_type) {
1462 - if ($this->should_include_post_type($post_type)) {
1742 + if (!$this->should_include_post_type($post_type)) {
1743 + continue;
1744 + }
1745 +
1746 + if (\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $post_type, $settings)) {
1463 1747 $post_types[] = $post_type;
1464 1748 }
1465 1749 }
1466 1750
@@ -1481,18 +1765,11 @@
1481 1765 // Templately's internal `templately_library` store — which are template
1482 1766 // records, not standalone indexable URLs. BetterDocs `docs` and
1483 1767 // WooCommerce `product` register exclude_from_search => false, so they
1484 1768 // remain included.
1485 - if (!is_post_type_viewable($post_type)) {
1486 - return false;
1487 - }
1488 -
1489 - $post_type_obj = get_post_type_object($post_type);
1490 - if (!$post_type_obj || !empty($post_type_obj->exclude_from_search)) {
1491 - return false;
1492 - }
1493 -
1494 - return true;
1769 + // The predicate lives in Content_Type_Settings so the matrix can ask
1770 + // the same question before offering a switch for this post type.
1771 + return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_post_type($post_type);
1495 1772 }
1496 1773
1497 1774 /**
1498 1775 * Check if taxonomy should trigger regeneration
@@ -1501,11 +1778,13 @@
1501 1778 * @param string $taxonomy Taxonomy slug
1502 1779 * @return bool True if taxonomy should trigger regeneration
1503 1780 */
1504 1781 private function should_include_taxonomy(string $taxonomy): bool {
1505 - // Include all public taxonomies (presets control which sitemaps are created)
1506 - $taxonomy_obj = get_taxonomy($taxonomy);
1507 - return $taxonomy_obj && $taxonomy_obj->public;
1782 + // Public taxonomies only, and only the ones this generator can actually
1783 + // emit — the same predicate the content-type matrix asks before it
1784 + // offers a sitemap switch for one (presets still control which
1785 + // sitemaps are created).
1786 + return \ThinkRank\SEO\Content_Type_Settings::sitemap_accepts_taxonomy($taxonomy);
1508 1787 }
1509 1788
1510 1789 /**
1511 1790 * Schedule debounced sitemap regeneration
@@ -1513,16 +1792,356 @@
1513 1792 * @since 1.0.0
1514 1793 * @return void
1515 1794 */
1516 1795 private function schedule_debounced_regeneration(): void {
1517 - // Clear any existing scheduled regeneration
1518 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap');
1796 + $this->mark_regeneration_pending('content');
1797 + $this->debounce_event('thinkrank_regenerate_sitemap');
1798 + }
1519 1799
1520 - // Schedule regeneration in 30 seconds to debounce rapid changes
1521 - wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap');
1800 + /**
1801 + * Schedule (or keep) the debounced single event behind a regeneration hook.
1802 + *
1803 + * An event that is already due is left alone. WP-Cron only runs when a
1804 + * request arrives, so on a site with DISABLE_WP_CRON, a blocked loopback or
1805 + * little traffic an overdue event can sit in the queue for a long time —
1806 + * clearing and re-scheduling it on every save pushed the rebuild
1807 + * permanently 30 seconds into the future and the sitemap never updated
1808 + * (#629). Debouncing only against an event that has not come due yet keeps
1809 + * the bulk-edit coalescing without starving the rebuild.
1810 + *
1811 + * @since 2.2.1
1812 + * @param string $hook Regeneration hook to debounce.
1813 + * @return void
1814 + */
1815 + private function debounce_event(string $hook): void {
1816 + $next = wp_next_scheduled($hook);
1817 +
1818 + if ($next !== false) {
1819 + if ($next <= time()) {
1820 + return;
1821 + }
1822 +
1823 + wp_clear_scheduled_hook($hook);
1824 + }
1825 +
1826 + wp_schedule_single_event(time() + self::REGENERATION_DEBOUNCE, $hook);
1522 1827 }
1523 1828
1524 1829 /**
1830 + * Record that a rebuild is outstanding, so an overdue one can be taken over
1831 + * by a later request and its staleness surfaced in the UI.
1832 + *
1833 + * `since` is the *oldest* outstanding change: it is what the takeover grace
1834 + * and the admin staleness warning are measured from, so successive edits
1835 + * must not push it forward. A settings change outranks a content change —
1836 + * it rebuilds regardless of the auto_generate toggle and handles a sitemap
1837 + * that has just been disabled — so once one is outstanding it stays the
1838 + * recorded source until the rebuild lands.
1839 + *
1840 + * @since 2.2.1
1841 + * @param string $source Either 'content' or 'settings'.
1842 + * @return void
1843 + */
1844 + private function mark_regeneration_pending(string $source): void {
1845 + // Whatever made the static files stale made the rendered ones stale
1846 + // too. Invalidating here rather than only on the rebuild keeps the two
1847 + // delivery modes reacting to exactly the same triggers, which is the
1848 + // only way a dynamic site stays as fresh as a static one (#752).
1849 + $this->flush_dynamic_cache();
1850 +
1851 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1852 + $pending = is_array($pending) ? $pending : [];
1853 +
1854 + $since = !empty($pending['since']) ? (int) $pending['since'] : time();
1855 + $current = isset($pending['source']) ? (string) $pending['source'] : '';
1856 + $source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1857 +
1858 + update_option(
1859 + self::REGENERATION_PENDING_OPTION,
1860 + [
1861 + 'since' => $since,
1862 + 'source' => $source,
1863 + 'attempts' => !empty($pending['attempts']) ? (int) $pending['attempts'] : 0,
1864 + 'next_attempt' => !empty($pending['next_attempt'])
1865 + ? (int) $pending['next_attempt']
1866 + : time() + self::REGENERATION_TAKEOVER_GRACE,
1867 + // Bumped on every change so a rebuild can tell whether the edit
1868 + // it started for is still the newest one outstanding.
1869 + 'revision' => (!empty($pending['revision']) ? (int) $pending['revision'] : 0) + 1,
1870 + ],
1871 + true
1872 + );
1873 + }
1874 +
1875 + /**
1876 + * The revision of the outstanding rebuild, for
1877 + * {@see mark_regeneration_complete()} to compare against once it is done.
1878 + *
1879 + * @since 2.2.1
1880 + * @return int Current revision, 0 when nothing is outstanding.
1881 + */
1882 + private function current_regeneration_revision(): int {
1883 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1884 +
1885 + return (is_array($pending) && !empty($pending['revision'])) ? (int) $pending['revision'] : 0;
1886 + }
1887 +
1888 + /**
1889 + * Clear the outstanding-rebuild marker and any recorded failure.
1890 + *
1891 + * Public because a manual generation satisfies whatever the automatic path
1892 + * was still waiting to write.
1893 + *
1894 + * @since 2.2.1
1895 + * @return void
1896 + */
1897 + public function mark_regeneration_complete(?int $revision = null): void {
1898 + // The write succeeded, so whatever failure was on record is history.
1899 + if (get_option(self::REGENERATION_ERROR_OPTION, null) !== null) {
1900 + delete_option(self::REGENERATION_ERROR_OPTION);
1901 + }
1902 +
1903 + $pending = get_option(self::REGENERATION_PENDING_OPTION, null);
1904 +
1905 + if ($pending === null) {
1906 + return;
1907 + }
1908 +
1909 + // A change that landed while this rebuild was running is not covered by
1910 + // the files it just wrote, so it has to stay outstanding — otherwise, on
1911 + // a site where WP-Cron never fires, clearing the marker would strand it
1912 + // exactly the way #629 stranded everything.
1913 + if (
1914 + $revision !== null
1915 + && is_array($pending)
1916 + && (int) ($pending['revision'] ?? 0) !== $revision
1917 + ) {
1918 + return;
1919 + }
1920 +
1921 + delete_option(self::REGENERATION_PENDING_OPTION);
1922 + }
1923 +
1924 + /**
1925 + * Record a failed regeneration instead of discarding it.
1926 + *
1927 + * Keeps the pending marker in place so the rebuild is retried, but backs the
1928 + * next attempt off exponentially (capped) so a persistently failing
1929 + * generation cannot run on every admin request.
1930 + *
1931 + * @since 2.2.1
1932 + * @param string $message Failure detail.
1933 + * @param string $source Either 'content' or 'settings'.
1934 + * @return void
1935 + */
1936 + private function record_regeneration_failure(string $message, string $source): void {
1937 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1938 + $pending = is_array($pending) ? $pending : [];
1939 + $attempts = (!empty($pending['attempts']) ? (int) $pending['attempts'] : 0) + 1;
1940 +
1941 + $backoff = min(
1942 + self::REGENERATION_TAKEOVER_GRACE * (2 ** min($attempts, 10)),
1943 + self::REGENERATION_MAX_BACKOFF
1944 + );
1945 +
1946 + // Same precedence mark_regeneration_pending() enforces: a settings
1947 + // rebuild outranks a content one and must not be downgraded by a failed
1948 + // attempt. Overwriting it routed the retry back through the content
1949 + // path, where should_auto_generate() can be false and the completion
1950 + // marker then discards the settings rebuild entirely. Only the
1951 + // outstanding rebuild is upgraded — the recorded error keeps reporting
1952 + // whichever attempt actually failed.
1953 + $current = isset($pending['source']) ? (string) $pending['source'] : '';
1954 + $pending_source = ($current === 'settings' || $source === 'settings') ? 'settings' : 'content';
1955 +
1956 + update_option(
1957 + self::REGENERATION_PENDING_OPTION,
1958 + [
1959 + 'since' => !empty($pending['since']) ? (int) $pending['since'] : time(),
1960 + 'source' => $pending_source,
1961 + 'attempts' => $attempts,
1962 + 'next_attempt' => time() + $backoff,
1963 + 'revision' => !empty($pending['revision']) ? (int) $pending['revision'] : 0,
1964 + ],
1965 + true
1966 + );
1967 +
1968 + update_option(
1969 + self::REGENERATION_ERROR_OPTION,
1970 + [
1971 + 'message' => $message,
1972 + 'source' => $source,
1973 + 'attempts' => $attempts,
1974 + 'time' => time(),
1975 + ],
1976 + false
1977 + );
1978 +
1979 + if (defined('WP_DEBUG') && WP_DEBUG) {
1980 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1981 + error_log(sprintf('ThinkRank: sitemap %s regeneration failed — %s', $source, $message));
1982 + }
1983 + }
1984 +
1985 + /**
1986 + * Is a rebuild outstanding and past the point where WP-Cron should have run
1987 + * it?
1988 + *
1989 + * Deliberately cheap — one autoloaded option read — because it is consulted
1990 + * on every admin request to decide whether the takeover is needed.
1991 + *
1992 + * @since 2.2.1
1993 + * @return bool True when a request should rebuild the sitemap itself.
1994 + */
1995 + public static function has_overdue_regeneration(): bool {
1996 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1997 +
1998 + if (!is_array($pending) || empty($pending['since'])) {
1999 + return false;
2000 + }
2001 +
2002 + $due = !empty($pending['next_attempt'])
2003 + ? (int) $pending['next_attempt']
2004 + : (int) $pending['since'] + self::REGENERATION_TAKEOVER_GRACE;
2005 +
2006 + return time() >= $due;
2007 + }
2008 +
2009 + /**
2010 + * Rebuild the sitemap in-request when WP-Cron has not delivered.
2011 + *
2012 + * Hooked on `shutdown` for admin, REST and CLI requests only (see
2013 + * Plugin::register_sitemap_cron_listeners()), so the work happens after the
2014 + * response has been sent and never adds latency to a visitor page view.
2015 + *
2016 + * @since 2.2.1
2017 + * @return void
2018 + */
2019 + public function run_overdue_regeneration(): void {
2020 + if (!self::has_overdue_regeneration()) {
2021 + return;
2022 + }
2023 +
2024 + // Cron is running: it is about to do exactly this work.
2025 + if (wp_doing_cron()) {
2026 + return;
2027 + }
2028 +
2029 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2030 + $source = (is_array($pending) && isset($pending['source'])) ? (string) $pending['source'] : 'content';
2031 +
2032 + if ($source === 'settings') {
2033 + $this->regenerate_sitemap_from_settings();
2034 + return;
2035 + }
2036 +
2037 + $this->auto_regenerate_sitemap();
2038 + }
2039 +
2040 + /**
2041 + * Acquire the shared generation lock.
2042 + *
2043 + * @since 2.2.1
2044 + * @return bool True when this process may generate.
2045 + */
2046 + private function acquire_generation_lock(): bool {
2047 + if (get_transient(self::GENERATION_LOCK_TRANSIENT)) {
2048 + return false;
2049 + }
2050 +
2051 + set_transient(self::GENERATION_LOCK_TRANSIENT, time(), 5 * MINUTE_IN_SECONDS);
2052 +
2053 + return true;
2054 + }
2055 +
2056 + /**
2057 + * Release the shared generation lock.
2058 + *
2059 + * @since 2.2.1
2060 + * @return void
2061 + */
2062 + private function release_generation_lock(): void {
2063 + delete_transient(self::GENERATION_LOCK_TRANSIENT);
2064 + }
2065 +
2066 + /**
2067 + * Report how automatic regeneration is faring, for the admin UI.
2068 + *
2069 + * The feature used to fail invisibly: `last_generated` simply stopped
2070 + * advancing and nothing drew attention to it (#629).
2071 + *
2072 + * @since 2.2.1
2073 + * @return array Health payload.
2074 + */
2075 + public function get_regeneration_health(): array {
2076 + $settings = $this->get_settings('site');
2077 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2078 + $pending = is_array($pending) ? $pending : [];
2079 + $error = get_option(self::REGENERATION_ERROR_OPTION, []);
2080 + $error = is_array($error) ? $error : [];
2081 +
2082 + $since = !empty($pending['since']) ? (int) $pending['since'] : 0;
2083 +
2084 + $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap');
2085 + if ($next_scheduled === false) {
2086 + $next_scheduled = wp_next_scheduled('thinkrank_regenerate_sitemap_settings');
2087 + }
2088 +
2089 + return [
2090 + 'auto_generate' => !empty($settings['auto_generate']),
2091 + 'last_generated' => $settings['last_generated'] ?? '',
2092 + 'pending_since' => $since ? gmdate('c', $since) : null,
2093 + 'pending_seconds' => $since ? max(0, time() - $since) : 0,
2094 + 'next_scheduled' => $next_scheduled ? gmdate('c', (int) $next_scheduled) : null,
2095 + 'cron_disabled' => defined('DISABLE_WP_CRON') && DISABLE_WP_CRON,
2096 + 'stale' => $this->is_sitemap_stale($settings),
2097 + 'last_error' => !empty($error['message'])
2098 + ? [
2099 + 'message' => (string) $error['message'],
2100 + 'source' => isset($error['source']) ? (string) $error['source'] : 'content',
2101 + 'time' => !empty($error['time']) ? gmdate('c', (int) $error['time']) : null,
2102 + ]
2103 + : null,
2104 + ];
2105 + }
2106 +
2107 + /**
2108 + * Has published content changed since the served sitemap was last written?
2109 + *
2110 + * Uses core's cached last-modified lookup, which only considers published
2111 + * posts — the same content the sitemap covers.
2112 + *
2113 + * @since 2.2.1
2114 + * @param array $settings Sitemap settings.
2115 + * @return bool True when the sitemap is behind the content.
2116 + */
2117 + private function is_sitemap_stale(array $settings): bool {
2118 + if (empty($settings['enabled']) || empty($settings['last_generated'])) {
2119 + // Never generated is already reported separately by the UI.
2120 + return false;
2121 + }
2122 +
2123 + $generated = strtotime((string) $settings['last_generated']);
2124 + if (!$generated) {
2125 + return false;
2126 + }
2127 +
2128 + $modified = get_lastpostmodified('gmt');
2129 + if (!$modified) {
2130 + return false;
2131 + }
2132 +
2133 + $modified = strtotime($modified . ' UTC');
2134 + if (!$modified) {
2135 + return false;
2136 + }
2137 +
2138 + // A minute of slack keeps a rebuild that ran alongside the edit from
2139 + // reporting itself as stale.
2140 + return $modified > ($generated + MINUTE_IN_SECONDS);
2141 + }
2142 +
2143 + /**
1525 2144 * Public entry point to debounce-rebuild the sitemap after a settings change
1526 2145 * (e.g. toggling inclusion rules via REST or the MCP ability), so the served
1527 2146 * file reflects the new settings instead of going stale until a content edit.
1528 2147 *
@@ -1531,10 +2150,10 @@
1531 2150 public function schedule_regeneration(): void {
1532 2151 // Debounce against rapid successive saves, but use the settings-specific
1533 2152 // hook so the rebuild runs regardless of the auto_generate toggle (which
1534 2153 // only governs content-change-triggered regeneration).
1535 - wp_clear_scheduled_hook('thinkrank_regenerate_sitemap_settings');
1536 - wp_schedule_single_event(time() + 30, 'thinkrank_regenerate_sitemap_settings');
2154 + $this->mark_regeneration_pending('settings');
2155 + $this->debounce_event('thinkrank_regenerate_sitemap_settings');
1537 2156 }
1538 2157
1539 2158 /**
1540 2159 * Rebuild the served sitemap after an explicit settings change.
@@ -1543,11 +2162,22 @@
1543 2162 * auto_generate setting: the user deliberately changed inclusion rules and
1544 2163 * expects the served file to reflect them even if content-triggered
1545 2164 * auto-generation is turned off. Still respects the master `enabled` flag.
1546 2165 *
1547 - * @return void
2166 + * @since 2.10.0 Reports whether the served sitemap was actually rebuilt, so
2167 + * a caller can say so rather than assume it (#764). Existing
2168 + * callers that ignore the return are unaffected.
2169 + *
2170 + * @return bool True when the served sitemap now reflects the settings.
1548 2171 */
1549 - public function regenerate_sitemap_from_settings(): void {
2172 + public function regenerate_sitemap_from_settings(): bool {
2173 + if (!$this->acquire_generation_lock()) {
2174 + // A manual generation (or another request's takeover) is already
2175 + // writing the files; the pending marker survives so this rebuild is
2176 + // retried rather than lost.
2177 + return false;
2178 + }
2179 +
1550 2180 try {
1551 2181 $settings = $this->get_settings('site');
1552 2182 if (empty($settings['enabled'])) {
1553 2183 // The sitemap was disabled: remove the previously generated static
@@ -1552,18 +2182,81 @@
1552 2182 if (empty($settings['enabled'])) {
1553 2183 // The sitemap was disabled: remove the previously generated static
1554 2184 // files so the web server stops serving a stale sitemap that
1555 2185 // crawlers would otherwise keep fetching.
1556 - $this->delete_published_sitemaps();
1557 - return;
2186 + //
2187 + // A file that could not be removed is still being served, so
2188 + // this is not a success. Reporting one here would tell a caller
2189 + // the sitemap was gone while the web server kept answering with
2190 + // it, which is the failure this return value exists to prevent
2191 + // (#764).
2192 + $removal = $this->delete_published_sitemaps($settings);
2193 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2194 +
2195 + if (!empty($stuck)) {
2196 + $this->record_regeneration_failure(
2197 + $this->stuck_files_message($stuck, true),
2198 + 'settings'
2199 + );
2200 +
2201 + return false;
2202 + }
2203 +
2204 + $this->mark_regeneration_complete();
2205 +
2206 + return true;
1558 2207 }
1559 - $this->generate_and_save($settings);
2208 +
2209 + $revision = $this->current_regeneration_revision();
2210 +
2211 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2212 + // Returns false when a static file is stuck in the web root:
2213 + // the server keeps serving that file in preference to WordPress,
2214 + // so the switch has not taken effect (#764).
2215 + return $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2216 + }
2217 +
2218 + if ($this->generate_and_save($settings)) {
2219 + $this->mark_regeneration_complete($revision);
2220 +
2221 + return true;
2222 + }
2223 +
2224 + $this->record_regeneration_failure(
2225 + $this->write_failure_message(),
2226 + 'settings'
2227 + );
2228 +
2229 + return false;
1560 2230 } catch (\Throwable $e) {
1561 - // Settings-triggered regeneration failed - details in exception.
2231 + $this->record_regeneration_failure($e->getMessage(), 'settings');
2232 +
2233 + return false;
2234 + } finally {
2235 + $this->release_generation_lock();
1562 2236 }
1563 2237 }
1564 2238
1565 2239 /**
2240 + * When a rebuild has been outstanding since, or 0 when none is.
2241 + *
2242 + * Lets a caller report an honest "saved, but the served file has not caught
2243 + * up yet" instead of a bare success (#764).
2244 + *
2245 + * @since 2.10.0
2246 + * @return int Unix timestamp, or 0 when nothing is pending.
2247 + */
2248 + public static function regeneration_pending_since(): int {
2249 + $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
2250 +
2251 + if (!is_array($pending) || empty($pending['since'])) {
2252 + return 0;
2253 + }
2254 +
2255 + return (int) $pending['since'];
2256 + }
2257 +
2258 + /**
1566 2259 * Remove every static sitemap file ThinkRank publishes to the web root.
1567 2260 *
1568 2261 * Called when the sitemap feature is disabled, by the cleanup route, and by
1569 2262 * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
@@ -1645,20 +2338,48 @@
1645 2338 * @since 1.0.0
1646 2339 * @return void
1647 2340 */
1648 2341 public function auto_regenerate_sitemap(): void {
2342 + if (!$this->acquire_generation_lock()) {
2343 + // A manual generation (or another request's takeover) is already
2344 + // writing the files; the pending marker survives so this rebuild is
2345 + // retried rather than lost.
2346 + return;
2347 + }
2348 +
1649 2349 try {
1650 2350 // Double-check that auto-generation is still enabled
1651 2351 if (!$this->should_auto_generate()) {
2352 + // Nothing outstanding can be delivered while the feature is off,
2353 + // so drop the marker rather than let the takeover retry forever.
2354 + $this->mark_regeneration_complete();
1652 2355 return;
1653 2356 }
1654 2357
1655 - $this->generate_and_save($this->get_settings('site'));
2358 + $revision = $this->current_regeneration_revision();
2359 + $settings = $this->get_settings('site');
1656 2360
1657 - // Sitemap auto-regenerated successfully
2361 + // See regenerate_sitemap_from_settings(): nothing to write.
2362 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2363 + $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2364 + return;
2365 + }
1658 2366
2367 + if ($this->generate_and_save($settings)) {
2368 + $this->mark_regeneration_complete($revision);
2369 + } else {
2370 + // Previously this returned quietly and last_generated simply
2371 + // stopped advancing, leaving the site owner with no way to learn
2372 + // the sitemap had stopped updating (#629).
2373 + $this->record_regeneration_failure(
2374 + $this->write_failure_message(),
2375 + 'content'
2376 + );
2377 + }
1659 2378 } catch (\Throwable $e) {
1660 - // Sitemap auto-regeneration failed - error details available in exception
2379 + $this->record_regeneration_failure($e->getMessage(), 'content');
2380 + } finally {
2381 + $this->release_generation_lock();
1661 2382 }
1662 2383 }
1663 2384
1664 2385 /**
@@ -1673,8 +2394,26 @@
1673 2394 * @param array $settings Sitemap settings.
1674 2395 * @return bool True when the sitemap files were written.
1675 2396 */
1676 2397 public function generate_and_save(array $settings): bool {
2398 + // Dynamic delivery publishes no files, so writing them here would put a
2399 + // static copy back in the web root for the server to serve in place of
2400 + // the dynamic route. Guarding at each call site left gaps — the
2401 + // snapshot migrator's post-import regeneration had none — so the rule
2402 + // lives with the writing instead.
2403 + //
2404 + // `is_collecting()` is the exception that makes dynamic delivery work
2405 + // at all: render_document() and collect_documents() reach this same
2406 + // method with the writer swapped for a collector, and that is precisely
2407 + // the dynamic build. Only a real write is skipped.
2408 + if (!$this->is_collecting() && 'dynamic' === $this->resolve_delivery_mode($settings)) {
2409 + // Whatever prompted this call changed the sitemap's content, so the
2410 + // rendered copies must not outlive it.
2411 + $this->flush_dynamic_cache();
2412 +
2413 + return true;
2414 + }
2415 +
1677 2416 // Index mode is driven by the use_sitemap_index toggle (not merely by how
1678 2417 // many sitemap_urls happen to be configured). When the toggle is on but
1679 2418 // no child sitemaps are set up yet, synthesize the per-type segmented set
1680 2419 // so we emit a real <sitemapindex> with paginated children instead of a
@@ -1701,9 +2440,13 @@
1701 2440 // names is never touched (#515).
1702 2441 $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
1703 2442 }
1704 2443
1705 - if ($written) {
2444 + // last_generated describes what is on disk. A dynamic render publishes
2445 + // nothing, so advancing it would report a static publication that never
2446 + // happened and would let primary_sitemap_file_exists() callers believe
2447 + // there is a file to serve.
2448 + if ($written && !$this->is_collecting()) {
1706 2449 $settings['last_generated'] = gmdate('c');
1707 2450 $this->save_settings('site', null, $settings);
1708 2451 }
1709 2452
@@ -1710,8 +2453,433 @@
1710 2453 return $written;
1711 2454 }
1712 2455
1713 2456 /**
2457 + * Whether this instance is rendering documents rather than publishing them.
2458 + *
2459 + * @since 2.9.0
2460 + *
2461 + * @return bool
2462 + */
2463 + private function is_collecting(): bool {
2464 + return $this->document_sink !== null;
2465 + }
2466 +
2467 + /**
2468 + * Complete a regeneration that delivers dynamically, retiring stale files.
2469 + *
2470 + * Dynamic delivery renders nothing to disk, but that is only half the job.
2471 + * A web server hands back an existing `/sitemap.xml` without ever loading
2472 + * WordPress, so any file left over from a previous static generation goes on
2473 + * being served forever and {@see \ThinkRank\Frontend\SEO_Manager
2474 + * ::maybe_serve_sitemap()} is never reached. Switching to dynamic while
2475 + * leaving those files in place would therefore appear to do nothing at all.
2476 + *
2477 + * Both transitions matter and they differ:
2478 + *
2479 + * - An explicit switch to `dynamic` happens on a site whose root is usually
2480 + * still writable, so the files can simply be removed.
2481 + * - An `auto` site that becomes read-only cannot remove them, because
2482 + * deleting an entry needs write permission on the directory that holds
2483 + * it. There the stale sitemap really is stuck in front of us, and the
2484 + * honest outcome is a recorded failure naming it rather than a rebuild
2485 + * reported as complete (#754 review).
2486 + *
2487 + * Ownership is tested per file by the shared helper, so another plugin's
2488 + * sitemap at one of our names is never deleted (#515).
2489 + *
2490 + * @since 2.9.0
2491 + *
2492 + * @param array $settings Sitemap settings.
2493 + * @param int $revision Revision this rebuild is completing.
2494 + * @param string $source 'settings' or 'content', for the failure record.
2495 + * @return void
2496 + */
2497 + private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): bool {
2498 + $this->flush_dynamic_cache();
2499 +
2500 + $removal = $this->delete_published_sitemaps($settings);
2501 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2502 +
2503 + if (!empty($stuck)) {
2504 + $this->record_regeneration_failure($this->stuck_files_message($stuck), $source);
2505 +
2506 + return false;
2507 + }
2508 +
2509 + $this->mark_regeneration_complete($revision);
2510 +
2511 + return true;
2512 + }
2513 +
2514 + /**
2515 + * Why a stale file left in the web root means the change has not landed.
2516 + *
2517 + * Shared by every path that removes published files, so they cannot
2518 + * describe the same situation differently (#764).
2519 + *
2520 + * The two situations that reach it differ in what WordPress is doing, and
2521 + * the message has to say which. After a switch to dynamic delivery
2522 + * WordPress IS serving the sitemap and the files shadow it. After the
2523 + * sitemap is switched off WordPress serves nothing, so the one message
2524 + * used to tell a site owner who had just disabled the sitemap that it was
2525 + * "being served from WordPress", which is the opposite of what they did.
2526 + *
2527 + * @since 2.10.0
2528 + * @since 2.10.0 Public, so the REST endpoint uses it rather than a copy;
2529 + * takes $sitemap_disabled for the disabled path.
2530 + *
2531 + * @param string[] $stuck Basenames that could not be removed.
2532 + * @param bool $sitemap_disabled True when the files outlived disabling
2533 + * the sitemap rather than a switch to
2534 + * dynamic delivery.
2535 + * @return string
2536 + */
2537 + public function stuck_files_message(array $stuck, bool $sitemap_disabled = false): string {
2538 + if ($sitemap_disabled) {
2539 + return sprintf(
2540 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2541 + __('The sitemap is disabled, but these files are still in the site root and your web server is still serving them: %1$s. They could not be removed because %2$s is not writable. Delete them, or ask your host to make the WordPress root writable.', 'thinkrank'),
2542 + implode(', ', $stuck),
2543 + untrailingslashit(ABSPATH)
2544 + );
2545 + }
2546 +
2547 + return sprintf(
2548 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2549 + __('The sitemap is being served from WordPress, but these files are still in the site root and your web server will keep serving them instead: %1$s. They could not be removed because %2$s is not writable. Delete them, or ask your host to make the WordPress root writable.', 'thinkrank'),
2550 + implode(', ', $stuck),
2551 + untrailingslashit(ABSPATH)
2552 + );
2553 + }
2554 +
2555 + /**
2556 + * What to tell the site owner when publishing the files failed.
2557 + *
2558 + * The old wording stated the symptom and stopped there, so the reported
2559 + * cause was a guess and this reached support as a plugin fault rather than
2560 + * a folder permission (#752, #753). When the root is demonstrably
2561 + * unwritable, say that, and say what to do about it.
2562 + *
2563 + * @since 2.9.0
2564 + *
2565 + * @return string
2566 + */
2567 + private function write_failure_message(): string {
2568 + if (!wp_is_writable(ABSPATH)) {
2569 + return sprintf(
2570 + /* translators: %s: absolute path to the WordPress root. */
2571 + __('The sitemap could not be written because the folder %s is not writable by PHP. Ask your host to make the WordPress root writable, or set Sitemap Delivery to Dynamic to serve the sitemap without writing files.', 'thinkrank'),
2572 + untrailingslashit(ABSPATH)
2573 + );
2574 + }
2575 +
2576 + return __('The sitemap files could not be written to the site root.', 'thinkrank');
2577 + }
2578 +
2579 + /**
2580 + * How this site delivers its sitemap.
2581 + *
2582 + * `auto` is resolved on whether the web root can be written. That is the
2583 + * right signal here (unlike llms.txt, where the question is whether the
2584 + * server applies the .htaccess charset block): a site whose root is
2585 + * read-only cannot publish a sitemap file at all, and before this existed
2586 + * the feature simply failed with "The sitemap files could not be written to
2587 + * the site root." and served nothing (#752).
2588 + *
2589 + * @since 2.9.0
2590 + *
2591 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2592 + * @return string One of 'static' or 'dynamic'. Never 'auto'.
2593 + */
2594 + public function resolve_delivery_mode(?array $settings = null): string {
2595 + $settings = $settings ?? $this->get_settings('site');
2596 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
2597 +
2598 + if ('static' === $mode || 'dynamic' === $mode) {
2599 + return $mode;
2600 + }
2601 +
2602 + return wp_is_writable(ABSPATH) ? 'static' : 'dynamic';
2603 + }
2604 +
2605 + /**
2606 + * Render one published sitemap document without touching the filesystem.
2607 + *
2608 + * Runs the ordinary build pipeline with the writer swapped for a collector,
2609 + * so the bytes returned here are the bytes the static path would have
2610 + * written. `SitemapDeliveryParityTest` asserts that equivalence rather than
2611 + * trusting it.
2612 + *
2613 + * The whole set is built to answer for one file, because the index can only
2614 + * be assembled from the children that were actually produced. The result is
2615 + * cached per document, so that cost is paid once per change and not once
2616 + * per crawler request.
2617 + *
2618 + * @since 2.9.0
2619 + *
2620 + * @param string $filename Published file name, e.g. 'sitemap.xml'.
2621 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2622 + * @return string|null XML, or null when this site does not publish that name.
2623 + */
2624 + public function render_document(string $filename, ?array $settings = null): ?string {
2625 + $filename = basename($filename);
2626 + $settings = $settings ?? $this->get_settings('site');
2627 +
2628 + if (empty($settings['enabled'])) {
2629 + return null;
2630 + }
2631 +
2632 + $cached = get_transient($this->dynamic_cache_key($filename));
2633 + if (self::ABSENT_MARKER === $cached) {
2634 + return null;
2635 + }
2636 + if (is_string($cached) && '' !== $cached) {
2637 + return $cached;
2638 + }
2639 +
2640 + // A miss builds the whole set, because the index can only be assembled
2641 + // from the children that were actually produced. Caching only the
2642 + // requested document therefore made a crawler walking the index and its
2643 + // children rebuild the entire site's sitemap once per file — every post
2644 + // and taxonomy query repeated N times on a public endpoint (#754
2645 + // review). The set is built once and stored in full.
2646 + return $this->stream_documents($settings, $filename);
2647 + }
2648 +
2649 + /**
2650 + * Build every document, caching each as it is produced, keeping one.
2651 + *
2652 + * A miss has to build the whole set, because the index can only be
2653 + * assembled from the children that were actually produced. It does not have
2654 + * to *hold* the whole set: the static path never keeps more than one page
2655 + * in memory, writing each to disk as it goes, and buffering every
2656 + * document's XML to return one of them undid that on the request path,
2657 + * where a large site's entire sitemap corpus would sit in a single PHP
2658 + * process (#754 review).
2659 + *
2660 + * So the sink writes each document straight to its cache entry and lets it
2661 + * go, retaining only the one this request is answering. Peak retention is
2662 + * one document, whatever the site's size.
2663 + *
2664 + * Concurrency: the first request through takes a short lock and does the
2665 + * work. One that finds the lock held waits a bounded moment for the winner
2666 + * to publish, then builds anyway, because serving a correct sitemap late
2667 + * beats serving none.
2668 + *
2669 + * @since 2.9.0
2670 + *
2671 + * @param array $settings Sitemap settings.
2672 + * @param string $wanted Document this request is answering.
2673 + * @return string|null XML for $wanted, or null when the site does not publish it.
2674 + */
2675 + private function stream_documents(array $settings, string $wanted): ?string {
2676 + $lock = self::DYNAMIC_CACHE_PREFIX . 'lock';
2677 +
2678 + if (!$this->acquire_render_lock($lock)) {
2679 + for ($attempt = 0; $attempt < self::RENDER_LOCK_WAIT_ATTEMPTS; $attempt++) {
2680 + usleep(self::RENDER_LOCK_WAIT_MICROSECONDS);
2681 +
2682 + $cached = get_transient($this->dynamic_cache_key($wanted));
2683 + if (self::ABSENT_MARKER === $cached) {
2684 + return null;
2685 + }
2686 + if (is_string($cached) && '' !== $cached) {
2687 + return $cached;
2688 + }
2689 + }
2690 + }
2691 +
2692 + $kept = null;
2693 + // Names only. Keeping the bodies here would be the very retention this
2694 + // method exists to avoid.
2695 + $produced = [];
2696 +
2697 + $previous = $this->document_sink;
2698 + $this->document_sink = function (string $name, string $xml) use (&$kept, &$produced, $wanted): void {
2699 + $produced[$name] = true;
2700 + set_transient($this->dynamic_cache_key($name), $xml, self::DYNAMIC_CACHE_TTL);
2701 +
2702 + if ($name === $wanted) {
2703 + $kept = $xml;
2704 + }
2705 + };
2706 +
2707 + try {
2708 + $this->generate_and_save($settings);
2709 +
2710 + // Names the configuration lists but this build did not produce get
2711 + // a negative entry, so asking for one again is a cache hit rather
2712 + // than another full rebuild.
2713 + $absent = $this->published_document_names($settings);
2714 +
2715 + // Also the exact name this request asked for: a paginated page past
2716 + // the end of a stem is a legitimate request shape that the base
2717 + // list cannot enumerate, and without an entry it would rebuild on
2718 + // every hit.
2719 + $absent[] = $wanted;
2720 +
2721 + foreach (array_unique($absent) as $name) {
2722 + if (!isset($produced[$name])) {
2723 + set_transient($this->dynamic_cache_key($name), self::ABSENT_MARKER, self::DYNAMIC_CACHE_TTL);
2724 + }
2725 + }
2726 + } finally {
2727 + $this->document_sink = $previous;
2728 + delete_transient($lock);
2729 + }
2730 +
2731 + return $kept;
2732 + }
2733 +
2734 + /**
2735 + * Take the render lock, if it is free.
2736 + *
2737 + * Not atomic across processes, and deliberately so: the fallback for losing
2738 + * a race is duplicated work, never a wrong or missing sitemap, so a
2739 + * heavier primitive would buy nothing here.
2740 + *
2741 + * @since 2.9.0
2742 + *
2743 + * @param string $lock Lock transient name.
2744 + * @return bool True when this request holds the lock.
2745 + */
2746 + private function acquire_render_lock(string $lock): bool {
2747 + if (false !== get_transient($lock)) {
2748 + return false;
2749 + }
2750 +
2751 + set_transient($lock, time(), self::RENDER_LOCK_TTL);
2752 +
2753 + return true;
2754 + }
2755 +
2756 + /**
2757 + * Build every document this site publishes and return them all.
2758 + *
2759 + * Verification and tooling only. This retains the whole set in memory, so
2760 + * it must never be used to answer a request: {@see self::stream_documents()}
2761 + * is the serving path and keeps one document at a time regardless of site
2762 + * size (#754 review). `SitemapDeliveryParityTest` enforces that separation
2763 + * by failing if the request path routes back through here.
2764 + *
2765 + * @since 2.9.0
2766 + *
2767 + * @param array $settings Sitemap settings.
2768 + * @return array<string,string> Filename => XML.
2769 + */
2770 + public function collect_documents(array $settings): array {
2771 + $documents = [];
2772 +
2773 + $previous = $this->document_sink;
2774 + $this->document_sink = static function (string $name, string $xml) use (&$documents): void {
2775 + $documents[$name] = $xml;
2776 + };
2777 +
2778 + try {
2779 + $this->generate_and_save($settings);
2780 + } finally {
2781 + $this->document_sink = $previous;
2782 + }
2783 +
2784 + return $documents;
2785 + }
2786 +
2787 + /**
2788 + * The file names this site publishes, without building their contents.
2789 + *
2790 + * Used by the request router to decide whether a URL is ours before doing
2791 + * any work. Cheap: it reads the configured child list rather than querying
2792 + * for entries.
2793 + *
2794 + * @since 2.9.0
2795 + *
2796 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2797 + * @return string[] File names, including paginated pages that may exist.
2798 + */
2799 + public function published_document_names(?array $settings = null): array {
2800 + $settings = $settings ?? $this->get_settings('site');
2801 + $resolved = $this->maybe_promote_to_index($settings);
2802 +
2803 + $names = [$this->get_primary_sitemap_filename($settings), 'local-sitemap.xml'];
2804 +
2805 + foreach ((array) ($resolved['sitemap_urls'] ?? []) as $child) {
2806 + if (!is_array($child) || empty($child['enabled'])) {
2807 + continue;
2808 + }
2809 +
2810 + $path = (string) wp_parse_url((string) ($child['url'] ?? ''), PHP_URL_PATH);
2811 + if ('' !== $path) {
2812 + $names[] = basename($path);
2813 + }
2814 + }
2815 +
2816 + return array_values(array_unique(array_filter($names)));
2817 + }
2818 +
2819 + /**
2820 + * Does this site publish a document under that name?
2821 + *
2822 + * Not a plain membership test against {@see self::published_document_names()}:
2823 + * that lists the configured children, and a child over the per-file URL cap
2824 + * is split into `<stem>-2.xml`, `<stem>-3.xml` and so on, with every page
2825 + * listed in the index. Gating the request router on the base list alone
2826 + * therefore 404'd exactly the pages the index points at, which is worse than
2827 + * not serving them at all.
2828 + *
2829 + * Page counts are not knowable without building, so the stem is what is
2830 + * matched; a page that does not exist is answered by the build finding
2831 + * nothing for it, and is then cached as absent.
2832 + *
2833 + * @since 2.9.0
2834 + *
2835 + * @param string $name Requested file name.
2836 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2837 + * @return bool
2838 + */
2839 + public function publishes_document_name(string $name, ?array $settings = null): bool {
2840 + $names = $this->published_document_names($settings);
2841 +
2842 + if (in_array($name, $names, true)) {
2843 + return true;
2844 + }
2845 +
2846 + if (!preg_match('/^(.*)-\d+\.xml$/i', $name, $m)) {
2847 + return false;
2848 + }
2849 +
2850 + return in_array($m[1] . '.xml', $names, true);
2851 + }
2852 +
2853 + /**
2854 + * Transient key for a rendered document.
2855 + *
2856 + * @since 2.9.0
2857 + *
2858 + * @param string $filename Published file name.
2859 + * @return string
2860 + */
2861 + private function dynamic_cache_key(string $filename): string {
2862 + return self::DYNAMIC_CACHE_PREFIX . md5($filename);
2863 + }
2864 +
2865 + /**
2866 + * Drop every cached dynamic document.
2867 + *
2868 + * Called from the same places that mark the static files stale, so the two
2869 + * delivery modes invalidate on identical triggers.
2870 + *
2871 + * @since 2.9.0
2872 + *
2873 + * @return void
2874 + */
2875 + public function flush_dynamic_cache(): void {
2876 + foreach ($this->published_document_names() as $name) {
2877 + delete_transient($this->dynamic_cache_key($name));
2878 + }
2879 + }
2880 +
2881 + /**
1714 2882 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
1715 2883 *
1716 2884 * - When use_sitemap_index is on but no child sitemaps are configured, build
1717 2885 * the per-type segmented set so a real <sitemapindex> is produced (#127).
@@ -1906,8 +3074,18 @@
1906 3074 // File validation failed - error details available in exception
1907 3075 return false;
1908 3076 }
1909 3077
3078 + // Dynamic delivery: hand the document to the collector instead of the
3079 + // filesystem. Reported as published, because for this run it is — the
3080 + // caller's success/failure bookkeeping and the index assembly both key
3081 + // off this return value.
3082 + if ($this->document_sink !== null) {
3083 + ($this->document_sink)($filename, $sitemap_xml);
3084 +
3085 + return true;
3086 + }
3087 +
1910 3088 $sitemap_path = ABSPATH . $filename;
1911 3089
1912 3090 // Use WordPress filesystem API for better security
1913 3091 global $wp_filesystem;
@@ -1965,13 +3143,55 @@
1965 3143
1966 3144 // Public custom post types each get a child sitemap (parity with the
1967 3145 // "complete" preset and with Rank Math, which lists every public CPT).
1968 3146 foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
1969 - if ($this->should_include_post_type($cpt)) {
1970 - $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
3147 + if (!$this->should_include_post_type($cpt)) {
3148 + continue;
1971 3149 }
3150 +
3151 + // ...and the per-content-type sitemap switch (#660).
3152 + // get_enabled_post_types() already honours it, but this list is what
3153 + // index mode builds its children from — so without the same test a
3154 + // CPT the user had switched off still got its own child sitemap,
3155 + // created and streamed in full. The flags live in $inclusions, which
3156 + // is the settings array these children are derived from.
3157 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $cpt, $inclusions)) {
3158 + continue;
3159 + }
3160 +
3161 + $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
1972 3162 }
1973 3163
3164 + // Public custom taxonomies get the same treatment (#690). Flat mode has
3165 + // always walked them through get_enabled_taxonomies(); index mode built
3166 + // its children from the list above and never consulted a taxonomy at
3167 + // all, so every custom-taxonomy archive silently vanished from the
3168 + // sitemap the moment a site switched modes — and the per-taxonomy switch
3169 + // the matrix writes had nothing to act on. Same two tests the post-type
3170 + // walk applies, in the same order.
3171 + $taken = array_column($urls, 'type');
3172 +
3173 + foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
3174 + if (!$this->should_include_taxonomy($taxonomy)) {
3175 + continue;
3176 + }
3177 +
3178 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $inclusions)) {
3179 + continue;
3180 + }
3181 +
3182 + // Post types and taxonomies are separate registries, so a site can
3183 + // hold both a `foo` post type and a `foo` taxonomy. They would
3184 + // resolve to one filename, and stream_type_entries() answers post
3185 + // types first, so the second child would list the first one's file
3186 + // twice in the index rather than adding anything.
3187 + if (in_array($taxonomy, $taken, true)) {
3188 + continue;
3189 + }
3190 +
3191 + $urls[] = $this->build_child_sitemap_entry($taxonomy, $pattern);
3192 + }
3193 +
1974 3194 return $urls;
1975 3195 }
1976 3196
1977 3197 /**
@@ -2118,8 +3338,48 @@
2118 3338 if (post_type_exists($type) && !$this->should_include_post_type($type)) {
2119 3339 continue;
2120 3340 }
2121 3341
3342 + // Same for the matrix switch: a child list saved before the user
3343 + // excluded this content type still names it, and regenerating from
3344 + // that list would rewrite the file they asked not to have. Built-in
3345 + // aggregates ('posts', 'pages', ...) are not post type names, so
3346 + // post_type_exists() keeps this to real custom post types.
3347 + if (post_type_exists($type)
3348 + && !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $type, $settings)) {
3349 + continue;
3350 + }
3351 +
3352 + // The taxonomy counterpart of the two guards above (#690). A child
3353 + // list saved while a taxonomy was still included keeps naming it, so
3354 + // without this, excluding one in the matrix would still rewrite and
3355 + // re-list the file the user asked not to have. The built-in
3356 + // aggregates are named 'categories'/'tags' rather than
3357 + // 'category'/'post_tag', so taxonomy_exists() leaves them to the
3358 + // inclusion-flag check below.
3359 + $child_taxonomy = self::CHILD_TYPE_ALIASES[$type] ?? $type;
3360 + if (taxonomy_exists($child_taxonomy)
3361 + && (!$this->should_include_taxonomy($child_taxonomy)
3362 + || !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $child_taxonomy, $settings))) {
3363 + continue;
3364 + }
3365 +
3366 + // The built-in aggregates carry their switch in the inclusion flag
3367 + // rather than under a post type name, and they are the four most
3368 + // people actually use. build_segmented_sitemap_urls() drops a child
3369 + // whose flag is empty; regenerating from a list saved while it was
3370 + // still on has to make the same decision, or turning Posts, Pages,
3371 + // Categories or Tags off in the matrix rewrites and re-lists the
3372 + // very file it was asked to remove.
3373 + // An absent flag means "not configured", which every other reader
3374 + // treats as included; only a flag that is present and off excludes.
3375 + $aggregate_flag = array_search($type, self::INCLUSION_CHILD_TYPES, true);
3376 + if ($aggregate_flag !== false
3377 + && array_key_exists($aggregate_flag, $settings)
3378 + && empty($settings[$aggregate_flag])) {
3379 + continue;
3380 + }
3381 +
2122 3382 try {
2123 3383 // The single "general"/"WordPress" sitemap is one un-paginated file
2124 3384 // (there is no index to reference extra pages); it no longer drops
2125 3385 // overflow URLs.
@@ -2185,10 +3445,16 @@
2185 3445 $results['errors'][] = 'Error generating local sitemap: ' . $e->getMessage();
2186 3446 $results['success'] = false;
2187 3447 }
2188 3448
2189 - // Build the index last, from the child files actually generated.
3449 + // Build the index last, from the child files actually generated, plus the
3450 + // sitemaps other plugins own: those serve their own URLs and write no
3451 + // file here, so they are appended to the index only (#104).
2190 3452 if ($index_config !== null) {
3453 + foreach (self::additional_sitemaps() as $extra) {
3454 + $index_children[] = ['url' => $extra];
3455 + }
3456 +
2191 3457 try {
2192 3458 $index_xml = $this->generate_sitemap_index($index_children, $settings);
2193 3459 $filename = basename(wp_parse_url($index_config['url'], PHP_URL_PATH));
2194 3460
@@ -2233,8 +3499,15 @@
2233 3499 * @param array $generated Entries from $results['sitemaps_generated'].
2234 3500 * @return string[] Basenames removed.
2235 3501 */
2236 3502 private function prune_orphaned_segments(array $settings, array $generated): array {
3503 + // Rendering for a request, not publishing: there is nothing on disk
3504 + // this run owns, and a dynamic render must never delete the files a
3505 + // site's previous static mode left behind.
3506 + if ($this->is_collecting()) {
3507 + return [];
3508 + }
3509 +
2237 3510 $kept = [];
2238 3511 foreach ($generated as $entry) {
2239 3512 if (!empty($entry['filename'])) {
2240 3513 $kept[strtolower((string) $entry['filename'])] = true;
@@ -2336,9 +3609,9 @@
2336 3609 // publishes under too (this method mirrors it deliberately), so on
2337 3610 // a migrated site the file at that path may never have been ours
2338 3611 // to delete (#515).
2339 3612 $path = ABSPATH . 'local-sitemap.xml';
2340 - if (file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
3613 + if (!$this->is_collecting() && file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
2341 3614 wp_delete_file($path);
2342 3615 }
2343 3616 return false;
2344 3617 }
@@ -2360,8 +3633,25 @@
2360 3633 *
2361 3634 * @since 1.15.x
2362 3635 * @return array Zero or one URL entry
2363 3636 */
3637 + /**
3638 + * Does this site publish a local business sitemap right now?
3639 + *
3640 + * The same gate {@see self::regenerate_local_sitemap()} applies, asked
3641 + * without writing anything. Callers that need to know whether the document
3642 + * exists must not test the filesystem: under dynamic delivery it is served
3643 + * from PHP and there is no file, which is how `local-sitemap.xml` came to be
3644 + * dropped from robots.txt on exactly those sites (#752).
3645 + *
3646 + * @since 2.9.0
3647 + *
3648 + * @return bool True when the local sitemap has content to publish.
3649 + */
3650 + public function publishes_local_sitemap(): bool {
3651 + return !empty($this->collect_local_entries());
3652 + }
3653 +
2364 3654 private function collect_local_entries(): array {
2365 3655 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2366 3656 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2367 3657 }
@@ -2433,17 +3723,43 @@
2433 3723 case 'products':
2434 3724 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
2435 3725 return ['entries' => $entries, 'image_ns' => true];
2436 3726
2437 - case 'product_categories':
2438 - $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
2439 - return ['entries' => $entries, 'image_ns' => false];
3727 + default:
3728 + // Resolve a preset's display name to the object it streams, so
3729 + // 'product_categories' is an ordinary taxonomy child rather than
3730 + // a case of its own (#690).
3731 + $alias = self::CHILD_TYPE_ALIASES[$type] ?? null;
3732 + $object = $alias ?? $type;
2440 3733
2441 - default:
2442 3734 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2443 - if (in_array($type, $custom_post_types, true)) {
2444 - return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
3735 + if (in_array($object, $custom_post_types, true)) {
3736 + return ['entries' => $this->collect_post_entries_iter([$object], $settings), 'image_ns' => true];
2445 3737 }
3738 +
3739 + // Custom taxonomies reach index mode here, streamed through the
3740 + // same iterator flat mode uses so the two modes emit identical
3741 + // URLs for the same settings.
3742 + if (taxonomy_exists($object) && $this->should_include_taxonomy($object)) {
3743 + return ['entries' => $this->collect_taxonomy_entries_iter($object, $settings), 'image_ns' => false];
3744 + }
3745 +
3746 + // An aliased child whose object is gone (WooCommerce deactivated)
3747 + // keeps writing the empty file it always wrote. Returning null
3748 + // here would hand it the whole-site fallback instead, dumping
3749 + // every URL on the site into a file named for products.
3750 + //
3751 + // A registered taxonomy this generator will not emit
3752 + // (`post_format`, `nav_menu`, a non-public one) needs the same
3753 + // answer for the same reason. generate_multiple_sitemaps()
3754 + // skips those before they reach here, so nothing takes this
3755 + // path today — but it is the one branch where falling through
3756 + // to null is silently catastrophic rather than merely wrong,
3757 + // and the guard keeping it unreachable lives in another method.
3758 + if ($alias !== null || taxonomy_exists($object)) {
3759 + return ['entries' => [], 'image_ns' => false];
3760 + }
3761 +
2446 3762 return null;
2447 3763 }
2448 3764 }
2449 3765
@@ -2496,8 +3812,13 @@
2496 3812 * @param array $settings Sitemap settings, for the ownership test.
2497 3813 * @return void
2498 3814 */
2499 3815 private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
3816 + // See prune_orphaned_segments(): a dynamic render deletes nothing.
3817 + if ($this->is_collecting()) {
3818 + return;
3819 + }
3820 +
2500 3821 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2501 3822 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2502 3823 return;
2503 3824 }
@@ -2523,8 +3844,97 @@
2523 3844 }
2524 3845 }
2525 3846
2526 3847 /**
3848 + * Sitemap URLs contributed by other plugins.
3849 + *
3850 + * ThinkRank owns the sitemap index and the robots.txt `Sitemap:` lines, so a
3851 + * companion plugin that serves its own sitemap — Pro's news and video
3852 + * sitemaps, for instance — had no way to be discovered: it appeared in
3853 + * neither, leaving manual Search Console submission as the only route in
3854 + * (#104). Registering here puts a sitemap in the index when one exists, and
3855 + * in robots.txt when it does not.
3856 + *
3857 + * Callers get root-relative paths. Entries are normalised to a leading
3858 + * slash, de-duplicated, and anything that is not a non-empty string is
3859 + * dropped, so one badly-behaved callback cannot produce a malformed index.
3860 + *
3861 + * @since 2.3.1
3862 + *
3863 + * @return string[] Root-relative sitemap paths, e.g. ['/news-sitemap.xml'].
3864 + */
3865 + public static function additional_sitemaps(): array {
3866 + /**
3867 + * Filters the sitemaps contributed by other plugins.
3868 + *
3869 + * @since 2.3.1
3870 + *
3871 + * @param string[] $sitemaps Root-relative sitemap paths.
3872 + */
3873 + $sitemaps = apply_filters('thinkrank_additional_sitemaps', []);
3874 +
3875 + if (!is_array($sitemaps)) {
3876 + return [];
3877 + }
3878 +
3879 + // Both consumers resolve an entry with home_url(), which prefixes the
3880 + // install's own directory. Everything below is measured against that so
3881 + // an absolute URL is reduced to what home_url() will put back.
3882 + $home = wp_parse_url(home_url('/'));
3883 + $home_host = strtolower((string) ($home['host'] ?? ''));
3884 + $home_path = '/' . trim((string) ($home['path'] ?? ''), '/');
3885 +
3886 + $clean = [];
3887 + foreach ($sitemaps as $sitemap) {
3888 + if (!is_string($sitemap)) {
3889 + continue;
3890 + }
3891 +
3892 + $sitemap = trim($sitemap);
3893 + if ('' === $sitemap) {
3894 + continue;
3895 + }
3896 +
3897 + // A full URL on this site is accepted and reduced to the part
3898 + // home_url() does not already supply, so a caller that reached for
3899 + // home_url() still lands in the right place — including on a
3900 + // subdirectory install, where keeping the whole path would repeat
3901 + // the directory. A URL on another host is dropped rather than
3902 + // rewritten: the sitemaps protocol will not accept a cross-host
3903 + // child anyway, and reusing its path would advertise a URL on this
3904 + // site that does not exist.
3905 + if (preg_match('#^(https?:)?//#i', $sitemap)) {
3906 + $parts = wp_parse_url('//' === substr($sitemap, 0, 2) ? 'https:' . $sitemap : $sitemap);
3907 + if (!is_array($parts)) {
3908 + continue;
3909 + }
3910 +
3911 + if (strtolower((string) ($parts['host'] ?? '')) !== $home_host) {
3912 + continue;
3913 + }
3914 +
3915 + $path = (string) ($parts['path'] ?? '');
3916 + if ('' === $path) {
3917 + continue;
3918 + }
3919 +
3920 + if ('/' !== $home_path && ($path === $home_path || 0 === strpos($path, $home_path . '/'))) {
3921 + $path = substr($path, strlen($home_path));
3922 + }
3923 +
3924 + // A sitemap served from a query string keeps it; dropping the
3925 + // query would point at a different document.
3926 + $query = (string) ($parts['query'] ?? '');
3927 + $sitemap = $path . ('' !== $query ? '?' . $query : '');
3928 + }
3929 +
3930 + $clean[] = '/' . ltrim($sitemap, '/');
3931 + }
3932 +
3933 + return array_values(array_unique($clean));
3934 + }
3935 +
3936 + /**
2527 3937 * Generate sitemap index XML from the list of child sitemap files produced
2528 3938 * during generation (each already resolved to its final, possibly paginated,
2529 3939 * URL).
2530 3940 *
@@ -2533,9 +3943,9 @@
2533 3943 * @param array $settings Sitemap settings
2534 3944 * @return string Sitemap index XML
2535 3945 */
2536 3946 private function generate_sitemap_index(array $children, array $settings): string {
2537 - $xml = $this->xml_prolog($settings, 'sitemap-index.xsl');
3947 + $xml = $this->xml_prolog($settings, 'index');
2538 3948 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
2539 3949
2540 3950 $site_url = home_url();
2541 3951
@@ -2548,9 +3958,9 @@
2548 3958 $sitemap_url = $site_url . $sitemap_url;
2549 3959 }
2550 3960
2551 3961 $xml .= " <sitemap>\n";
2552 - $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
3962 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($sitemap_url)) . "</loc>\n";
2553 3963 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
2554 3964 $xml .= " </sitemap>\n";
2555 3965 }
2556 3966