| @@ -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 | |