PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.1 1.0.2 All 50 releases
← All changes | includes/seo/class-sitemap-generator.php +766 -21 2.5.0 → 2.9.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
@@ -127,8 +144,98 @@
127 144 */
128 145 public const REGENERATION_ERROR_OPTION = 'thinkrank_sitemap_regeneration_error';
129 146
130 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 + /**
131 238 * Transient guarding against two generations running at once. Shared with
132 239 * Sitemap_Endpoint's manual generate route so an automatic rebuild and a
133 240 * manual one cannot write the same files concurrently.
134 241 *
@@ -270,9 +377,9 @@
270 377 */
271 378 public function generate_sitemap(array $options = []): string {
272 379 $settings = $this->get_settings('site');
273 380
274 - $xml = $this->xml_prolog($settings, 'sitemap.xsl');
381 + $xml = $this->xml_prolog($settings, 'sitemap');
275 382
276 383 // Add image namespace if images are enabled
277 384 if (!empty($settings['include_images'])) {
278 385 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
@@ -435,8 +542,15 @@
435 542 ]
436 543 ],
437 544 'use_sitemap_index' => false,
438 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 +
439 553 // General Settings
440 554 'links_per_sitemap' => 1000,
441 555 'include_images' => true,
442 556 'include_featured_images' => false,
@@ -458,8 +572,17 @@
458 572 // Advanced Options
459 573 'enable_styling' => true,
460 574 'custom_url_pattern' => 'sitemap-{type}.xml',
461 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 +
462 585 // Generation tracking
463 586 'last_generated' => ''
464 587 ];
465 588 }
@@ -464,8 +587,49 @@
464 587 ];
465 588 }
466 589
467 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 + /**
468 632 * Get settings schema definition (implements interface)
469 633 *
470 634 * @since 1.0.0
471 635 *
@@ -479,8 +643,15 @@
479 643 'title' => 'Enable Sitemap',
480 644 'description' => 'Generate XML sitemap for search engines',
481 645 'default' => true
482 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 + ],
483 654 'include_posts' => [
484 655 'type' => 'boolean',
485 656 'title' => 'Include Posts',
486 657 'description' => 'Include blog posts in sitemap',
@@ -521,8 +692,32 @@
521 692 'type' => 'string',
522 693 'title' => 'Last Generated',
523 694 'description' => 'Timestamp of last sitemap generation',
524 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' => ''
525 720 ]
526 721 ];
527 722 }
528 723
@@ -539,9 +734,14 @@
539 734 * @return string XML URL entry
540 735 */
541 736 private function generate_url_entry(string $url, string $lastmod, float $priority, string $changefreq, array $images = []): string {
542 737 $xml = " <url>\n";
543 - $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";
544 744 // Omit <lastmod> when unknown (empty) — a fabricated timestamp is worse
545 745 // than no timestamp, and an absent lastmod is valid per the spec.
546 746 if (!empty($lastmod)) {
547 747 $xml .= " <lastmod>" . esc_html($lastmod) . "</lastmod>\n";
@@ -551,9 +751,9 @@
551 751
552 752 // Add image entries if provided
553 753 foreach ($images as $image) {
554 754 $xml .= " <image:image>\n";
555 - $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";
556 756
557 757 if (!empty($image['title'])) {
558 758 $xml .= " <image:title>" . esc_html($image['title']) . "</image:title>\n";
559 759 }
@@ -819,19 +1019,25 @@
819 1019 * kept a shadowing file behind (#510) or had a competitor's deleted.
820 1020 *
821 1021 * @since 2.1.1
822 1022 *
823 - * @param array $settings Sitemap settings (read for `enable_styling`).
824 - * @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`.
825 1031 * @return string Prolog lines, newline-terminated.
826 1032 */
827 - private function xml_prolog(array $settings, string $stylesheet): string {
1033 + private function xml_prolog(array $settings, string $variant): string {
828 1034 $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
829 1035 $xml .= THINKRANK_SITEMAP_MARKER . "\n";
830 1036
831 1037 // The stylesheet is presentation only, so it stays opt-in.
832 1038 if (!empty($settings['enable_styling'])) {
833 - $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";
834 1040 }
835 1041
836 1042 return $xml;
837 1043 }
@@ -846,9 +1052,9 @@
846 1052 * @param bool $with_image_ns Include the image sitemap namespace
847 1053 * @return string Full sitemap XML
848 1054 */
849 1055 private function wrap_urlset(array $entries, array $settings, bool $with_image_ns): string {
850 - $xml = $this->xml_prolog($settings, 'sitemap.xsl');
1056 + $xml = $this->xml_prolog($settings, 'sitemap');
851 1057
852 1058 if ($with_image_ns && !empty($settings['include_images'])) {
853 1059 $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n";
854 1060 } else {
@@ -1635,8 +1841,14 @@
1635 1841 * @param string $source Either 'content' or 'settings'.
1636 1842 * @return void
1637 1843 */
1638 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 +
1639 1851 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1640 1852 $pending = is_array($pending) ? $pending : [];
1641 1853
1642 1854 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
@@ -1973,13 +2185,18 @@
1973 2185 }
1974 2186
1975 2187 $revision = $this->current_regeneration_revision();
1976 2188
2189 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2190 + $this->switch_to_dynamic_delivery($settings, $revision, 'settings');
2191 + return;
2192 + }
2193 +
1977 2194 if ($this->generate_and_save($settings)) {
1978 2195 $this->mark_regeneration_complete($revision);
1979 2196 } else {
1980 2197 $this->record_regeneration_failure(
1981 - __('The sitemap files could not be written to the site root.', 'thinkrank'),
2198 + $this->write_failure_message(),
1982 2199 'settings'
1983 2200 );
1984 2201 }
1985 2202 } catch (\Throwable $e) {
@@ -2089,10 +2306,17 @@
2089 2306 return;
2090 2307 }
2091 2308
2092 2309 $revision = $this->current_regeneration_revision();
2310 + $settings = $this->get_settings('site');
2093 2311
2094 - if ($this->generate_and_save($this->get_settings('site'))) {
2312 + // See regenerate_sitemap_from_settings(): nothing to write.
2313 + if ('dynamic' === $this->resolve_delivery_mode($settings)) {
2314 + $this->switch_to_dynamic_delivery($settings, $revision, 'content');
2315 + return;
2316 + }
2317 +
2318 + if ($this->generate_and_save($settings)) {
2095 2319 $this->mark_regeneration_complete($revision);
2096 2320 } else {
2097 2321 // Previously this returned quietly and last_generated simply
2098 2322 // stopped advancing, leaving the site owner with no way to learn
@@ -2097,9 +2321,9 @@
2097 2321 // Previously this returned quietly and last_generated simply
2098 2322 // stopped advancing, leaving the site owner with no way to learn
2099 2323 // the sitemap had stopped updating (#629).
2100 2324 $this->record_regeneration_failure(
2101 - __('The sitemap files could not be written to the site root.', 'thinkrank'),
2325 + $this->write_failure_message(),
2102 2326 'content'
2103 2327 );
2104 2328 }
2105 2329 } catch (\Throwable $e) {
@@ -2121,8 +2345,26 @@
2121 2345 * @param array $settings Sitemap settings.
2122 2346 * @return bool True when the sitemap files were written.
2123 2347 */
2124 2348 public function generate_and_save(array $settings): bool {
2349 + // Dynamic delivery publishes no files, so writing them here would put a
2350 + // static copy back in the web root for the server to serve in place of
2351 + // the dynamic route. Guarding at each call site left gaps — the
2352 + // snapshot migrator's post-import regeneration had none — so the rule
2353 + // lives with the writing instead.
2354 + //
2355 + // `is_collecting()` is the exception that makes dynamic delivery work
2356 + // at all: render_document() and collect_documents() reach this same
2357 + // method with the writer swapped for a collector, and that is precisely
2358 + // the dynamic build. Only a real write is skipped.
2359 + if (!$this->is_collecting() && 'dynamic' === $this->resolve_delivery_mode($settings)) {
2360 + // Whatever prompted this call changed the sitemap's content, so the
2361 + // rendered copies must not outlive it.
2362 + $this->flush_dynamic_cache();
2363 +
2364 + return true;
2365 + }
2366 +
2125 2367 // Index mode is driven by the use_sitemap_index toggle (not merely by how
2126 2368 // many sitemap_urls happen to be configured). When the toggle is on but
2127 2369 // no child sitemaps are set up yet, synthesize the per-type segmented set
2128 2370 // so we emit a real <sitemapindex> with paginated children instead of a
@@ -2149,9 +2391,13 @@
2149 2391 // names is never touched (#515).
2150 2392 $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
2151 2393 }
2152 2394
2153 - if ($written) {
2395 + // last_generated describes what is on disk. A dynamic render publishes
2396 + // nothing, so advancing it would report a static publication that never
2397 + // happened and would let primary_sitemap_file_exists() callers believe
2398 + // there is a file to serve.
2399 + if ($written && !$this->is_collecting()) {
2154 2400 $settings['last_generated'] = gmdate('c');
2155 2401 $this->save_settings('site', null, $settings);
2156 2402 }
2157 2403
@@ -2158,8 +2404,398 @@
2158 2404 return $written;
2159 2405 }
2160 2406
2161 2407 /**
2408 + * Whether this instance is rendering documents rather than publishing them.
2409 + *
2410 + * @since 2.9.0
2411 + *
2412 + * @return bool
2413 + */
2414 + private function is_collecting(): bool {
2415 + return $this->document_sink !== null;
2416 + }
2417 +
2418 + /**
2419 + * Complete a regeneration that delivers dynamically, retiring stale files.
2420 + *
2421 + * Dynamic delivery renders nothing to disk, but that is only half the job.
2422 + * A web server hands back an existing `/sitemap.xml` without ever loading
2423 + * WordPress, so any file left over from a previous static generation goes on
2424 + * being served forever and {@see \ThinkRank\Frontend\SEO_Manager
2425 + * ::maybe_serve_sitemap()} is never reached. Switching to dynamic while
2426 + * leaving those files in place would therefore appear to do nothing at all.
2427 + *
2428 + * Both transitions matter and they differ:
2429 + *
2430 + * - An explicit switch to `dynamic` happens on a site whose root is usually
2431 + * still writable, so the files can simply be removed.
2432 + * - An `auto` site that becomes read-only cannot remove them, because
2433 + * deleting an entry needs write permission on the directory that holds
2434 + * it. There the stale sitemap really is stuck in front of us, and the
2435 + * honest outcome is a recorded failure naming it rather than a rebuild
2436 + * reported as complete (#754 review).
2437 + *
2438 + * Ownership is tested per file by the shared helper, so another plugin's
2439 + * sitemap at one of our names is never deleted (#515).
2440 + *
2441 + * @since 2.9.0
2442 + *
2443 + * @param array $settings Sitemap settings.
2444 + * @param int $revision Revision this rebuild is completing.
2445 + * @param string $source 'settings' or 'content', for the failure record.
2446 + * @return void
2447 + */
2448 + private function switch_to_dynamic_delivery(array $settings, int $revision, string $source): void {
2449 + $this->flush_dynamic_cache();
2450 +
2451 + $removal = $this->delete_published_sitemaps($settings);
2452 + $stuck = is_array($removal['failed'] ?? null) ? $removal['failed'] : [];
2453 +
2454 + if (!empty($stuck)) {
2455 + $this->record_regeneration_failure(
2456 + sprintf(
2457 + /* translators: 1: comma-separated file names, 2: absolute path to the WordPress root. */
2458 + __('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'),
2459 + implode(', ', $stuck),
2460 + untrailingslashit(ABSPATH)
2461 + ),
2462 + $source
2463 + );
2464 +
2465 + return;
2466 + }
2467 +
2468 + $this->mark_regeneration_complete($revision);
2469 + }
2470 +
2471 + /**
2472 + * What to tell the site owner when publishing the files failed.
2473 + *
2474 + * The old wording stated the symptom and stopped there, so the reported
2475 + * cause was a guess and this reached support as a plugin fault rather than
2476 + * a folder permission (#752, #753). When the root is demonstrably
2477 + * unwritable, say that, and say what to do about it.
2478 + *
2479 + * @since 2.9.0
2480 + *
2481 + * @return string
2482 + */
2483 + private function write_failure_message(): string {
2484 + if (!wp_is_writable(ABSPATH)) {
2485 + return sprintf(
2486 + /* translators: %s: absolute path to the WordPress root. */
2487 + __('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'),
2488 + untrailingslashit(ABSPATH)
2489 + );
2490 + }
2491 +
2492 + return __('The sitemap files could not be written to the site root.', 'thinkrank');
2493 + }
2494 +
2495 + /**
2496 + * How this site delivers its sitemap.
2497 + *
2498 + * `auto` is resolved on whether the web root can be written. That is the
2499 + * right signal here (unlike llms.txt, where the question is whether the
2500 + * server applies the .htaccess charset block): a site whose root is
2501 + * read-only cannot publish a sitemap file at all, and before this existed
2502 + * the feature simply failed with "The sitemap files could not be written to
2503 + * the site root." and served nothing (#752).
2504 + *
2505 + * @since 2.9.0
2506 + *
2507 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2508 + * @return string One of 'static' or 'dynamic'. Never 'auto'.
2509 + */
2510 + public function resolve_delivery_mode(?array $settings = null): string {
2511 + $settings = $settings ?? $this->get_settings('site');
2512 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
2513 +
2514 + if ('static' === $mode || 'dynamic' === $mode) {
2515 + return $mode;
2516 + }
2517 +
2518 + return wp_is_writable(ABSPATH) ? 'static' : 'dynamic';
2519 + }
2520 +
2521 + /**
2522 + * Render one published sitemap document without touching the filesystem.
2523 + *
2524 + * Runs the ordinary build pipeline with the writer swapped for a collector,
2525 + * so the bytes returned here are the bytes the static path would have
2526 + * written. `SitemapDeliveryParityTest` asserts that equivalence rather than
2527 + * trusting it.
2528 + *
2529 + * The whole set is built to answer for one file, because the index can only
2530 + * be assembled from the children that were actually produced. The result is
2531 + * cached per document, so that cost is paid once per change and not once
2532 + * per crawler request.
2533 + *
2534 + * @since 2.9.0
2535 + *
2536 + * @param string $filename Published file name, e.g. 'sitemap.xml'.
2537 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2538 + * @return string|null XML, or null when this site does not publish that name.
2539 + */
2540 + public function render_document(string $filename, ?array $settings = null): ?string {
2541 + $filename = basename($filename);
2542 + $settings = $settings ?? $this->get_settings('site');
2543 +
2544 + if (empty($settings['enabled'])) {
2545 + return null;
2546 + }
2547 +
2548 + $cached = get_transient($this->dynamic_cache_key($filename));
2549 + if (self::ABSENT_MARKER === $cached) {
2550 + return null;
2551 + }
2552 + if (is_string($cached) && '' !== $cached) {
2553 + return $cached;
2554 + }
2555 +
2556 + // A miss builds the whole set, because the index can only be assembled
2557 + // from the children that were actually produced. Caching only the
2558 + // requested document therefore made a crawler walking the index and its
2559 + // children rebuild the entire site's sitemap once per file — every post
2560 + // and taxonomy query repeated N times on a public endpoint (#754
2561 + // review). The set is built once and stored in full.
2562 + return $this->stream_documents($settings, $filename);
2563 + }
2564 +
2565 + /**
2566 + * Build every document, caching each as it is produced, keeping one.
2567 + *
2568 + * A miss has to build the whole set, because the index can only be
2569 + * assembled from the children that were actually produced. It does not have
2570 + * to *hold* the whole set: the static path never keeps more than one page
2571 + * in memory, writing each to disk as it goes, and buffering every
2572 + * document's XML to return one of them undid that on the request path,
2573 + * where a large site's entire sitemap corpus would sit in a single PHP
2574 + * process (#754 review).
2575 + *
2576 + * So the sink writes each document straight to its cache entry and lets it
2577 + * go, retaining only the one this request is answering. Peak retention is
2578 + * one document, whatever the site's size.
2579 + *
2580 + * Concurrency: the first request through takes a short lock and does the
2581 + * work. One that finds the lock held waits a bounded moment for the winner
2582 + * to publish, then builds anyway, because serving a correct sitemap late
2583 + * beats serving none.
2584 + *
2585 + * @since 2.9.0
2586 + *
2587 + * @param array $settings Sitemap settings.
2588 + * @param string $wanted Document this request is answering.
2589 + * @return string|null XML for $wanted, or null when the site does not publish it.
2590 + */
2591 + private function stream_documents(array $settings, string $wanted): ?string {
2592 + $lock = self::DYNAMIC_CACHE_PREFIX . 'lock';
2593 +
2594 + if (!$this->acquire_render_lock($lock)) {
2595 + for ($attempt = 0; $attempt < self::RENDER_LOCK_WAIT_ATTEMPTS; $attempt++) {
2596 + usleep(self::RENDER_LOCK_WAIT_MICROSECONDS);
2597 +
2598 + $cached = get_transient($this->dynamic_cache_key($wanted));
2599 + if (self::ABSENT_MARKER === $cached) {
2600 + return null;
2601 + }
2602 + if (is_string($cached) && '' !== $cached) {
2603 + return $cached;
2604 + }
2605 + }
2606 + }
2607 +
2608 + $kept = null;
2609 + // Names only. Keeping the bodies here would be the very retention this
2610 + // method exists to avoid.
2611 + $produced = [];
2612 +
2613 + $previous = $this->document_sink;
2614 + $this->document_sink = function (string $name, string $xml) use (&$kept, &$produced, $wanted): void {
2615 + $produced[$name] = true;
2616 + set_transient($this->dynamic_cache_key($name), $xml, self::DYNAMIC_CACHE_TTL);
2617 +
2618 + if ($name === $wanted) {
2619 + $kept = $xml;
2620 + }
2621 + };
2622 +
2623 + try {
2624 + $this->generate_and_save($settings);
2625 +
2626 + // Names the configuration lists but this build did not produce get
2627 + // a negative entry, so asking for one again is a cache hit rather
2628 + // than another full rebuild.
2629 + $absent = $this->published_document_names($settings);
2630 +
2631 + // Also the exact name this request asked for: a paginated page past
2632 + // the end of a stem is a legitimate request shape that the base
2633 + // list cannot enumerate, and without an entry it would rebuild on
2634 + // every hit.
2635 + $absent[] = $wanted;
2636 +
2637 + foreach (array_unique($absent) as $name) {
2638 + if (!isset($produced[$name])) {
2639 + set_transient($this->dynamic_cache_key($name), self::ABSENT_MARKER, self::DYNAMIC_CACHE_TTL);
2640 + }
2641 + }
2642 + } finally {
2643 + $this->document_sink = $previous;
2644 + delete_transient($lock);
2645 + }
2646 +
2647 + return $kept;
2648 + }
2649 +
2650 + /**
2651 + * Take the render lock, if it is free.
2652 + *
2653 + * Not atomic across processes, and deliberately so: the fallback for losing
2654 + * a race is duplicated work, never a wrong or missing sitemap, so a
2655 + * heavier primitive would buy nothing here.
2656 + *
2657 + * @since 2.9.0
2658 + *
2659 + * @param string $lock Lock transient name.
2660 + * @return bool True when this request holds the lock.
2661 + */
2662 + private function acquire_render_lock(string $lock): bool {
2663 + if (false !== get_transient($lock)) {
2664 + return false;
2665 + }
2666 +
2667 + set_transient($lock, time(), self::RENDER_LOCK_TTL);
2668 +
2669 + return true;
2670 + }
2671 +
2672 + /**
2673 + * Build every document this site publishes and return them all.
2674 + *
2675 + * Verification and tooling only. This retains the whole set in memory, so
2676 + * it must never be used to answer a request: {@see self::stream_documents()}
2677 + * is the serving path and keeps one document at a time regardless of site
2678 + * size (#754 review). `SitemapDeliveryParityTest` enforces that separation
2679 + * by failing if the request path routes back through here.
2680 + *
2681 + * @since 2.9.0
2682 + *
2683 + * @param array $settings Sitemap settings.
2684 + * @return array<string,string> Filename => XML.
2685 + */
2686 + public function collect_documents(array $settings): array {
2687 + $documents = [];
2688 +
2689 + $previous = $this->document_sink;
2690 + $this->document_sink = static function (string $name, string $xml) use (&$documents): void {
2691 + $documents[$name] = $xml;
2692 + };
2693 +
2694 + try {
2695 + $this->generate_and_save($settings);
2696 + } finally {
2697 + $this->document_sink = $previous;
2698 + }
2699 +
2700 + return $documents;
2701 + }
2702 +
2703 + /**
2704 + * The file names this site publishes, without building their contents.
2705 + *
2706 + * Used by the request router to decide whether a URL is ours before doing
2707 + * any work. Cheap: it reads the configured child list rather than querying
2708 + * for entries.
2709 + *
2710 + * @since 2.9.0
2711 + *
2712 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2713 + * @return string[] File names, including paginated pages that may exist.
2714 + */
2715 + public function published_document_names(?array $settings = null): array {
2716 + $settings = $settings ?? $this->get_settings('site');
2717 + $resolved = $this->maybe_promote_to_index($settings);
2718 +
2719 + $names = [$this->get_primary_sitemap_filename($settings), 'local-sitemap.xml'];
2720 +
2721 + foreach ((array) ($resolved['sitemap_urls'] ?? []) as $child) {
2722 + if (!is_array($child) || empty($child['enabled'])) {
2723 + continue;
2724 + }
2725 +
2726 + $path = (string) wp_parse_url((string) ($child['url'] ?? ''), PHP_URL_PATH);
2727 + if ('' !== $path) {
2728 + $names[] = basename($path);
2729 + }
2730 + }
2731 +
2732 + return array_values(array_unique(array_filter($names)));
2733 + }
2734 +
2735 + /**
2736 + * Does this site publish a document under that name?
2737 + *
2738 + * Not a plain membership test against {@see self::published_document_names()}:
2739 + * that lists the configured children, and a child over the per-file URL cap
2740 + * is split into `<stem>-2.xml`, `<stem>-3.xml` and so on, with every page
2741 + * listed in the index. Gating the request router on the base list alone
2742 + * therefore 404'd exactly the pages the index points at, which is worse than
2743 + * not serving them at all.
2744 + *
2745 + * Page counts are not knowable without building, so the stem is what is
2746 + * matched; a page that does not exist is answered by the build finding
2747 + * nothing for it, and is then cached as absent.
2748 + *
2749 + * @since 2.9.0
2750 + *
2751 + * @param string $name Requested file name.
2752 + * @param array|null $settings Sitemap settings (falls back to saved ones).
2753 + * @return bool
2754 + */
2755 + public function publishes_document_name(string $name, ?array $settings = null): bool {
2756 + $names = $this->published_document_names($settings);
2757 +
2758 + if (in_array($name, $names, true)) {
2759 + return true;
2760 + }
2761 +
2762 + if (!preg_match('/^(.*)-\d+\.xml$/i', $name, $m)) {
2763 + return false;
2764 + }
2765 +
2766 + return in_array($m[1] . '.xml', $names, true);
2767 + }
2768 +
2769 + /**
2770 + * Transient key for a rendered document.
2771 + *
2772 + * @since 2.9.0
2773 + *
2774 + * @param string $filename Published file name.
2775 + * @return string
2776 + */
2777 + private function dynamic_cache_key(string $filename): string {
2778 + return self::DYNAMIC_CACHE_PREFIX . md5($filename);
2779 + }
2780 +
2781 + /**
2782 + * Drop every cached dynamic document.
2783 + *
2784 + * Called from the same places that mark the static files stale, so the two
2785 + * delivery modes invalidate on identical triggers.
2786 + *
2787 + * @since 2.9.0
2788 + *
2789 + * @return void
2790 + */
2791 + public function flush_dynamic_cache(): void {
2792 + foreach ($this->published_document_names() as $name) {
2793 + delete_transient($this->dynamic_cache_key($name));
2794 + }
2795 + }
2796 +
2797 + /**
2162 2798 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
2163 2799 *
2164 2800 * - When use_sitemap_index is on but no child sitemaps are configured, build
2165 2801 * the per-type segmented set so a real <sitemapindex> is produced (#127).
@@ -2354,8 +2990,18 @@
2354 2990 // File validation failed - error details available in exception
2355 2991 return false;
2356 2992 }
2357 2993
2994 + // Dynamic delivery: hand the document to the collector instead of the
2995 + // filesystem. Reported as published, because for this run it is — the
2996 + // caller's success/failure bookkeeping and the index assembly both key
2997 + // off this return value.
2998 + if ($this->document_sink !== null) {
2999 + ($this->document_sink)($filename, $sitemap_xml);
3000 +
3001 + return true;
3002 + }
3003 +
2358 3004 $sitemap_path = ABSPATH . $filename;
2359 3005
2360 3006 // Use WordPress filesystem API for better security
2361 3007 global $wp_filesystem;
@@ -2430,8 +3076,38 @@
2430 3076
2431 3077 $urls[] = $this->build_child_sitemap_entry($cpt, $pattern);
2432 3078 }
2433 3079
3080 + // Public custom taxonomies get the same treatment (#690). Flat mode has
3081 + // always walked them through get_enabled_taxonomies(); index mode built
3082 + // its children from the list above and never consulted a taxonomy at
3083 + // all, so every custom-taxonomy archive silently vanished from the
3084 + // sitemap the moment a site switched modes — and the per-taxonomy switch
3085 + // the matrix writes had nothing to act on. Same two tests the post-type
3086 + // walk applies, in the same order.
3087 + $taken = array_column($urls, 'type');
3088 +
3089 + foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
3090 + if (!$this->should_include_taxonomy($taxonomy)) {
3091 + continue;
3092 + }
3093 +
3094 + if (!\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $taxonomy, $inclusions)) {
3095 + continue;
3096 + }
3097 +
3098 + // Post types and taxonomies are separate registries, so a site can
3099 + // hold both a `foo` post type and a `foo` taxonomy. They would
3100 + // resolve to one filename, and stream_type_entries() answers post
3101 + // types first, so the second child would list the first one's file
3102 + // twice in the index rather than adding anything.
3103 + if (in_array($taxonomy, $taken, true)) {
3104 + continue;
3105 + }
3106 +
3107 + $urls[] = $this->build_child_sitemap_entry($taxonomy, $pattern);
3108 + }
3109 +
2434 3110 return $urls;
2435 3111 }
2436 3112
2437 3113 /**
@@ -2588,8 +3264,22 @@
2588 3264 && !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('post_type', $type, $settings)) {
2589 3265 continue;
2590 3266 }
2591 3267
3268 + // The taxonomy counterpart of the two guards above (#690). A child
3269 + // list saved while a taxonomy was still included keeps naming it, so
3270 + // without this, excluding one in the matrix would still rewrite and
3271 + // re-list the file the user asked not to have. The built-in
3272 + // aggregates are named 'categories'/'tags' rather than
3273 + // 'category'/'post_tag', so taxonomy_exists() leaves them to the
3274 + // inclusion-flag check below.
3275 + $child_taxonomy = self::CHILD_TYPE_ALIASES[$type] ?? $type;
3276 + if (taxonomy_exists($child_taxonomy)
3277 + && (!$this->should_include_taxonomy($child_taxonomy)
3278 + || !\ThinkRank\SEO\Content_Type_Settings::is_included_in_sitemap('taxonomy', $child_taxonomy, $settings))) {
3279 + continue;
3280 + }
3281 +
2592 3282 // The built-in aggregates carry their switch in the inclusion flag
2593 3283 // rather than under a post type name, and they are the four most
2594 3284 // people actually use. build_segmented_sitemap_urls() drops a child
2595 3285 // whose flag is empty; regenerating from a list saved while it was
@@ -2725,8 +3415,15 @@
2725 3415 * @param array $generated Entries from $results['sitemaps_generated'].
2726 3416 * @return string[] Basenames removed.
2727 3417 */
2728 3418 private function prune_orphaned_segments(array $settings, array $generated): array {
3419 + // Rendering for a request, not publishing: there is nothing on disk
3420 + // this run owns, and a dynamic render must never delete the files a
3421 + // site's previous static mode left behind.
3422 + if ($this->is_collecting()) {
3423 + return [];
3424 + }
3425 +
2729 3426 $kept = [];
2730 3427 foreach ($generated as $entry) {
2731 3428 if (!empty($entry['filename'])) {
2732 3429 $kept[strtolower((string) $entry['filename'])] = true;
@@ -2828,9 +3525,9 @@
2828 3525 // publishes under too (this method mirrors it deliberately), so on
2829 3526 // a migrated site the file at that path may never have been ours
2830 3527 // to delete (#515).
2831 3528 $path = ABSPATH . 'local-sitemap.xml';
2832 - if (file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
3529 + if (!$this->is_collecting() && file_exists($path) && $this->webroot_sitemap_is_ours($path, $settings)) {
2833 3530 wp_delete_file($path);
2834 3531 }
2835 3532 return false;
2836 3533 }
@@ -2852,8 +3549,25 @@
2852 3549 *
2853 3550 * @since 1.15.x
2854 3551 * @return array Zero or one URL entry
2855 3552 */
3553 + /**
3554 + * Does this site publish a local business sitemap right now?
3555 + *
3556 + * The same gate {@see self::regenerate_local_sitemap()} applies, asked
3557 + * without writing anything. Callers that need to know whether the document
3558 + * exists must not test the filesystem: under dynamic delivery it is served
3559 + * from PHP and there is no file, which is how `local-sitemap.xml` came to be
3560 + * dropped from robots.txt on exactly those sites (#752).
3561 + *
3562 + * @since 2.9.0
3563 + *
3564 + * @return bool True when the local sitemap has content to publish.
3565 + */
3566 + public function publishes_local_sitemap(): bool {
3567 + return !empty($this->collect_local_entries());
3568 + }
3569 +
2856 3570 private function collect_local_entries(): array {
2857 3571 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2858 3572 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2859 3573 }
@@ -2925,17 +3639,43 @@
2925 3639 case 'products':
2926 3640 $entries = post_type_exists('product') ? $this->collect_post_entries_iter(['product'], $settings) : [];
2927 3641 return ['entries' => $entries, 'image_ns' => true];
2928 3642
2929 - case 'product_categories':
2930 - $entries = taxonomy_exists('product_cat') ? $this->collect_taxonomy_entries_iter('product_cat', $settings) : [];
2931 - return ['entries' => $entries, 'image_ns' => false];
3643 + default:
3644 + // Resolve a preset's display name to the object it streams, so
3645 + // 'product_categories' is an ordinary taxonomy child rather than
3646 + // a case of its own (#690).
3647 + $alias = self::CHILD_TYPE_ALIASES[$type] ?? null;
3648 + $object = $alias ?? $type;
2932 3649
2933 - default:
2934 3650 $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
2935 - if (in_array($type, $custom_post_types, true)) {
2936 - return ['entries' => $this->collect_post_entries_iter([$type], $settings), 'image_ns' => true];
3651 + if (in_array($object, $custom_post_types, true)) {
3652 + return ['entries' => $this->collect_post_entries_iter([$object], $settings), 'image_ns' => true];
2937 3653 }
3654 +
3655 + // Custom taxonomies reach index mode here, streamed through the
3656 + // same iterator flat mode uses so the two modes emit identical
3657 + // URLs for the same settings.
3658 + if (taxonomy_exists($object) && $this->should_include_taxonomy($object)) {
3659 + return ['entries' => $this->collect_taxonomy_entries_iter($object, $settings), 'image_ns' => false];
3660 + }
3661 +
3662 + // An aliased child whose object is gone (WooCommerce deactivated)
3663 + // keeps writing the empty file it always wrote. Returning null
3664 + // here would hand it the whole-site fallback instead, dumping
3665 + // every URL on the site into a file named for products.
3666 + //
3667 + // A registered taxonomy this generator will not emit
3668 + // (`post_format`, `nav_menu`, a non-public one) needs the same
3669 + // answer for the same reason. generate_multiple_sitemaps()
3670 + // skips those before they reach here, so nothing takes this
3671 + // path today — but it is the one branch where falling through
3672 + // to null is silently catastrophic rather than merely wrong,
3673 + // and the guard keeping it unreachable lives in another method.
3674 + if ($alias !== null || taxonomy_exists($object)) {
3675 + return ['entries' => [], 'image_ns' => false];
3676 + }
3677 +
2938 3678 return null;
2939 3679 }
2940 3680 }
2941 3681
@@ -2988,8 +3728,13 @@
2988 3728 * @param array $settings Sitemap settings, for the ownership test.
2989 3729 * @return void
2990 3730 */
2991 3731 private function cleanup_stale_pages(string $base_url, int $current_pages, array $settings): void {
3732 + // See prune_orphaned_segments(): a dynamic render deletes nothing.
3733 + if ($this->is_collecting()) {
3734 + return;
3735 + }
3736 +
2992 3737 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
2993 3738 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
2994 3739 return;
2995 3740 }
@@ -3114,9 +3859,9 @@
3114 3859 * @param array $settings Sitemap settings
3115 3860 * @return string Sitemap index XML
3116 3861 */
3117 3862 private function generate_sitemap_index(array $children, array $settings): string {
3118 - $xml = $this->xml_prolog($settings, 'sitemap-index.xsl');
3863 + $xml = $this->xml_prolog($settings, 'index');
3119 3864 $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";
3120 3865
3121 3866 $site_url = home_url();
3122 3867
@@ -3129,9 +3874,9 @@
3129 3874 $sitemap_url = $site_url . $sitemap_url;
3130 3875 }
3131 3876
3132 3877 $xml .= " <sitemap>\n";
3133 - $xml .= " <loc>" . esc_url($sitemap_url) . "</loc>\n";
3878 + $xml .= " <loc>" . esc_url(Url_Scheme::apply($sitemap_url)) . "</loc>\n";
3134 3879 $xml .= " <lastmod>" . gmdate('c') . "</lastmod>\n";
3135 3880 $xml .= " </sitemap>\n";
3136 3881 }
3137 3882