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 +679 -14 2.7.0 → 2.10.0 View file →
@@ -144,8 +144,98 @@
144 144 */
145 145 public const REGENERATION_ERROR_OPTION = 'thinkrank_sitemap_regeneration_error';
146 146
147 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 + /**
148 238 * Transient guarding against two generations running at once. Shared with
149 239 * Sitemap_Endpoint's manual generate route so an automatic rebuild and a
150 240 * manual one cannot write the same files concurrently.
151 241 *
@@ -452,8 +542,15 @@
452 542 ]
453 543 ],
454 544 'use_sitemap_index' => false,
455 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 +
456 553 // General Settings
457 554 'links_per_sitemap' => 1000,
458 555 'include_images' => true,
459 556 'include_featured_images' => false,
@@ -519,8 +616,16 @@
519 616 if (array_key_exists('styling_logo_url', $sanitized)) {
520 617 $sanitized['styling_logo_url'] = esc_url_raw((string) $sanitized['styling_logo_url']);
521 618 }
522 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 +
523 628 return $sanitized;
524 629 }
525 630
526 631 /**
@@ -538,8 +643,15 @@
538 643 'title' => 'Enable Sitemap',
539 644 'description' => 'Generate XML sitemap for search engines',
540 645 'default' => true
541 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 + ],
542 654 'include_posts' => [
543 655 'type' => 'boolean',
544 656 'title' => 'Include Posts',
545 657 'description' => 'Include blog posts in sitemap',
@@ -1729,8 +1841,14 @@
1729 1841 * @param string $source Either 'content' or 'settings'.
1730 1842 * @return void
1731 1843 */
1732 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 +
1733 1851 $pending = get_option(self::REGENERATION_PENDING_OPTION, []);
1734 1852 $pending = is_array($pending) ? $pending : [];
1735 1853
1736 1854 $since = !empty($pending['since']) ? (int) $pending['since'] : time();
@@ -2044,16 +2162,20 @@
2044 2162 * auto_generate setting: the user deliberately changed inclusion rules and
2045 2163 * expects the served file to reflect them even if content-triggered
2046 2164 * auto-generation is turned off. Still respects the master `enabled` flag.
2047 2165 *
2048 - * @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.
2049 2171 */
2050 - public function regenerate_sitemap_from_settings(): void {
2172 + public function regenerate_sitemap_from_settings(): bool {
2051 2173 if (!$this->acquire_generation_lock()) {
2052 2174 // A manual generation (or another request's takeover) is already
2053 2175 // writing the files; the pending marker survives so this rebuild is
2054 2176 // retried rather than lost.
2055 - return;
2177 + return false;
2056 2178 }
2057 2179
2058 2180 try {
2059 2181 $settings = $this->get_settings('site');
@@ -2060,25 +2182,56 @@
2060 2182 if (empty($settings['enabled'])) {
2061 2183 // The sitemap was disabled: remove the previously generated static
2062 2184 // files so the web server stops serving a stale sitemap that
2063 2185 // crawlers would otherwise keep fetching.
2064 - $this->delete_published_sitemaps();
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 +
2065 2204 $this->mark_regeneration_complete();
2066 - return;
2205 +
2206 + return true;
2067 2207 }
2068 2208
2069 2209 $revision = $this->current_regeneration_revision();
2070 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 +
2071 2218 if ($this->generate_and_save($settings)) {
2072 2219 $this->mark_regeneration_complete($revision);
2073 - } else {
2074 - $this->record_regeneration_failure(
2075 - __('The sitemap files could not be written to the site root.', 'thinkrank'),
2076 - 'settings'
2077 - );
2220 +
2221 + return true;
2078 2222 }
2223 +
2224 + $this->record_regeneration_failure(
2225 + $this->write_failure_message(),
2226 + 'settings'
2227 + );
2228 +
2229 + return false;
2079 2230 } catch (\Throwable $e) {
2080 2231 $this->record_regeneration_failure($e->getMessage(), 'settings');
2232 +
2233 + return false;
2081 2234 } finally {
2082 2235 $this->release_generation_lock();
2083 2236 }
2084 2237 }
@@ -2083,8 +2236,27 @@
2083 2236 }
2084 2237 }
2085 2238
2086 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 + /**
2087 2259 * Remove every static sitemap file ThinkRank publishes to the web root.
2088 2260 *
2089 2261 * Called when the sitemap feature is disabled, by the cleanup route, and by
2090 2262 * both removal paths, so /sitemap.xml, /sitemap_index.xml, the segmented
@@ -2183,10 +2355,17 @@
2183 2355 return;
2184 2356 }
2185 2357
2186 2358 $revision = $this->current_regeneration_revision();
2359 + $settings = $this->get_settings('site');
2187 2360
2188 - if ($this->generate_and_save($this->get_settings('site'))) {
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 + }
2366 +
2367 + if ($this->generate_and_save($settings)) {
2189 2368 $this->mark_regeneration_complete($revision);
2190 2369 } else {
2191 2370 // Previously this returned quietly and last_generated simply
2192 2371 // stopped advancing, leaving the site owner with no way to learn
@@ -2191,9 +2370,9 @@
2191 2370 // Previously this returned quietly and last_generated simply
2192 2371 // stopped advancing, leaving the site owner with no way to learn
2193 2372 // the sitemap had stopped updating (#629).
2194 2373 $this->record_regeneration_failure(
2195 - __('The sitemap files could not be written to the site root.', 'thinkrank'),
2374 + $this->write_failure_message(),
2196 2375 'content'
2197 2376 );
2198 2377 }
2199 2378 } catch (\Throwable $e) {
@@ -2215,8 +2394,26 @@
2215 2394 * @param array $settings Sitemap settings.
2216 2395 * @return bool True when the sitemap files were written.
2217 2396 */
2218 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 +
2219 2416 // Index mode is driven by the use_sitemap_index toggle (not merely by how
2220 2417 // many sitemap_urls happen to be configured). When the toggle is on but
2221 2418 // no child sitemaps are set up yet, synthesize the per-type segmented set
2222 2419 // so we emit a real <sitemapindex> with paginated children instead of a
@@ -2243,9 +2440,13 @@
2243 2440 // names is never touched (#515).
2244 2441 $this->prune_orphaned_segments($settings, [['filename' => basename($primary)]]);
2245 2442 }
2246 2443
2247 - 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()) {
2248 2449 $settings['last_generated'] = gmdate('c');
2249 2450 $this->save_settings('site', null, $settings);
2250 2451 }
2251 2452
@@ -2252,8 +2453,433 @@
2252 2453 return $written;
2253 2454 }
2254 2455
2255 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 + /**
2256 2882 * Resolve index-vs-single mode, synthesizing child sitemaps when needed.
2257 2883 *
2258 2884 * - When use_sitemap_index is on but no child sitemaps are configured, build
2259 2885 * the per-type segmented set so a real <sitemapindex> is produced (#127).
@@ -2448,8 +3074,18 @@
2448 3074 // File validation failed - error details available in exception
2449 3075 return false;
2450 3076 }
2451 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 +
2452 3088 $sitemap_path = ABSPATH . $filename;
2453 3089
2454 3090 // Use WordPress filesystem API for better security
2455 3091 global $wp_filesystem;
@@ -2863,8 +3499,15 @@
2863 3499 * @param array $generated Entries from $results['sitemaps_generated'].
2864 3500 * @return string[] Basenames removed.
2865 3501 */
2866 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 +
2867 3510 $kept = [];
2868 3511 foreach ($generated as $entry) {
2869 3512 if (!empty($entry['filename'])) {
2870 3513 $kept[strtolower((string) $entry['filename'])] = true;
@@ -2966,9 +3609,9 @@
2966 3609 // publishes under too (this method mirrors it deliberately), so on
2967 3610 // a migrated site the file at that path may never have been ours
2968 3611 // to delete (#515).
2969 3612 $path = ABSPATH . 'local-sitemap.xml';
2970 - 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)) {
2971 3614 wp_delete_file($path);
2972 3615 }
2973 3616 return false;
2974 3617 }
@@ -2990,8 +3633,25 @@
2990 3633 *
2991 3634 * @since 1.15.x
2992 3635 * @return array Zero or one URL entry
2993 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 +
2994 3654 private function collect_local_entries(): array {
2995 3655 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
2996 3656 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-site-identity-manager.php';
2997 3657 }
@@ -3152,8 +3812,13 @@
3152 3812 * @param array $settings Sitemap settings, for the ownership test.
3153 3813 * @return void
3154 3814 */
3155 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 +
3156 3821 $filename = basename(wp_parse_url($base_url, PHP_URL_PATH));
3157 3822 if (!preg_match('/^(.*)\.xml$/i', $filename, $m)) {
3158 3823 return;
3159 3824 }