From b92b90cd1e81af3948244f22bdbc192218245c86 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 14:29:28 +0200 Subject: [PATCH 01/25] feat(warmup): one canonical URL shape to join signals on --- src/BreezeWarmup/UrlCanonicalizer.php | 73 +++++++++++++++++ .../BreezeWarmup/UrlCanonicalizerTest.php | 79 +++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/BreezeWarmup/UrlCanonicalizer.php create mode 100644 tests/Unit/BreezeWarmup/UrlCanonicalizerTest.php diff --git a/src/BreezeWarmup/UrlCanonicalizer.php b/src/BreezeWarmup/UrlCanonicalizer.php new file mode 100644 index 0000000..60a17ea --- /dev/null +++ b/src/BreezeWarmup/UrlCanonicalizer.php @@ -0,0 +1,73 @@ + Default port per scheme. */ + private const DEFAULT_PORTS = array( + 'http' => 80, + 'https' => 443, + ); + + /** + * @param string $url Absolute http(s) URL. + * @return string Canonical form, or the input unchanged when it cannot be parsed. + */ + public static function canonicalize( string $url ): string { + $parts = parse_url( $url ); + if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { + return $url; + } + + $scheme = strtolower( (string) $parts['scheme'] ); + $host = strtolower( (string) $parts['host'] ); + + $port = ''; + if ( isset( $parts['port'] ) && ( self::DEFAULT_PORTS[ $scheme ] ?? null ) !== (int) $parts['port'] ) { + $port = ':' . (int) $parts['port']; + } + + $path = isset( $parts['path'] ) ? (string) $parts['path'] : ''; + $path = self::withTrailingSlash( $path ); + $query = isset( $parts['query'] ) && '' !== $parts['query'] ? '?' . $parts['query'] : ''; + + return $scheme . '://' . $host . $port . $path . $query; + } + + /** + * Add a trailing slash unless the last segment looks like a file — a dot + * in the final segment is the cheapest available signal for that, and + * getting it wrong only costs one duplicate entry, never a wrong page. + * + * @param string $path + * @return string + */ + private static function withTrailingSlash( string $path ): string { + if ( '' === $path ) { + return '/'; + } + + if ( str_ends_with( $path, '/' ) ) { + return $path; + } + + $last = substr( $path, (int) strrpos( $path, '/' ) + 1 ); + + return str_contains( $last, '.' ) ? $path : $path . '/'; + } +} diff --git a/tests/Unit/BreezeWarmup/UrlCanonicalizerTest.php b/tests/Unit/BreezeWarmup/UrlCanonicalizerTest.php new file mode 100644 index 0000000..bfd3162 --- /dev/null +++ b/tests/Unit/BreezeWarmup/UrlCanonicalizerTest.php @@ -0,0 +1,79 @@ + + */ + public static function canonicalCases(): array { + return array( + 'lowercases scheme and host' => array( + 'HTTPS://Example.TEST/Page/', + 'https://example.test/Page/', + ), + 'drops the fragment' => array( + 'https://example.test/page/#section', + 'https://example.test/page/', + ), + 'drops the default https port' => array( + 'https://example.test:443/page/', + 'https://example.test/page/', + ), + 'drops the default http port' => array( + 'http://example.test:80/page/', + 'http://example.test/page/', + ), + 'keeps a non-default port' => array( + 'https://example.test:8443/page/', + 'https://example.test:8443/page/', + ), + 'adds the trailing slash' => array( + 'https://example.test/page', + 'https://example.test/page/', + ), + 'leaves a file-looking path alone' => array( + 'https://example.test/feed.xml', + 'https://example.test/feed.xml', + ), + 'root gets a slash' => array( + 'https://example.test', + 'https://example.test/', + ), + 'preserves the query verbatim' => array( + 'https://example.test/?lang=sk&b=1', + 'https://example.test/?lang=sk&b=1', + ), + 'garbage passes through untouched' => array( + 'not a url', + 'not a url', + ), + ); + } + + #[DataProvider('canonicalCases')] + public function test_canonicalizes( string $input, string $expected ): void { + $this->assertSame( $expected, UrlCanonicalizer::canonicalize( $input ) ); + } + + public function test_is_idempotent(): void { + $once = UrlCanonicalizer::canonicalize( 'HTTPS://Example.TEST:443/a#x' ); + $twice = UrlCanonicalizer::canonicalize( $once ); + + $this->assertSame( $once, $twice ); + } +} From b8a71c2fcacefa5112e664aac0ede40a67e98f75 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 14:33:35 +0200 Subject: [PATCH 02/25] feat(warmup): derive post type and language from sitemap provenance --- src/BreezeWarmup/SourceNaming.php | 100 ++++++++++++++++++ tests/Unit/BreezeWarmup/SourceNamingTest.php | 104 +++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/BreezeWarmup/SourceNaming.php create mode 100644 tests/Unit/BreezeWarmup/SourceNamingTest.php diff --git a/src/BreezeWarmup/SourceNaming.php b/src/BreezeWarmup/SourceNaming.php new file mode 100644 index 0000000..15af63d --- /dev/null +++ b/src/BreezeWarmup/SourceNaming.php @@ -0,0 +1,100 @@ +-.xml`; AIOSEO emits + * `-sitemap.xml`. The result is not validated against + * `get_post_types()` — it is only a key into the weight map, and an + * unknown key scores 0 exactly like an unrecognised name would. + * + * @param string $sitemapUrl + * @return string Post type, or '' when the name is not recognised. + */ + public static function derivePostType( string $sitemapUrl ): string { + $path = (string) ( parse_url( $sitemapUrl, PHP_URL_PATH ) ?: '' ); + $base = basename( $path ); + if ( '' === $base ) { + return ''; + } + + $base = preg_replace( '/\.gz$/i', '', $base ) ?? $base; + + if ( 1 === preg_match( '/^wp-sitemap-posts-(.+)-\d+\.xml$/i', $base, $m ) ) { + return strtolower( $m[1] ); + } + + if ( 1 === preg_match( '/^(.+)-sitemap(?:\d+)?\.xml$/i', $base, $m ) ) { + // "wp-sitemap.xml" itself matches nothing useful; guard the known + // index names so a root document is not read as a type. + $candidate = strtolower( $m[1] ); + + return in_array( $candidate, array( 'wp', '' ), true ) ? '' : $candidate; + } + + return ''; + } + + /** + * Language for a URL, in falling order of confidence. + * + * @param string $url The page URL. + * @param string $sitemapUrl The sub-sitemap it came from. + * @param array $activeCodes Active language codes. + * @param string $defaultCode Site default language code. + * @return string + */ + public static function deriveLanguage( string $url, string $sitemapUrl, array $activeCodes, string $defaultCode ): string { + $codes = array_map( 'strtolower', $activeCodes ); + + $fromSitemap = self::firstPathSegment( $sitemapUrl ); + if ( '' !== $fromSitemap && in_array( $fromSitemap, $codes, true ) ) { + return $fromSitemap; + } + + $fromPath = self::firstPathSegment( $url ); + if ( '' !== $fromPath && in_array( $fromPath, $codes, true ) ) { + return $fromPath; + } + + $query = (string) ( parse_url( $url, PHP_URL_QUERY ) ?: '' ); + if ( '' !== $query ) { + parse_str( $query, $params ); + $lang = isset( $params['lang'] ) && is_string( $params['lang'] ) ? strtolower( $params['lang'] ) : ''; + if ( '' !== $lang && in_array( $lang, $codes, true ) ) { + return $lang; + } + } + + return $defaultCode; + } + + /** + * @param string $url + * @return string Lowercased first path segment, or '' when there is none. + */ + private static function firstPathSegment( string $url ): string { + $path = (string) ( parse_url( $url, PHP_URL_PATH ) ?: '' ); + $segments = array_values( array_filter( explode( '/', $path ), static fn( string $s ): bool => '' !== $s ) ); + + return isset( $segments[0] ) ? strtolower( $segments[0] ) : ''; + } +} diff --git a/tests/Unit/BreezeWarmup/SourceNamingTest.php b/tests/Unit/BreezeWarmup/SourceNamingTest.php new file mode 100644 index 0000000..b0b7ed7 --- /dev/null +++ b/tests/Unit/BreezeWarmup/SourceNamingTest.php @@ -0,0 +1,104 @@ + + */ + public static function postTypeCases(): array { + return array( + 'core shape' => array( 'https://example.test/wp-sitemap-posts-post-1.xml', 'post' ), + 'core custom type' => array( 'https://example.test/wp-sitemap-posts-realizace-2.xml', 'realizace' ), + 'core taxonomy is not a post type' => array( 'https://example.test/wp-sitemap-taxonomies-category-1.xml', '' ), + 'aioseo shape' => array( 'https://example.test/post-sitemap.xml', 'post' ), + 'aioseo custom type' => array( 'https://example.test/realizace-sitemap.xml', 'realizace' ), + 'aioseo gzipped' => array( 'https://example.test/post-sitemap.xml.gz', 'post' ), + 'root sitemap' => array( 'https://example.test/sitemap.xml', '' ), + 'core root' => array( 'https://example.test/wp-sitemap.xml', '' ), + 'unknown shape' => array( 'https://example.test/whatever.xml', '' ), + 'empty' => array( '', '' ), + ); + } + + #[DataProvider('postTypeCases')] + public function test_derives_post_type( string $sitemapUrl, string $expected ): void { + $this->assertSame( $expected, SourceNaming::derivePostType( $sitemapUrl ) ); + } + + public function test_language_comes_from_the_sub_sitemap_when_it_names_one(): void { + $this->assertSame( + 'sk', + SourceNaming::deriveLanguage( + 'https://example.test/nieco/', + 'https://example.test/sk/post-sitemap.xml', + array( 'cs', 'sk' ), + 'cs' + ) + ); + } + + public function test_language_comes_from_the_first_path_segment(): void { + $this->assertSame( + 'sk', + SourceNaming::deriveLanguage( + 'https://example.test/sk/nieco/', + 'https://example.test/sitemap.xml', + array( 'cs', 'sk' ), + 'cs' + ) + ); + } + + public function test_language_comes_from_the_lang_query_parameter(): void { + $this->assertSame( + 'sk', + SourceNaming::deriveLanguage( + 'https://example.test/?lang=sk', + 'https://example.test/sitemap.xml', + array( 'cs', 'sk' ), + 'cs' + ) + ); + } + + public function test_language_falls_back_to_the_default(): void { + $this->assertSame( + 'cs', + SourceNaming::deriveLanguage( + 'https://example.test/neco/', + 'https://example.test/sitemap.xml', + array( 'cs', 'sk' ), + 'cs' + ) + ); + } + + public function test_a_path_segment_that_is_not_an_active_code_is_not_a_language(): void { + // "blog" looks like a prefix but is not a registered language, so it + // must not be mistaken for one. + $this->assertSame( + 'cs', + SourceNaming::deriveLanguage( + 'https://example.test/blog/neco/', + 'https://example.test/sitemap.xml', + array( 'cs', 'sk' ), + 'cs' + ) + ); + } +} From 0a2320a1cae5f863c9cf369062660df78b89857e Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 14:37:38 +0200 Subject: [PATCH 03/25] fix(warmup): don't guess AIOSEO archive indexes as post types, lowercase default lang --- src/BreezeWarmup/SourceNaming.php | 19 +++++++++++++++++-- tests/Unit/BreezeWarmup/SourceNamingTest.php | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/BreezeWarmup/SourceNaming.php b/src/BreezeWarmup/SourceNaming.php index 15af63d..cfac259 100644 --- a/src/BreezeWarmup/SourceNaming.php +++ b/src/BreezeWarmup/SourceNaming.php @@ -18,6 +18,17 @@ */ final class SourceNaming { + /** + * AIOSEO archive index names that share the `-sitemap.xml` shape + * with a post-type sitemap but are not post types at all. A taxonomy + * sitemap cannot be told apart from a post-type one by filename alone; + * that case is not solved here and simply falls through to weight 0, + * the safe default the rest of the design already relies on. + * + * @var string[] + */ + private const AIOSEO_NON_POST_TYPE_INDEXES = array( 'author', 'date', 'product_attributes', 'rss', 'additional' ); + /** * Post type from a sub-sitemap URL. * @@ -47,7 +58,11 @@ public static function derivePostType( string $sitemapUrl ): string { // index names so a root document is not read as a type. $candidate = strtolower( $m[1] ); - return in_array( $candidate, array( 'wp', '' ), true ) ? '' : $candidate; + if ( in_array( $candidate, array( 'wp', '' ), true ) ) { + return ''; + } + + return in_array( $candidate, self::AIOSEO_NON_POST_TYPE_INDEXES, true ) ? '' : $candidate; } return ''; @@ -84,7 +99,7 @@ public static function deriveLanguage( string $url, string $sitemapUrl, array $a } } - return $defaultCode; + return strtolower( $defaultCode ); } /** diff --git a/tests/Unit/BreezeWarmup/SourceNamingTest.php b/tests/Unit/BreezeWarmup/SourceNamingTest.php index b0b7ed7..3807caf 100644 --- a/tests/Unit/BreezeWarmup/SourceNamingTest.php +++ b/tests/Unit/BreezeWarmup/SourceNamingTest.php @@ -32,6 +32,9 @@ public static function postTypeCases(): array { 'core root' => array( 'https://example.test/wp-sitemap.xml', '' ), 'unknown shape' => array( 'https://example.test/whatever.xml', '' ), 'empty' => array( '', '' ), + 'aioseo author index is not a post type' => array( 'https://example.test/author-sitemap.xml', '' ), + 'aioseo date index is not a post type' => array( 'https://example.test/date-sitemap.xml', '' ), + 'aioseo product_attributes index is not a post type' => array( 'https://example.test/product_attributes-sitemap.xml', '' ), ); } @@ -88,6 +91,18 @@ public function test_language_falls_back_to_the_default(): void { ); } + public function test_language_default_is_lowercased(): void { + $this->assertSame( + 'cs', + SourceNaming::deriveLanguage( + 'https://example.test/neco/', + 'https://example.test/sitemap.xml', + array( 'cs', 'sk' ), + 'CS' + ) + ); + } + public function test_a_path_segment_that_is_not_an_active_code_is_not_a_language(): void { // "blog" looks like a prefix but is not a registered language, so it // must not be mistaken for one. From 53314d1ad7bb13c0ded0774cd757c620a178fb36 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 14:40:13 +0200 Subject: [PATCH 04/25] feat(warmup): score a sitemap record and order by it, stably --- src/BreezeWarmup/Scorer.php | 174 ++++++++++++++++++++++++ tests/Unit/BreezeWarmup/ScorerTest.php | 175 +++++++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 src/BreezeWarmup/Scorer.php create mode 100644 tests/Unit/BreezeWarmup/ScorerTest.php diff --git a/src/BreezeWarmup/Scorer.php b/src/BreezeWarmup/Scorer.php new file mode 100644 index 0000000..92a765d --- /dev/null +++ b/src/BreezeWarmup/Scorer.php @@ -0,0 +1,174 @@ +, freshness: array} + */ + public const DEFAULT_WEIGHTS = array( + 'front_page' => 1000, + 'manual' => 800, + 'menu' => 500, + 'types' => array(), + 'freshness' => array( + 2 => 300, + 7 => 200, + 30 => 100, + 365 => 25, + ), + ); + + /** + * Bucketed freshness. Bucketed rather than continuous because the buckets + * are trivially testable and immune to clock skew of a few minutes. + * + * The boundary belongs to the higher bucket (strict `<`). A missing, + * unparseable or **future** timestamp scores zero: scheduled content is + * not fresh content, and a broken `lastmod` must never be able to shoot a + * URL to the front of the queue. + * + * @param int|null $lastmod Unix timestamp, or null. + * @param int $now Unix timestamp to measure against. + * @param array $buckets Days => points, ascending by days. + * @return int + */ + public static function freshness( ?int $lastmod, int $now, array $buckets ): int { + if ( null === $lastmod || $lastmod > $now ) { + return 0; + } + + $ageDays = ( $now - $lastmod ) / self::DAY; + + ksort( $buckets ); + foreach ( $buckets as $days => $points ) { + if ( $ageDays < $days ) { + return (int) $points; + } + } + + return 0; + } + + /** + * @param array $record + * @param array $weights + * @param int $now + * @return int + */ + public static function score( array $record, array $weights, int $now = 0 ): int { + $score = 0; + + if ( ! empty( $record['front_page'] ) ) { + $score += (int) ( $weights['front_page'] ?? 0 ); + } + if ( ! empty( $record['manual'] ) ) { + $score += (int) ( $weights['manual'] ?? 0 ); + } + if ( ! empty( $record['menu'] ) ) { + $score += (int) ( $weights['menu'] ?? 0 ); + } + + $type = isset( $record['type'] ) ? (string) $record['type'] : ''; + if ( '' !== $type ) { + $types = is_array( $weights['types'] ?? null ) ? $weights['types'] : array(); + $score += (int) ( $types[ $type ] ?? 0 ); + } + + if ( $now > 0 ) { + $buckets = is_array( $weights['freshness'] ?? null ) ? $weights['freshness'] : array(); + $lastmod = isset( $record['lastmod'] ) ? (int) $record['lastmod'] : null; + $score += self::freshness( null === $record['lastmod'] ? null : $lastmod, $now, $buckets ); + } + + return $score; + } + + /** + * @param array> $records + * @param array $weights + * @param int $now + * @return array> Records with a `score` key added. + */ + public static function scoreAll( array $records, array $weights, int $now ): array { + foreach ( $records as $i => $record ) { + $records[ $i ]['score'] = self::score( $record, $weights, $now ); + } + + return array_values( $records ); + } + + /** + * Stable descending sort by score. + * + * PHP's `usort` has been stable since 8.0, but the tie-break is stated + * explicitly here because the whole design leans on it: ties preserve the + * sitemap's own ordering, which for AIOSEO is newest-first — free date + * ordering as a last resort. + * + * @param array> $records + * @return array> + */ + public static function sort( array $records ): array { + $records = array_values( $records ); + + usort( + $records, + static fn( array $a, array $b ): int => ( (int) ( $b['score'] ?? 0 ) ) <=> ( (int) ( $a['score'] ?? 0 ) ) + ); + + return $records; + } + + /** + * Fingerprint of the effective weight map. + * + * Stored alongside the ordered list so a deploy that changes the weights + * invalidates the ordering by itself. Cheaper than tracking *when* the + * config changed: record *what it was* and let the mismatch notice. + * + * Key order must not affect the result, or a harmless reordering in + * `StarterBase` would trigger a needless refresh. + * + * @param array $weights + * @return string + */ + public static function weightsHash( array $weights ): string { + $normalized = self::sortKeysDeep( $weights ); + + return md5( (string) json_encode( $normalized ) ); + } + + /** + * @param array $value + * @return array + */ + private static function sortKeysDeep( array $value ): array { + ksort( $value ); + foreach ( $value as $k => $v ) { + if ( is_array( $v ) ) { + $value[ $k ] = self::sortKeysDeep( $v ); + } + } + + return $value; + } +} diff --git a/tests/Unit/BreezeWarmup/ScorerTest.php b/tests/Unit/BreezeWarmup/ScorerTest.php new file mode 100644 index 0000000..3cd20ce --- /dev/null +++ b/tests/Unit/BreezeWarmup/ScorerTest.php @@ -0,0 +1,175 @@ + $overrides + * @return array + */ + private function record( array $overrides = array() ): array { + return array_merge( + array( + 'url' => 'https://example.test/a/', + 'key' => 'https://example.test/a/', + 'lastmod' => null, + 'type' => '', + 'lang' => 'cs', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + ), + $overrides + ); + } + + // -- freshness buckets -------------------------------------------------- + + /** + * @return array + */ + public static function freshnessCases(): array { + $day = 86400; + + return array( + 'today' => array( 0, 300 ), + 'just under two days' => array( 2 * $day - 1, 300 ), + 'exactly two days' => array( 2 * $day, 200 ), + 'just under seven days' => array( 7 * $day - 1, 200 ), + 'exactly seven days' => array( 7 * $day, 100 ), + 'exactly thirty days' => array( 30 * $day, 25 ), + 'exactly a year' => array( 365 * $day, 0 ), + 'ancient' => array( 4000 * $day, 0 ), + ); + } + + #[DataProvider('freshnessCases')] + public function test_freshness_buckets( int $ageSeconds, int $expected ): void { + $this->assertSame( + $expected, + Scorer::freshness( self::NOW - $ageSeconds, self::NOW, Scorer::DEFAULT_WEIGHTS['freshness'] ) + ); + } + + public function test_missing_lastmod_scores_zero(): void { + $this->assertSame( 0, Scorer::freshness( null, self::NOW, Scorer::DEFAULT_WEIGHTS['freshness'] ) ); + } + + public function test_future_lastmod_scores_zero(): void { + // Scheduled content is not fresh content, and a broken lastmod must + // never be able to shoot a URL to the front of the queue. + $this->assertSame( + 0, + Scorer::freshness( self::NOW + 86400, self::NOW, Scorer::DEFAULT_WEIGHTS['freshness'] ) + ); + } + + // -- score composition -------------------------------------------------- + + public function test_front_page_weight(): void { + $score = Scorer::score( $this->record( array( 'front_page' => true ) ), Scorer::DEFAULT_WEIGHTS ); + + $this->assertSame( 1000, $score ); + } + + public function test_manual_weight(): void { + $this->assertSame( 800, Scorer::score( $this->record( array( 'manual' => true ) ), Scorer::DEFAULT_WEIGHTS ) ); + } + + public function test_menu_weight(): void { + $this->assertSame( 500, Scorer::score( $this->record( array( 'menu' => true ) ), Scorer::DEFAULT_WEIGHTS ) ); + } + + public function test_type_weight_comes_from_the_map(): void { + $weights = Scorer::DEFAULT_WEIGHTS; + $weights['types']['realizace'] = 50; + + $this->assertSame( 50, Scorer::score( $this->record( array( 'type' => 'realizace' ) ), $weights ) ); + } + + public function test_unknown_type_scores_zero(): void { + $this->assertSame( 0, Scorer::score( $this->record( array( 'type' => 'nonesuch' ) ), Scorer::DEFAULT_WEIGHTS ) ); + } + + public function test_components_add_up(): void { + // A fresh page in a menu must outrank a stale page in a menu — that is + // why the model sums instead of taking a maximum. + $record = $this->record( + array( + 'menu' => true, + 'lastmod' => self::NOW, + ) + ); + + $this->assertSame( 800, Scorer::scoreAll( array( $record ), Scorer::DEFAULT_WEIGHTS, self::NOW )[0]['score'] ); + } + + // -- sorting ------------------------------------------------------------ + + public function test_sorts_descending_by_score(): void { + $records = array( + array_merge( $this->record( array( 'url' => 'https://example.test/low/' ) ), array( 'score' => 10 ) ), + array_merge( $this->record( array( 'url' => 'https://example.test/high/' ) ), array( 'score' => 900 ) ), + ); + + $sorted = Scorer::sort( $records ); + + $this->assertSame( 'https://example.test/high/', $sorted[0]['url'] ); + } + + public function test_ties_keep_input_order(): void { + // Stable ordering preserves the sitemap's own date ordering as a free + // last resort. AIOSEO emits newest-first inside each sub-sitemap. + $records = array(); + foreach ( array( 'a', 'b', 'c', 'd' ) as $slug ) { + $records[] = array_merge( + $this->record( array( 'url' => 'https://example.test/' . $slug . '/' ) ), + array( 'score' => 100 ) + ); + } + + $sorted = array_column( Scorer::sort( $records ), 'url' ); + + $this->assertSame( + array( + 'https://example.test/a/', + 'https://example.test/b/', + 'https://example.test/c/', + 'https://example.test/d/', + ), + $sorted + ); + } + + // -- weights hash ------------------------------------------------------- + + public function test_weights_hash_is_stable_across_key_order(): void { + $a = array( 'menu' => 500, 'manual' => 800 ); + $b = array( 'manual' => 800, 'menu' => 500 ); + + $this->assertSame( Scorer::weightsHash( $a ), Scorer::weightsHash( $b ) ); + } + + public function test_weights_hash_changes_with_a_value(): void { + $a = array( 'menu' => 500 ); + $b = array( 'menu' => 501 ); + + $this->assertNotSame( Scorer::weightsHash( $a ), Scorer::weightsHash( $b ) ); + } +} From d822d84feb70eb23241484b47d05bb10e953c893 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 14:43:40 +0200 Subject: [PATCH 05/25] fix(warmup): guard the undefined-key warning and require $now in score() score() re-read $record['lastmod'] directly after already guarding it with isset(), so a record with no lastmod key at all raised an E_WARNING that PHPUnit turns into a failure. Pass the guarded local through instead. Also drop the $now = 0 default: a caller who forgets it silently loses the whole freshness contribution with no signal. $now is now required, and freshness always participates in the sum. --- src/BreezeWarmup/Scorer.php | 14 ++++++++------ tests/Unit/BreezeWarmup/ScorerTest.php | 17 ++++++++++++----- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/BreezeWarmup/Scorer.php b/src/BreezeWarmup/Scorer.php index 92a765d..716d14e 100644 --- a/src/BreezeWarmup/Scorer.php +++ b/src/BreezeWarmup/Scorer.php @@ -69,12 +69,16 @@ public static function freshness( ?int $lastmod, int $now, array $buckets ): int } /** + * $now is required, not defaulted: a default lets a caller who passes a + * real lastmod but forgets $now silently lose the whole freshness + * contribution, with no warning and no exception to catch it. + * * @param array $record * @param array $weights * @param int $now * @return int */ - public static function score( array $record, array $weights, int $now = 0 ): int { + public static function score( array $record, array $weights, int $now ): int { $score = 0; if ( ! empty( $record['front_page'] ) ) { @@ -93,11 +97,9 @@ public static function score( array $record, array $weights, int $now = 0 ): int $score += (int) ( $types[ $type ] ?? 0 ); } - if ( $now > 0 ) { - $buckets = is_array( $weights['freshness'] ?? null ) ? $weights['freshness'] : array(); - $lastmod = isset( $record['lastmod'] ) ? (int) $record['lastmod'] : null; - $score += self::freshness( null === $record['lastmod'] ? null : $lastmod, $now, $buckets ); - } + $buckets = is_array( $weights['freshness'] ?? null ) ? $weights['freshness'] : array(); + $lastmod = isset( $record['lastmod'] ) ? (int) $record['lastmod'] : null; + $score += self::freshness( $lastmod, $now, $buckets ); return $score; } diff --git a/tests/Unit/BreezeWarmup/ScorerTest.php b/tests/Unit/BreezeWarmup/ScorerTest.php index 3cd20ce..70ba130 100644 --- a/tests/Unit/BreezeWarmup/ScorerTest.php +++ b/tests/Unit/BreezeWarmup/ScorerTest.php @@ -83,28 +83,35 @@ public function test_future_lastmod_scores_zero(): void { // -- score composition -------------------------------------------------- public function test_front_page_weight(): void { - $score = Scorer::score( $this->record( array( 'front_page' => true ) ), Scorer::DEFAULT_WEIGHTS ); + $score = Scorer::score( $this->record( array( 'front_page' => true ) ), Scorer::DEFAULT_WEIGHTS, self::NOW ); $this->assertSame( 1000, $score ); } public function test_manual_weight(): void { - $this->assertSame( 800, Scorer::score( $this->record( array( 'manual' => true ) ), Scorer::DEFAULT_WEIGHTS ) ); + $this->assertSame( 800, Scorer::score( $this->record( array( 'manual' => true ) ), Scorer::DEFAULT_WEIGHTS, self::NOW ) ); } public function test_menu_weight(): void { - $this->assertSame( 500, Scorer::score( $this->record( array( 'menu' => true ) ), Scorer::DEFAULT_WEIGHTS ) ); + $this->assertSame( 500, Scorer::score( $this->record( array( 'menu' => true ) ), Scorer::DEFAULT_WEIGHTS, self::NOW ) ); } public function test_type_weight_comes_from_the_map(): void { $weights = Scorer::DEFAULT_WEIGHTS; $weights['types']['realizace'] = 50; - $this->assertSame( 50, Scorer::score( $this->record( array( 'type' => 'realizace' ) ), $weights ) ); + $this->assertSame( 50, Scorer::score( $this->record( array( 'type' => 'realizace' ) ), $weights, self::NOW ) ); } public function test_unknown_type_scores_zero(): void { - $this->assertSame( 0, Scorer::score( $this->record( array( 'type' => 'nonesuch' ) ), Scorer::DEFAULT_WEIGHTS ) ); + $this->assertSame( 0, Scorer::score( $this->record( array( 'type' => 'nonesuch' ) ), Scorer::DEFAULT_WEIGHTS, self::NOW ) ); + } + + public function test_record_without_a_lastmod_key_scores_without_a_warning(): void { + $this->assertSame( + 1000, + Scorer::score( array( 'front_page' => true ), Scorer::DEFAULT_WEIGHTS, self::NOW ) + ); } public function test_components_add_up(): void { From 3f0543e37120b06f071a8a0aceadc9d9ab73f0f0 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 14:45:59 +0200 Subject: [PATCH 06/25] feat(warmup): divide the URL budget between languages, guarantees first --- src/BreezeWarmup/LanguageQuota.php | 115 ++++++++++++++++ tests/Unit/BreezeWarmup/LanguageQuotaTest.php | 127 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 src/BreezeWarmup/LanguageQuota.php create mode 100644 tests/Unit/BreezeWarmup/LanguageQuotaTest.php diff --git a/src/BreezeWarmup/LanguageQuota.php b/src/BreezeWarmup/LanguageQuota.php new file mode 100644 index 0000000..776c64a --- /dev/null +++ b/src/BreezeWarmup/LanguageQuota.php @@ -0,0 +1,115 @@ +> $records Scored records. + * @param int $max Soft cap on total URLs. + * @return array> Selected records, input order preserved. + */ + public static function apply( array $records, int $max ): array { + $max = max( 0, $max ); + $keep = array(); + $dropped = array(); + + foreach ( $records as $i => $record ) { + if ( ! empty( $record['front_page'] ) || ! empty( $record['menu'] ) ) { + $keep[ $i ] = true; + } else { + $dropped[] = $i; + } + } + + $budget = max( 0, $max - count( $keep ) ); + if ( $budget > 0 && array() !== $dropped ) { + foreach ( self::selectByLanguage( $records, $dropped, $budget ) as $i ) { + $keep[ $i ] = true; + } + } + + $result = array(); + foreach ( $records as $i => $record ) { + if ( isset( $keep[ $i ] ) ) { + $result[] = $record; + } + } + + return $result; + } + + /** + * Split the remaining budget across languages in proportion to how many + * optional URLs each has, then take that many best-scoring ones. + * + * Proportional rather than equal: on a site where 90 percent of the + * content is Czech, an equal split would hand English a third of the + * budget for nothing. + * + * @param array> $records + * @param array $candidates Indexes eligible for selection. + * @param int $budget + * @return array Selected indexes. + */ + private static function selectByLanguage( array $records, array $candidates, int $budget ): array { + $byLang = array(); + foreach ( $candidates as $i ) { + $lang = isset( $records[ $i ]['lang'] ) ? (string) $records[ $i ]['lang'] : ''; + $byLang[ $lang ] = $byLang[ $lang ] ?? array(); + $byLang[ $lang ][] = $i; + } + + $total = count( $candidates ); + $selected = array(); + $assigned = 0; + + // Largest-remainder is overkill here; floor each share and hand any + // leftover slots to the languages with the most candidates, so no + // slot is wasted to rounding. + $quotas = array(); + foreach ( $byLang as $lang => $indexes ) { + $quotas[ $lang ] = (int) floor( $budget * count( $indexes ) / $total ); + $assigned += $quotas[ $lang ]; + } + + $leftover = $budget - $assigned; + if ( $leftover > 0 ) { + uasort( $byLang, static fn( array $a, array $b ): int => count( $b ) <=> count( $a ) ); + foreach ( array_keys( $byLang ) as $lang ) { + if ( $leftover <= 0 ) { + break; + } + ++$quotas[ $lang ]; + --$leftover; + } + } + + foreach ( $byLang as $lang => $indexes ) { + usort( + $indexes, + static fn( int $a, int $b ): int => ( (int) $records[ $b ]['score'] ) <=> ( (int) $records[ $a ]['score'] ) + ); + foreach ( array_slice( $indexes, 0, $quotas[ $lang ] ) as $i ) { + $selected[] = $i; + } + } + + return $selected; + } +} diff --git a/tests/Unit/BreezeWarmup/LanguageQuotaTest.php b/tests/Unit/BreezeWarmup/LanguageQuotaTest.php new file mode 100644 index 0000000..c922e6a --- /dev/null +++ b/tests/Unit/BreezeWarmup/LanguageQuotaTest.php @@ -0,0 +1,127 @@ + $overrides + * @return array + */ + private function record( string $url, array $overrides = array() ): array { + return array_merge( + array( + 'url' => $url, + 'key' => $url, + 'lastmod' => null, + 'type' => '', + 'lang' => 'cs', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + 'score' => 0, + ), + $overrides + ); + } + + public function test_single_language_is_a_plain_cap(): void { + $records = array(); + for ( $i = 0; $i < 10; $i++ ) { + $records[] = $this->record( 'https://example.test/' . $i . '/', array( 'score' => 100 - $i ) ); + } + + $result = LanguageQuota::apply( $records, 4 ); + + $this->assertCount( 4, $result ); + $this->assertSame( 'https://example.test/0/', $result[0]['url'] ); + } + + public function test_budget_is_split_proportionally_to_url_count(): void { + // 8 Czech URLs, 2 Slovak, cap 5 -> Czech gets 4, Slovak gets 1. + $records = array(); + for ( $i = 0; $i < 8; $i++ ) { + $records[] = $this->record( 'https://example.test/cs' . $i . '/', array( 'lang' => 'cs', 'score' => 10 ) ); + } + for ( $i = 0; $i < 2; $i++ ) { + $records[] = $this->record( 'https://example.test/sk' . $i . '/', array( 'lang' => 'sk', 'score' => 10 ) ); + } + + $result = LanguageQuota::apply( $records, 5 ); + $byLang = array_count_values( array_column( $result, 'lang' ) ); + + $this->assertSame( 4, $byLang['cs'] ); + $this->assertSame( 1, $byLang['sk'] ); + } + + public function test_front_page_and_menu_are_always_kept(): void { + $records = array( + $this->record( 'https://example.test/', array( 'lang' => 'cs', 'front_page' => true, 'score' => 1000 ) ), + $this->record( 'https://example.test/sk/', array( 'lang' => 'sk', 'front_page' => true, 'score' => 1000 ) ), + $this->record( 'https://example.test/kontakt/', array( 'lang' => 'cs', 'menu' => true, 'score' => 500 ) ), + $this->record( 'https://example.test/filler/', array( 'lang' => 'cs', 'score' => 1 ) ), + ); + + $result = LanguageQuota::apply( $records, 1 ); + $urls = array_column( $result, 'url' ); + + $this->assertContains( 'https://example.test/', $urls ); + $this->assertContains( 'https://example.test/sk/', $urls ); + $this->assertContains( 'https://example.test/kontakt/', $urls ); + $this->assertNotContains( 'https://example.test/filler/', $urls ); + } + + public function test_guarantees_may_overflow_the_cap(): void { + // Cap 2, but four guaranteed records. Guarantees win; the cap is soft. + $records = array(); + foreach ( array( 'cs', 'sk', 'en', 'de' ) as $lang ) { + $records[] = $this->record( + 'https://example.test/' . $lang . '/', + array( 'lang' => $lang, 'front_page' => true, 'score' => 1000 ) + ); + } + + $result = LanguageQuota::apply( $records, 2 ); + + $this->assertCount( 4, $result ); + } + + public function test_zero_cap_still_keeps_guarantees(): void { + $records = array( + $this->record( 'https://example.test/', array( 'front_page' => true, 'score' => 1000 ) ), + $this->record( 'https://example.test/x/', array( 'score' => 1 ) ), + ); + + $result = LanguageQuota::apply( $records, 0 ); + + $this->assertCount( 1, $result ); + $this->assertSame( 'https://example.test/', $result[0]['url'] ); + } + + public function test_preserves_input_order(): void { + // apply() selects; it must not reorder. Sorting happens after. + $records = array( + $this->record( 'https://example.test/a/', array( 'score' => 5 ) ), + $this->record( 'https://example.test/b/', array( 'score' => 9 ) ), + ); + + $result = LanguageQuota::apply( $records, 2 ); + + $this->assertSame( + array( 'https://example.test/a/', 'https://example.test/b/' ), + array_column( $result, 'url' ) + ); + } +} From d7b033b890cea0e55d024d8efaa6a15ee6603567 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 14:49:03 +0200 Subject: [PATCH 07/25] test(warmup): cover leftover redistribution and unscored records; guard score read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add coverage for the leftover-slot branch in selectByLanguage() (previously untested — all six original fixtures divided evenly), plus a cap-exceeds- candidates case. Guard the score comparator with ?? 0 to match the existing lang guard, so an unscored record sorts last instead of raising an Undefined array key warning. --- src/BreezeWarmup/LanguageQuota.php | 4 +- tests/Unit/BreezeWarmup/LanguageQuotaTest.php | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/BreezeWarmup/LanguageQuota.php b/src/BreezeWarmup/LanguageQuota.php index 776c64a..0b5d4e3 100644 --- a/src/BreezeWarmup/LanguageQuota.php +++ b/src/BreezeWarmup/LanguageQuota.php @@ -101,9 +101,11 @@ private static function selectByLanguage( array $records, array $candidates, int } foreach ( $byLang as $lang => $indexes ) { + // Guard the read: a record that never went through the Scorer has no + // 'score' key, and it should sort last rather than warn. usort( $indexes, - static fn( int $a, int $b ): int => ( (int) $records[ $b ]['score'] ) <=> ( (int) $records[ $a ]['score'] ) + static fn( int $a, int $b ): int => ( (int) ( $records[ $b ]['score'] ?? 0 ) ) <=> ( (int) ( $records[ $a ]['score'] ?? 0 ) ) ); foreach ( array_slice( $indexes, 0, $quotas[ $lang ] ) as $i ) { $selected[] = $i; diff --git a/tests/Unit/BreezeWarmup/LanguageQuotaTest.php b/tests/Unit/BreezeWarmup/LanguageQuotaTest.php index c922e6a..3580431 100644 --- a/tests/Unit/BreezeWarmup/LanguageQuotaTest.php +++ b/tests/Unit/BreezeWarmup/LanguageQuotaTest.php @@ -124,4 +124,61 @@ public function test_preserves_input_order(): void { array_column( $result, 'url' ) ); } + + public function test_leftover_slots_go_to_the_language_with_most_candidates(): void { + // cs=3, sk=2, en=2, cap 4 -> floors are 1/1/1 (3 assigned, 1 leftover). + // The leftover slot must go to cs, the language with the most candidates. + $records = array(); + for ( $i = 0; $i < 3; $i++ ) { + $records[] = $this->record( 'https://example.test/cs' . $i . '/', array( 'lang' => 'cs', 'score' => 10 ) ); + } + for ( $i = 0; $i < 2; $i++ ) { + $records[] = $this->record( 'https://example.test/sk' . $i . '/', array( 'lang' => 'sk', 'score' => 10 ) ); + } + for ( $i = 0; $i < 2; $i++ ) { + $records[] = $this->record( 'https://example.test/en' . $i . '/', array( 'lang' => 'en', 'score' => 10 ) ); + } + + $result = LanguageQuota::apply( $records, 4 ); + $byLang = array_count_values( array_column( $result, 'lang' ) ); + + $this->assertCount( 4, $result ); + $this->assertSame( 2, $byLang['cs'] ); + $this->assertSame( 1, $byLang['sk'] ); + $this->assertSame( 1, $byLang['en'] ); + } + + public function test_cap_exceeding_candidate_count_returns_all_of_them(): void { + $records = array( + $this->record( 'https://example.test/a/', array( 'score' => 3 ) ), + $this->record( 'https://example.test/b/', array( 'score' => 2 ) ), + $this->record( 'https://example.test/c/', array( 'score' => 1 ) ), + ); + + $result = LanguageQuota::apply( $records, 100 ); + + $this->assertCount( 3, $result ); + } + + public function test_missing_score_key_does_not_warn(): void { + // A record that never went through the Scorer must not raise an + // "Undefined array key" warning; it should simply sort last. + $records = array( + array( + 'url' => 'https://example.test/no-score/', + 'key' => 'https://example.test/no-score/', + 'lastmod' => null, + 'type' => '', + 'lang' => 'cs', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + ), + $this->record( 'https://example.test/scored/', array( 'score' => 5 ) ), + ); + + $result = LanguageQuota::apply( $records, 2 ); + + $this->assertCount( 2, $result ); + } } From ced46844640d08a75c11aa5fb7b77465f2457f5f Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 14:51:58 +0200 Subject: [PATCH 08/25] feat(warmup): carry lastmod and provenance through the sitemap crawl --- src/BreezeWarmupSitemap.php | 103 +++++++++++--- .../FetchSitemapRecordsTest.php | 130 ++++++++++++++++++ tests/Unit/BreezeWarmupSitemap/Fixtures.php | 20 +++ 3 files changed, 231 insertions(+), 22 deletions(-) create mode 100644 tests/Unit/BreezeWarmupSitemap/FetchSitemapRecordsTest.php diff --git a/src/BreezeWarmupSitemap.php b/src/BreezeWarmupSitemap.php index ddd4dd7..3aa8f93 100644 --- a/src/BreezeWarmupSitemap.php +++ b/src/BreezeWarmupSitemap.php @@ -4,6 +4,9 @@ namespace Parisek\TimberKit; +use Parisek\TimberKit\BreezeWarmup\SourceNaming; +use Parisek\TimberKit\BreezeWarmup\UrlCanonicalizer; + /** * Feeds Breeze's Cache Warmup preloader with every URL from the site's XML * sitemap, via the `breeze_preload_urls` filter. @@ -294,29 +297,56 @@ public static function runRefresh(): void { } /** - * Fetch and parse the site's sitemap into a flat, deduped, same-host URL - * list. Never throws — any failure along the way degrades to an empty - * array. Only ever called from the deferred refresh job, never from the - * purge-time filter callback. + * Structured sitemap crawl. Never throws — any failure degrades to an + * empty array. Only ever called from the deferred refresh job. * - * @return array + * @return array> */ - public static function fetchSitemapUrls(): array { + public static function fetchSitemapRecords(): array { try { $root = self::resolveSitemapRootUrl(); if ( '' === $root ) { return array(); } - $seen = array(); - $urls = self::fetchAndParseSitemap( $root, 0, $seen ); + $seen = array(); + $records = self::fetchAndParseSitemap( $root, 0, $seen ); - return array_values( array_unique( $urls ) ); + return self::dedupeByKey( $records ); } catch ( \Throwable $e ) { return array(); } } + /** + * Backwards-compatible string view of {@see self::fetchSitemapRecords()}. + * + * @return array + */ + public static function fetchSitemapUrls(): array { + return array_column( self::fetchSitemapRecords(), 'url' ); + } + + /** + * @param array> $records + * @return array> + */ + private static function dedupeByKey( array $records ): array { + $seen = array(); + $result = array(); + + foreach ( $records as $record ) { + $key = (string) $record['key']; + if ( isset( $seen[ $key ] ) ) { + continue; + } + $seen[ $key ] = true; + $result[] = $record; + } + + return $result; + } + /** * AIOSEO-first sitemap root URL resolution, falling back to WordPress core. * @@ -352,7 +382,7 @@ private static function isAioseoActive(): bool { * @param string $url Sitemap (or sub-sitemap) URL. * @param int $depth Current recursion depth. * @param array $seen URLs already fetched, by reference — guards against index cycles. - * @return array URLs collected from `` entries. + * @return array> Records collected from `` entries. */ private static function fetchAndParseSitemap( string $url, int $depth, array &$seen ): array { if ( ! self::isFetchableSameHostUrl( $url ) ) { @@ -386,7 +416,7 @@ private static function fetchAndParseSitemap( string $url, int $depth, array &$s } if ( 'urlset' === $root_name ) { - return self::collectFromUrlset( $xml ); + return self::collectFromUrlset( $xml, $url ); } return array(); @@ -398,15 +428,15 @@ private static function fetchAndParseSitemap( string $url, int $depth, array &$s * @param \SimpleXMLElement $xml Parsed `` root. * @param int $depth Current recursion depth. * @param array $seen URLs already fetched, by reference. - * @return array + * @return array> */ private static function collectFromIndex( \SimpleXMLElement $xml, int $depth, array &$seen ): array { if ( $depth >= self::MAX_DEPTH ) { return array(); } - $urls = array(); - $count = 0; + $records = array(); + $count = 0; foreach ( $xml->sitemap as $sitemap ) { if ( $count >= self::MAX_SUBSITEMAPS ) { @@ -423,21 +453,23 @@ private static function collectFromIndex( \SimpleXMLElement $xml, int $depth, ar // same-host http(s) URL before fetching it — this counts a // rejected off-host entry against MAX_SUBSITEMAPS too, which is // fine: it's still one entry consumed either way. - $urls = array( ...$urls, ...self::fetchAndParseSitemap( $loc, $depth + 1, $seen ) ); + $records = array( ...$records, ...self::fetchAndParseSitemap( $loc, $depth + 1, $seen ) ); } - return $urls; + return $records; } /** * Collect `` entries from a `` document, keeping only * same-host URLs. * - * @param \SimpleXMLElement $xml Parsed `` root. - * @return array + * @param \SimpleXMLElement $xml Parsed `` root. + * @param string $sourceUrl The document this urlset came from. + * @return array> */ - private static function collectFromUrlset( \SimpleXMLElement $xml ): array { - $urls = array(); + private static function collectFromUrlset( \SimpleXMLElement $xml, string $sourceUrl ): array { + $type = SourceNaming::derivePostType( $sourceUrl ); + $records = array(); foreach ( $xml->url as $entry ) { $loc = isset( $entry->loc ) ? trim( (string) $entry->loc ) : ''; @@ -445,10 +477,37 @@ private static function collectFromUrlset( \SimpleXMLElement $xml ): array { continue; } - $urls[] = $loc; + $records[] = array( + 'url' => $loc, + 'key' => UrlCanonicalizer::canonicalize( $loc ), + 'lastmod' => self::parseLastmod( isset( $entry->lastmod ) ? trim( (string) $entry->lastmod ) : '' ), + 'type' => $type, + 'source' => $sourceUrl, + 'lang' => '', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + ); } - return $urls; + return $records; + } + + /** + * `` to a unix timestamp. Anything unparseable is null rather + * than "now" — a broken timestamp must not read as fresh content. + * + * @param string $raw + * @return int|null + */ + private static function parseLastmod( string $raw ): ?int { + if ( '' === $raw ) { + return null; + } + + $ts = strtotime( $raw ); + + return false === $ts ? null : $ts; } /** diff --git a/tests/Unit/BreezeWarmupSitemap/FetchSitemapRecordsTest.php b/tests/Unit/BreezeWarmupSitemap/FetchSitemapRecordsTest.php new file mode 100644 index 0000000..2dd1bd1 --- /dev/null +++ b/tests/Unit/BreezeWarmupSitemap/FetchSitemapRecordsTest.php @@ -0,0 +1,130 @@ +alias( + static fn( string $path = '' ): string => 'https://example.test' . $path + ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + } + + protected function tearDown(): void { + BreezeWarmupSitemap::reset_for_tests(); + Monkey\tearDown(); + parent::tearDown(); + } + + /** + * @param array $bodies URL => body + */ + private function serve( array $bodies ): void { + Functions\when( 'wp_remote_get' )->alias( + static fn( string $url ): array => array( 'body' => $bodies[ $url ] ?? '' ) + ); + Functions\when( 'wp_remote_retrieve_body' )->alias( + static fn( array $r ): string => (string) $r['body'] + ); + } + + public function test_reads_lastmod_into_a_timestamp(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::urlsetWithLastmod( + array( 'https://example.test/a/' => '2026-08-01T10:00:00+00:00' ) + ), + ) + ); + + $records = BreezeWarmupSitemap::fetchSitemapRecords(); + + $this->assertCount( 1, $records ); + $this->assertSame( strtotime( '2026-08-01T10:00:00+00:00' ), $records[0]['lastmod'] ); + } + + public function test_missing_lastmod_is_null(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::urlset( array( 'https://example.test/a/' ) ), + ) + ); + + $this->assertNull( BreezeWarmupSitemap::fetchSitemapRecords()[0]['lastmod'] ); + } + + public function test_unparseable_lastmod_is_null(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::urlsetWithLastmod( + array( 'https://example.test/a/' => 'not a date' ) + ), + ) + ); + + $this->assertNull( BreezeWarmupSitemap::fetchSitemapRecords()[0]['lastmod'] ); + } + + public function test_post_type_comes_from_the_sub_sitemap_name(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::sitemapIndex( + array( 'https://example.test/wp-sitemap-posts-realizace-1.xml' ) + ), + 'https://example.test/wp-sitemap-posts-realizace-1.xml' => Fixtures::urlset( + array( 'https://example.test/realizace/a/' ) + ), + ) + ); + + $this->assertSame( 'realizace', BreezeWarmupSitemap::fetchSitemapRecords()[0]['type'] ); + } + + public function test_record_carries_a_canonical_key(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::urlset( array( 'https://example.test/a' ) ), + ) + ); + + $record = BreezeWarmupSitemap::fetchSitemapRecords()[0]; + + $this->assertSame( 'https://example.test/a', $record['url'], 'the original URL is what Breeze must warm' ); + $this->assertSame( 'https://example.test/a/', $record['key'] ); + } + + public function test_legacy_string_api_still_works(): void { + $this->serve( + array( + 'https://example.test/wp-sitemap.xml' => Fixtures::urlset( + array( 'https://example.test/a/', 'https://example.test/b/' ) + ), + ) + ); + + $this->assertSame( + array( 'https://example.test/a/', 'https://example.test/b/' ), + BreezeWarmupSitemap::fetchSitemapUrls() + ); + } +} diff --git a/tests/Unit/BreezeWarmupSitemap/Fixtures.php b/tests/Unit/BreezeWarmupSitemap/Fixtures.php index 7582006..ee3dd9a 100644 --- a/tests/Unit/BreezeWarmupSitemap/Fixtures.php +++ b/tests/Unit/BreezeWarmupSitemap/Fixtures.php @@ -41,6 +41,26 @@ public static function sitemapIndex( array $locs ): string { . $entries . ''; } + /** + * A `` document whose entries carry a ``. + * + * @param array $locs loc => lastmod (ISO-8601), empty string for none. + * @return string + */ + public static function urlsetWithLastmod( array $locs ): string { + $entries = ''; + foreach ( $locs as $loc => $lastmod ) { + $entries .= '' . htmlspecialchars( $loc, ENT_XML1 ) . ''; + if ( '' !== $lastmod ) { + $entries .= '' . htmlspecialchars( $lastmod, ENT_XML1 ) . ''; + } + $entries .= ''; + } + + return '' + . $entries . ''; + } + /** * A successful `wp_remote_get()`-shaped response array wrapping a body. * From 5fad0e5fb0f9c7329ee179e5f929f58e6dea3256 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 14:54:50 +0200 Subject: [PATCH 09/25] docs(warmup): state that sitemap URLs dedup on canonical form --- src/BreezeWarmupSitemap.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/BreezeWarmupSitemap.php b/src/BreezeWarmupSitemap.php index 3aa8f93..36d8d52 100644 --- a/src/BreezeWarmupSitemap.php +++ b/src/BreezeWarmupSitemap.php @@ -321,6 +321,13 @@ public static function fetchSitemapRecords(): array { /** * Backwards-compatible string view of {@see self::fetchSitemapRecords()}. * + * Entries are deduplicated by canonical URL form, not by exact string — + * two spellings of the same page (differing only in trailing slash, + * scheme case, default port, or fragment) collapse to one, and the + * first-seen spelling wins. Warming the same page twice wastes a slot of + * the URL cap, and the canonical key is what joins this list with the + * signals coming from menus and Breeze's own preload list. + * * @return array */ public static function fetchSitemapUrls(): array { @@ -328,6 +335,9 @@ public static function fetchSitemapUrls(): array { } /** + * First-seen-wins: when two records share a canonical key, later ones + * are dropped rather than overwriting the first. + * * @param array> $records * @return array> */ From 810a6de745ffdc195af0b4bd2680e8ea44f3558b Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 14:58:24 +0200 Subject: [PATCH 10/25] feat(warmup): collect menu, per-language homepage and manual signals --- src/BreezeWarmup/SignalCollector.php | 179 ++++++++++++++++++ .../Unit/BreezeWarmup/SignalCollectorTest.php | 143 ++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 src/BreezeWarmup/SignalCollector.php create mode 100644 tests/Unit/BreezeWarmup/SignalCollectorTest.php diff --git a/src/BreezeWarmup/SignalCollector.php b/src/BreezeWarmup/SignalCollector.php new file mode 100644 index 0000000..81bc912 --- /dev/null +++ b/src/BreezeWarmup/SignalCollector.php @@ -0,0 +1,179 @@ + + */ + public static function menuKeys(): array { + if ( ! function_exists( 'wp_get_nav_menus' ) || ! function_exists( 'wp_get_nav_menu_items' ) ) { + return array(); + } + + $keys = array(); + + foreach ( (array) wp_get_nav_menus() as $menu ) { + $id = is_object( $menu ) && isset( $menu->term_id ) ? (int) $menu->term_id : 0; + $items = 0 !== $id ? wp_get_nav_menu_items( $id ) : false; + if ( ! is_array( $items ) ) { + continue; + } + + foreach ( $items as $item ) { + $url = is_object( $item ) && isset( $item->url ) ? trim( (string) $item->url ) : ''; + if ( '' === $url || '#' === $url ) { + continue; + } + + $keys[ UrlCanonicalizer::canonicalize( $url ) ] = true; + } + } + + return $keys; + } + + /** + * Homepage of every active language, keyed by canonical URL. + * + * Breeze only ever knows about one homepage — whichever language the + * purge request happened to run in. Every other translation lands + * wherever the sitemap put it, which is the gap this closes. + * + * Foreign hosts are dropped without complaint: under WPML's + * domain-per-language mode each domain warms itself in its own purge + * request, so one language remaining here is correct, not a failure. + * + * @return array Canonical URL => language code ('' without WPML). + */ + public static function frontPages(): array { + if ( ! function_exists( 'home_url' ) ) { + return array(); + } + + $home = (string) home_url( '/' ); + $host = strtolower( (string) ( parse_url( $home, PHP_URL_HOST ) ?: '' ) ); + + $languages = function_exists( 'apply_filters' ) + ? apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => false ) ) + : null; + + if ( ! is_array( $languages ) || array() === $languages ) { + return array( UrlCanonicalizer::canonicalize( $home ) => '' ); + } + + $pages = array(); + foreach ( $languages as $language ) { + $url = is_array( $language ) && isset( $language['url'] ) ? (string) $language['url'] : ''; + if ( '' === $url ) { + continue; + } + + if ( strtolower( (string) ( parse_url( $url, PHP_URL_HOST ) ?: '' ) ) !== $host ) { + continue; + } + + $code = is_array( $language ) && isset( $language['language_code'] ) + ? (string) $language['language_code'] + : ''; + + $pages[ UrlCanonicalizer::canonicalize( $url ) ] = $code; + } + + return array() === $pages ? array( UrlCanonicalizer::canonicalize( $home ) => '' ) : $pages; + } + + /** + * Canonical URLs the admin typed into Breeze's Preload settings tab. + * + * Read here rather than from the filter argument: at purge time there is + * no room to score anything, and in the refresh job this is one cheap + * option read. + * + * @return array + */ + public static function manualKeys(): array { + if ( ! function_exists( 'breeze_get_option' ) ) { + return array(); + } + + $options = breeze_get_option( 'preload_settings', false ); + $raw = is_array( $options ) && isset( $options['breeze-preload-cache-urls'] ) + ? $options['breeze-preload-cache-urls'] + : array(); + + if ( ! is_array( $raw ) ) { + return array(); + } + + $keys = array(); + foreach ( $raw as $url ) { + if ( ! is_string( $url ) || '' === trim( $url ) ) { + continue; + } + $keys[ UrlCanonicalizer::canonicalize( trim( $url ) ) ] = true; + } + + return $keys; + } + + /** + * Active language codes plus the default, for language attribution. + * + * @return array{codes: array, default: string} + */ + public static function activeLanguages(): array { + $languages = function_exists( 'apply_filters' ) + ? apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => false ) ) + : null; + + if ( ! is_array( $languages ) || array() === $languages ) { + return array( 'codes' => array(), 'default' => '' ); + } + + $codes = array(); + $default = ''; + foreach ( $languages as $language ) { + $code = is_array( $language ) && isset( $language['language_code'] ) + ? strtolower( (string) $language['language_code'] ) + : ''; + if ( '' === $code ) { + continue; + } + $codes[] = $code; + if ( '' === $default ) { + $default = $code; + } + } + + $currentDefault = function_exists( 'apply_filters' ) + ? apply_filters( 'wpml_default_language', null ) + : null; + + if ( is_string( $currentDefault ) && '' !== $currentDefault ) { + $default = strtolower( $currentDefault ); + } + + return array( 'codes' => $codes, 'default' => $default ); + } +} diff --git a/tests/Unit/BreezeWarmup/SignalCollectorTest.php b/tests/Unit/BreezeWarmup/SignalCollectorTest.php new file mode 100644 index 0000000..22280ae --- /dev/null +++ b/tests/Unit/BreezeWarmup/SignalCollectorTest.php @@ -0,0 +1,143 @@ +alias( + static fn( string $path = '' ): string => 'https://example.test' . $path + ); + } + + protected function tearDown(): void { + Monkey\tearDown(); + parent::tearDown(); + } + + public function test_menu_keys_are_canonical(): void { + Functions\when( 'wp_get_nav_menus' )->justReturn( array( (object) array( 'term_id' => 7 ) ) ); + Functions\when( 'wp_get_nav_menu_items' )->justReturn( + array( + (object) array( 'url' => 'https://example.test/kontakt' ), + (object) array( 'url' => 'https://example.test/o-nas/#tym' ), + ) + ); + + $keys = SignalCollector::menuKeys(); + + $this->assertArrayHasKey( 'https://example.test/kontakt/', $keys ); + $this->assertArrayHasKey( 'https://example.test/o-nas/', $keys ); + } + + public function test_menu_items_without_a_url_are_skipped(): void { + Functions\when( 'wp_get_nav_menus' )->justReturn( array( (object) array( 'term_id' => 7 ) ) ); + Functions\when( 'wp_get_nav_menu_items' )->justReturn( + array( + (object) array( 'url' => '' ), + (object) array( 'url' => '#' ), + ) + ); + + $this->assertSame( array(), SignalCollector::menuKeys() ); + } + + public function test_no_menus_yields_no_keys(): void { + Functions\when( 'wp_get_nav_menus' )->justReturn( array() ); + + $this->assertSame( array(), SignalCollector::menuKeys() ); + } + + public function test_front_pages_without_wpml_is_just_home(): void { + Functions\when( 'apply_filters' )->alias( + static fn( string $hook, $value ) => $value + ); + + $pages = SignalCollector::frontPages(); + + $this->assertSame( array( 'https://example.test/' => '' ), $pages ); + } + + public function test_front_pages_covers_every_active_language(): void { + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'cs' => array( 'language_code' => 'cs', 'url' => 'https://example.test/' ), + 'sk' => array( 'language_code' => 'sk', 'url' => 'https://example.test/sk/' ), + ); + } + + return $value; + } + ); + + $pages = SignalCollector::frontPages(); + + $this->assertSame( + array( + 'https://example.test/' => 'cs', + 'https://example.test/sk/' => 'sk', + ), + $pages + ); + } + + public function test_front_pages_drops_foreign_hosts(): void { + // WPML domain-per-language mode. Breeze would reject these in + // preload_url() anyway, so dropping them here is the honest result, + // not an error worth logging. + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'cs' => array( 'language_code' => 'cs', 'url' => 'https://example.test/' ), + 'sk' => array( 'language_code' => 'sk', 'url' => 'https://example.sk/' ), + ); + } + + return $value; + } + ); + + $this->assertSame( array( 'https://example.test/' => 'cs' ), SignalCollector::frontPages() ); + } + + // breeze_get_option() is mocked here via Functions\when(), which — unlike + // Functions\expect() — patches the function definition for the rest of + // the process. StarterBase::setupBreezeWarmupSitemap() branches on + // function_exists('breeze_get_option') to detect Breeze's absence, so + // leaking this mock would falsely make Breeze look installed in later, + // unrelated tests. Run in a separate process to keep the leak contained. + #[RunInSeparateProcess] + public function test_manual_keys_come_from_breeze_settings(): void { + Functions\when( 'breeze_get_option' )->justReturn( + array( 'breeze-preload-cache-urls' => array( 'https://example.test/akce' ) ) + ); + + $this->assertArrayHasKey( 'https://example.test/akce/', SignalCollector::manualKeys() ); + } + + #[RunInSeparateProcess] + public function test_manual_keys_tolerate_missing_settings(): void { + Functions\when( 'breeze_get_option' )->justReturn( false ); + + $this->assertSame( array(), SignalCollector::manualKeys() ); + } +} From fce5d6465017fd931810ccd24c8f17088beb8aa0 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:02:11 +0200 Subject: [PATCH 11/25] fix(warmup): harden test isolation and reject non-page menu URLs - Pair #[RunInSeparateProcess] with #[PreserveGlobalState(false)] on the SignalCollector manualKeys tests, matching the existing convention. - Isolate BreezeWarmupSitemapSetupTest's "Breeze absent" test the same way, since its function_exists('breeze_get_option') check is unreliable whenever any other test in the run mocks that function. - menuKeys() now skips menu items that are not absolute http(s) URLs (fragments, mailto: links), so they cannot become junk keys that can never join with a sitemap record. - Add missing coverage for activeLanguages() and the all-foreign frontPages() fallback branch. --- src/BreezeWarmup/SignalCollector.php | 12 +- .../Unit/BreezeWarmup/SignalCollectorTest.php | 118 ++++++++++++++++++ .../BreezeWarmupSitemapSetupTest.php | 8 ++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/BreezeWarmup/SignalCollector.php b/src/BreezeWarmup/SignalCollector.php index 81bc912..f802792 100644 --- a/src/BreezeWarmup/SignalCollector.php +++ b/src/BreezeWarmup/SignalCollector.php @@ -42,7 +42,17 @@ public static function menuKeys(): array { foreach ( $items as $item ) { $url = is_object( $item ) && isset( $item->url ) ? trim( (string) $item->url ) : ''; - if ( '' === $url || '#' === $url ) { + if ( '' === $url ) { + continue; + } + + // A menu can hold custom links that are pure fragments + // ('#section') or non-page targets ('mailto:'). Only a real + // http(s) page URL can ever join with a sitemap record, so + // anything else must not become a junk key in the map. + $scheme = strtolower( (string) ( parse_url( $url, PHP_URL_SCHEME ) ?: '' ) ); + $host = (string) ( parse_url( $url, PHP_URL_HOST ) ?: '' ); + if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || '' === $host ) { continue; } diff --git a/tests/Unit/BreezeWarmup/SignalCollectorTest.php b/tests/Unit/BreezeWarmup/SignalCollectorTest.php index 22280ae..1d94d93 100644 --- a/tests/Unit/BreezeWarmup/SignalCollectorTest.php +++ b/tests/Unit/BreezeWarmup/SignalCollectorTest.php @@ -6,6 +6,7 @@ use Brain\Monkey; use Brain\Monkey\Functions; +use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use Parisek\TimberKit\BreezeWarmup\SignalCollector; @@ -64,6 +65,21 @@ public function test_no_menus_yields_no_keys(): void { $this->assertSame( array(), SignalCollector::menuKeys() ); } + public function test_menu_items_that_are_not_absolute_http_urls_are_skipped(): void { + // A menu can hold custom links that are pure fragments or mailto: + // targets. Only a real page URL can ever join with a sitemap record, + // so anything else must not become a junk key in the signal map. + Functions\when( 'wp_get_nav_menus' )->justReturn( array( (object) array( 'term_id' => 7 ) ) ); + Functions\when( 'wp_get_nav_menu_items' )->justReturn( + array( + (object) array( 'url' => '#section' ), + (object) array( 'url' => 'mailto:a@b.test' ), + ) + ); + + $this->assertSame( array(), SignalCollector::menuKeys() ); + } + public function test_front_pages_without_wpml_is_just_home(): void { Functions\when( 'apply_filters' )->alias( static fn( string $hook, $value ) => $value @@ -119,12 +135,113 @@ static function ( string $hook, $value ) { $this->assertSame( array( 'https://example.test/' => 'cs' ), SignalCollector::frontPages() ); } + public function test_front_pages_falls_back_to_home_when_every_language_is_foreign(): void { + // Distinct from "no WPML at all": WPML answered with a non-empty + // array, but domain-per-language mode put every entry on a foreign + // host. The result must still be the single-home fallback, not an + // empty map — an empty preload list would warm nothing at all. + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'sk' => array( 'language_code' => 'sk', 'url' => 'https://example.sk/' ), + 'de' => array( 'language_code' => 'de', 'url' => 'https://example.de/' ), + ); + } + + return $value; + } + ); + + $this->assertSame( array( 'https://example.test/' => '' ), SignalCollector::frontPages() ); + } + + public function test_active_languages_without_wpml_is_empty(): void { + Functions\when( 'apply_filters' )->alias( + static fn( string $hook, $value ) => $value + ); + + $this->assertSame( + array( 'codes' => array(), 'default' => '' ), + SignalCollector::activeLanguages() + ); + } + + public function test_active_languages_lists_codes_and_seeds_default_from_first(): void { + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'cs' => array( 'language_code' => 'cs' ), + 'sk' => array( 'language_code' => 'sk' ), + ); + } + + return $value; + } + ); + + $this->assertSame( + array( 'codes' => array( 'cs', 'sk' ), 'default' => 'cs' ), + SignalCollector::activeLanguages() + ); + } + + public function test_active_languages_prefers_wpml_default_language_filter(): void { + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'cs' => array( 'language_code' => 'cs' ), + 'sk' => array( 'language_code' => 'sk' ), + ); + } + + if ( 'wpml_default_language' === $hook ) { + return 'SK'; + } + + return $value; + } + ); + + $this->assertSame( + array( 'codes' => array( 'cs', 'sk' ), 'default' => 'sk' ), + SignalCollector::activeLanguages() + ); + } + + public function test_active_languages_ignores_unusable_default_language_filter_result(): void { + Functions\when( 'apply_filters' )->alias( + static function ( string $hook, $value ) { + if ( 'wpml_active_languages' === $hook ) { + return array( + 'cs' => array( 'language_code' => 'cs' ), + 'sk' => array( 'language_code' => 'sk' ), + ); + } + + if ( 'wpml_default_language' === $hook ) { + return ''; + } + + return $value; + } + ); + + $this->assertSame( + array( 'codes' => array( 'cs', 'sk' ), 'default' => 'cs' ), + SignalCollector::activeLanguages() + ); + } + // breeze_get_option() is mocked here via Functions\when(), which — unlike // Functions\expect() — patches the function definition for the rest of // the process. StarterBase::setupBreezeWarmupSitemap() branches on // function_exists('breeze_get_option') to detect Breeze's absence, so // leaking this mock would falsely make Breeze look installed in later, // unrelated tests. Run in a separate process to keep the leak contained. + #[PreserveGlobalState( false )] #[RunInSeparateProcess] public function test_manual_keys_come_from_breeze_settings(): void { Functions\when( 'breeze_get_option' )->justReturn( @@ -134,6 +251,7 @@ public function test_manual_keys_come_from_breeze_settings(): void { $this->assertArrayHasKey( 'https://example.test/akce/', SignalCollector::manualKeys() ); } + #[PreserveGlobalState( false )] #[RunInSeparateProcess] public function test_manual_keys_tolerate_missing_settings(): void { Functions\when( 'breeze_get_option' )->justReturn( false ); diff --git a/tests/Unit/StarterBase/BreezeWarmupSitemapSetupTest.php b/tests/Unit/StarterBase/BreezeWarmupSitemapSetupTest.php index 4c4f7b7..1febc03 100644 --- a/tests/Unit/StarterBase/BreezeWarmupSitemapSetupTest.php +++ b/tests/Unit/StarterBase/BreezeWarmupSitemapSetupTest.php @@ -67,6 +67,14 @@ function ( string $tag ) use ( &$filters ) { $this->assertNotContains( 'breeze_preload_urls', $filters ); } + // This test's assertion depends on function_exists( 'breeze_get_option' ) + // returning FALSE — the same check AGENTS.md calls unreliable under + // Brain\Monkey, because any other test in the run that mocks + // breeze_get_option() patches it in for the rest of the process, which + // makes the "Breeze absent" case this test asserts unobservable. It must + // run isolated regardless of who else is in the suite. + #[PreserveGlobalState( false )] + #[RunInSeparateProcess] public function test_does_not_register_when_flag_is_on_but_breeze_is_absent(): void { $filters = array(); Functions\when( 'add_filter' )->alias( From 3e83fc2a124f863c5d325532ba18cba648446a3b Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:05:11 +0200 Subject: [PATCH 12/25] feat(warmup): guard the ordering row with an optimistic revision --- src/BreezeWarmup/PriorityStore.php | 95 +++++++++++++++ tests/Unit/BreezeWarmup/PriorityStoreTest.php | 112 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/BreezeWarmup/PriorityStore.php create mode 100644 tests/Unit/BreezeWarmup/PriorityStoreTest.php diff --git a/src/BreezeWarmup/PriorityStore.php b/src/BreezeWarmup/PriorityStore.php new file mode 100644 index 0000000..f555f92 --- /dev/null +++ b/src/BreezeWarmup/PriorityStore.php @@ -0,0 +1,95 @@ +, signals: array, fetched_at: int, weights_hash: string, revision: int}|null + */ + public static function read(): ?array { + if ( ! function_exists( 'get_option' ) ) { + return null; + } + + $data = get_option( self::OPTION_KEY, null ); + + if ( + ! is_array( $data ) + || ! isset( $data['urls'], $data['signals'], $data['fetched_at'], $data['weights_hash'], $data['revision'] ) + || ! is_array( $data['urls'] ) + || ! is_array( $data['signals'] ) + ) { + return null; + } + + return array( + 'urls' => array_values( array_filter( $data['urls'], 'is_string' ) ), + 'signals' => $data['signals'], + 'fetched_at' => (int) $data['fetched_at'], + 'weights_hash' => (string) $data['weights_hash'], + 'revision' => (int) $data['revision'], + ); + } + + /** + * Current revision, 0 when the row is missing or legacy. + * + * @return int + */ + public static function revision(): int { + $data = self::read(); + + return null === $data ? 0 : $data['revision']; + } + + /** + * @param array $urls Ordered URLs. + * @param array $signals Canonical key => signal record. + * @param string $weightsHash + * @param int $expectedRevision Revision the caller read before computing. + * @return bool True when written, false when a concurrent write won. + */ + public static function write( array $urls, array $signals, string $weightsHash, int $expectedRevision ): bool { + if ( ! function_exists( 'update_option' ) ) { + return false; + } + + if ( self::revision() !== $expectedRevision ) { + return false; + } + + update_option( + self::OPTION_KEY, + array( + 'urls' => array_values( $urls ), + 'signals' => $signals, + 'fetched_at' => function_exists( 'time' ) ? time() : 0, + 'weights_hash' => $weightsHash, + 'revision' => $expectedRevision + 1, + ), + false + ); + + return true; + } +} diff --git a/tests/Unit/BreezeWarmup/PriorityStoreTest.php b/tests/Unit/BreezeWarmup/PriorityStoreTest.php new file mode 100644 index 0000000..9bda700 --- /dev/null +++ b/tests/Unit/BreezeWarmup/PriorityStoreTest.php @@ -0,0 +1,112 @@ +justReturn( + array( + 'urls' => array( 'https://example.test/' ), + 'signals' => array( 'https://example.test/' => array( 'menu' => true ) ), + 'fetched_at' => 123, + 'weights_hash' => 'abc', + 'revision' => 4, + ) + ); + + $data = PriorityStore::read(); + + $this->assertNotNull( $data ); + $this->assertSame( 4, $data['revision'] ); + $this->assertSame( 'abc', $data['weights_hash'] ); + } + + public function test_legacy_payload_without_signals_reads_as_null(): void { + // A v1.x row has only urls + fetched_at. Treating it as null makes the + // caller schedule a refresh, which is exactly the desired migration: + // none. + Functions\when( 'get_option' )->justReturn( + array( 'urls' => array( 'https://example.test/' ), 'fetched_at' => 123 ) + ); + + $this->assertNull( PriorityStore::read() ); + } + + public function test_missing_option_reads_as_null(): void { + Functions\when( 'get_option' )->justReturn( null ); + + $this->assertNull( PriorityStore::read() ); + } + + public function test_write_succeeds_when_the_revision_is_unchanged(): void { + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array(), + 'signals' => array(), + 'fetched_at' => 1, + 'weights_hash' => 'a', + 'revision' => 2, + ) + ); + $written = null; + Functions\when( 'update_option' )->alias( + static function ( string $key, $value ) use ( &$written ): bool { + $written = $value; + + return true; + } + ); + + $ok = PriorityStore::write( array( 'https://example.test/' ), array(), 'b', 2 ); + + $this->assertTrue( $ok ); + $this->assertSame( 3, $written['revision'], 'the stored revision advances' ); + } + + public function test_write_is_discarded_when_the_revision_moved(): void { + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array(), + 'signals' => array(), + 'fetched_at' => 1, + 'weights_hash' => 'a', + 'revision' => 9, + ) + ); + Functions\expect( 'update_option' )->never(); + + $this->assertFalse( PriorityStore::write( array( 'x' ), array(), 'b', 2 ) ); + } + + public function test_write_on_an_empty_store_succeeds_from_revision_zero(): void { + Functions\when( 'get_option' )->justReturn( null ); + Functions\when( 'update_option' )->justReturn( true ); + + $this->assertTrue( PriorityStore::write( array( 'https://example.test/' ), array(), 'b', 0 ) ); + } +} From 08a3fc027ac62cc156bb05a895fb83b4338a4af0 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:08:02 +0200 Subject: [PATCH 13/25] docs(warmup): state which write race the revision guard does and does not close --- src/BreezeWarmup/PriorityStore.php | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/BreezeWarmup/PriorityStore.php b/src/BreezeWarmup/PriorityStore.php index f555f92..007ed55 100644 --- a/src/BreezeWarmup/PriorityStore.php +++ b/src/BreezeWarmup/PriorityStore.php @@ -12,11 +12,21 @@ * options are last-write-wins, so a refresh that started before a menu edit * and finished after it would silently restore the stale ordering. * - * The revision counter makes that a discarded write instead: read the - * revision, write with revision + 1, and refuse if somebody moved it in - * between. Not atomic in the strong sense — no transaction is available here - * — but it shrinks the window to a single get/update pair and removes the - * failure that actually happens. + * The revision counter guards the case that actually happens: a cron refresh + * reads revision N, spends seconds crawling a sitemap, and by the time it + * writes, a menu-change rescore has already stored N + 1. The re-read before + * write() catches that, and the stale ordering is discarded instead of + * clobbering fresh data. + * + * It does not guard two genuinely concurrent writers. Both can read + * revision N, both can pass the check, and both can call update_option() — + * the later one wins, both calls return true, and the stored revision reads + * N + 1 instead of N + 2, so nothing downstream can tell a write was lost. + * Closing that would need a conditional UPDATE through $wpdb matched against + * the serialized option value: fragile, and disproportionate to the risk. + * The two writers here are an hourly cron job and a human saving a menu — + * overlapping to the microsecond is possible but rare, while the slow-cron + * case above is routine. */ final class PriorityStore { From 1f8682d63d6188eff49c80437b9720b883af6635 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:21:27 +0200 Subject: [PATCH 14/25] feat(warmup): score and order the sitemap during the deferred refresh --- src/BreezeWarmup/PriorityStore.php | 26 +++ src/BreezeWarmupSitemap.php | 193 ++++++++++++------ tests/Unit/BreezeWarmup/PriorityStoreTest.php | 65 ++++++ .../RunRefreshPriorityTest.php | 121 +++++++++++ .../BreezeWarmupSitemap/RunRefreshTest.php | 25 +++ 5 files changed, 365 insertions(+), 65 deletions(-) create mode 100644 tests/Unit/BreezeWarmupSitemap/RunRefreshPriorityTest.php diff --git a/src/BreezeWarmup/PriorityStore.php b/src/BreezeWarmup/PriorityStore.php index 007ed55..40d4f80 100644 --- a/src/BreezeWarmup/PriorityStore.php +++ b/src/BreezeWarmup/PriorityStore.php @@ -61,6 +61,32 @@ public static function read(): ?array { ); } + /** + * Tolerant URL-only read, accepting either the current five-key payload + * or the legacy `{urls, fetched_at}` shape a pre-upgrade site left behind. + * + * {@see self::read()} is deliberately strict: a legacy row must read as + * null there so the caller schedules a refresh (that IS the migration — + * there is no other conversion step). But the purge-time filter still + * needs *some* URL list to hand Breeze in the window between an upgrade + * and the first cron refresh. Falling back to nothing there would be a + * regression: today's code at least keeps serving the stale list. + * + * @return array + */ + public static function readUrls(): array { + if ( ! function_exists( 'get_option' ) ) { + return array(); + } + + $data = get_option( self::OPTION_KEY, null ); + if ( ! is_array( $data ) || ! isset( $data['fetched_at'] ) || ! is_array( $data['urls'] ?? null ) ) { + return array(); + } + + return array_values( array_filter( $data['urls'], 'is_string' ) ); + } + /** * Current revision, 0 when the row is missing or legacy. * diff --git a/src/BreezeWarmupSitemap.php b/src/BreezeWarmupSitemap.php index 36d8d52..ab39bba 100644 --- a/src/BreezeWarmupSitemap.php +++ b/src/BreezeWarmupSitemap.php @@ -4,6 +4,10 @@ namespace Parisek\TimberKit; +use Parisek\TimberKit\BreezeWarmup\LanguageQuota; +use Parisek\TimberKit\BreezeWarmup\PriorityStore; +use Parisek\TimberKit\BreezeWarmup\Scorer; +use Parisek\TimberKit\BreezeWarmup\SignalCollector; use Parisek\TimberKit\BreezeWarmup\SourceNaming; use Parisek\TimberKit\BreezeWarmup\UrlCanonicalizer; @@ -57,9 +61,6 @@ final class BreezeWarmupSitemap { /** @var bool Prevent duplicate hook registration. */ private static bool $registered = false; - /** @var string wp_options key holding the last-known-good URL list + fetch timestamp (autoload off). */ - private const STORAGE_OPTION_KEY = 'timber_kit_breeze_warmup_sitemap_urls'; - /** @var string Transient key for the short refresh lock. */ private const LOCK_KEY = 'timber_kit_breeze_warmup_sitemap_refresh_lock'; @@ -146,75 +147,29 @@ public static function filterPreloadUrls( mixed $urls ): array { return $existing; } - $stored = self::getStoredData(); + $stored = PriorityStore::read(); if ( null === $stored || self::isStale( $stored ) ) { self::maybeScheduleRefresh(); } - $sitemap_urls = null !== $stored ? $stored['urls'] : array(); - - return self::mergeUrls( $existing, $sitemap_urls ); + return self::mergeUrls( $existing, self::getStoredUrls() ); } /** * Current last-known-good sitemap URL list, for inspection/testing. * Never triggers a fetch or a refresh. * + * Tolerant of the legacy `{urls, fetched_at}` payload as well as the + * current one — see {@see PriorityStore::readUrls()} for why. + * * @return array */ public static function getStoredUrls(): array { - $stored = self::getStoredData(); - - return null !== $stored ? $stored['urls'] : array(); - } - - /** - * Read the stored `{urls, fetched_at}` payload, tolerating a missing or - * malformed option value. - * - * @return array{urls: array, fetched_at: int}|null - */ - private static function getStoredData(): ?array { - if ( ! function_exists( 'get_option' ) ) { - return null; - } - - $data = get_option( self::STORAGE_OPTION_KEY, null ); - if ( ! is_array( $data ) || ! isset( $data['urls'], $data['fetched_at'] ) || ! is_array( $data['urls'] ) ) { - return null; - } - - return array( - 'urls' => array_values( array_filter( $data['urls'], 'is_string' ) ), - 'fetched_at' => (int) $data['fetched_at'], - ); + return PriorityStore::readUrls(); } /** - * Persist a freshly fetched URL list as the new last known good, stamped - * with the current time. Caller ({@see self::runRefresh()}) is - * responsible for never calling this with an empty list. - * - * @param array $urls - * @return void - */ - private static function storeData( array $urls ): void { - if ( ! function_exists( 'update_option' ) ) { - return; - } - - update_option( - self::STORAGE_OPTION_KEY, - array( - 'urls' => array_values( $urls ), - 'fetched_at' => function_exists( 'time' ) ? time() : 0, - ), - false - ); - } - - /** - * @param array{urls: array, fetched_at: int} $data + * @param array{urls: array, signals: array, fetched_at: int, weights_hash: string, revision: int} $data * @return bool */ private static function isStale( array $data ): bool { @@ -278,22 +233,130 @@ private static function releaseRefreshLock(): void { } /** - * Deferred-refresh cron callback ({@see self::CRON_HOOK}) — does the - * actual sitemap crawl and, only on a non-empty result, replaces the - * stored last known good list. An empty or failed crawl leaves whatever - * was previously stored untouched, so a transient sitemap outage never - * wipes out a previously working warmup list. + * Deferred-refresh cron callback. A failed or empty crawl never overwrites + * the last known good list — stale data always beats no data. + * + * The body wraps in try/finally because it now does far more than fetch + * and store: any throw between here and the release used to hold the lock + * until its TTL expired, which silently blocked retries for a minute. * * @return void */ public static function runRefresh(): void { - $urls = self::fetchSitemapUrls(); + try { + $revision = PriorityStore::revision(); + $records = self::fetchSitemapRecords(); + + if ( array() === $records ) { + return; + } + + $records = self::enrichRecords( $records ); + $weights = self::weights(); + $built = self::buildOrderedUrls( $records, $weights, time(), self::maxUrls() ); + + PriorityStore::write( $built['urls'], $built['signals'], Scorer::weightsHash( $weights ), $revision ); + } catch ( \Throwable $e ) { + // Best-effort by contract: a sitemap outage must never surface as + // a fatal in a cron job. + } finally { + self::releaseRefreshLock(); + } + } - if ( array() !== $urls ) { - self::storeData( $urls ); + /** + * Score, budget and order a set of sitemap records. + * + * Split out from {@see self::runRefresh()} so the ordering rules can be + * tested without mocking the network: everything here is deterministic + * given its arguments. + * + * @param array> $records + * @param array $weights + * @param int $now + * @param int $max + * @return array{urls: array, signals: array} + */ + public static function buildOrderedUrls( array $records, array $weights, int $now, int $max ): array { + $scored = Scorer::scoreAll( $records, $weights, $now ); + $kept = LanguageQuota::apply( $scored, $max ); + $ordered = Scorer::sort( $kept ); + + $signals = array(); + foreach ( $ordered as $record ) { + $signals[ (string) $record['key'] ] = array( + 'lastmod' => $record['lastmod'], + 'type' => (string) $record['type'], + 'lang' => (string) $record['lang'], + 'menu' => (bool) $record['menu'], + 'front_page' => (bool) $record['front_page'], + 'manual' => (bool) $record['manual'], + 'url' => (string) $record['url'], + ); } - self::releaseRefreshLock(); + return array( + 'urls' => array_column( $ordered, 'url' ), + 'signals' => $signals, + ); + } + + /** + * Attach the signals a sitemap cannot carry, and resolve each record's + * language. + * + * @param array> $records + * @return array> + */ + private static function enrichRecords( array $records ): array { + $menu = SignalCollector::menuKeys(); + $frontPages = SignalCollector::frontPages(); + $manual = SignalCollector::manualKeys(); + $languages = SignalCollector::activeLanguages(); + + foreach ( $records as $i => $record ) { + $key = (string) $record['key']; + + $records[ $i ]['menu'] = isset( $menu[ $key ] ); + $records[ $i ]['front_page'] = isset( $frontPages[ $key ] ); + $records[ $i ]['manual'] = isset( $manual[ $key ] ); + $records[ $i ]['lang'] = isset( $frontPages[ $key ] ) && '' !== $frontPages[ $key ] + ? $frontPages[ $key ] + : SourceNaming::deriveLanguage( + (string) $record['url'], + (string) ( $record['source'] ?? '' ), + $languages['codes'], + $languages['default'] + ); + } + + return $records; + } + + /** + * Effective weight map: the defaults, filterable per project. + * + * @return array + */ + public static function weights(): array { + $weights = Scorer::DEFAULT_WEIGHTS; + + $filtered = function_exists( 'apply_filters' ) + ? apply_filters( 'timberkit_warmup_priority_weights', $weights ) + : $weights; + + return is_array( $filtered ) ? $filtered : $weights; + } + + /** + * @return int + */ + private static function maxUrls(): int { + $max = function_exists( 'apply_filters' ) + ? apply_filters( 'timberkit_warmup_sitemap_max_urls', self::DEFAULT_MAX_URLS ) + : self::DEFAULT_MAX_URLS; + + return is_numeric( $max ) ? max( 0, (int) $max ) : self::DEFAULT_MAX_URLS; } /** diff --git a/tests/Unit/BreezeWarmup/PriorityStoreTest.php b/tests/Unit/BreezeWarmup/PriorityStoreTest.php index 9bda700..8d98bae 100644 --- a/tests/Unit/BreezeWarmup/PriorityStoreTest.php +++ b/tests/Unit/BreezeWarmup/PriorityStoreTest.php @@ -109,4 +109,69 @@ public function test_write_on_an_empty_store_succeeds_from_revision_zero(): void $this->assertTrue( PriorityStore::write( array( 'https://example.test/' ), array(), 'b', 0 ) ); } + + public function test_read_urls_reads_the_current_shape(): void { + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array( 'https://example.test/' ), + 'signals' => array(), + 'fetched_at' => 123, + 'weights_hash' => 'abc', + 'revision' => 4, + ) + ); + + $this->assertSame( array( 'https://example.test/' ), PriorityStore::readUrls() ); + } + + public function test_read_urls_reads_the_legacy_shape(): void { + // A v1.x row has only urls + fetched_at. read() treats this as null + // (correctly — it is not a scored payload), but readUrls() must still + // hand back the URLs: an upgraded site must keep sending Breeze *some* + // list in the window before the first cron refresh writes the new + // shape, rather than going empty. + Functions\when( 'get_option' )->justReturn( + array( 'urls' => array( 'https://example.test/a/' ), 'fetched_at' => 123 ) + ); + + $this->assertSame( array( 'https://example.test/a/' ), PriorityStore::readUrls() ); + } + + public function test_read_urls_returns_empty_array_when_option_missing(): void { + Functions\when( 'get_option' )->justReturn( null ); + + $this->assertSame( array(), PriorityStore::readUrls() ); + } + + public function test_read_urls_returns_empty_array_when_option_is_not_an_array(): void { + Functions\when( 'get_option' )->justReturn( 'not-an-array' ); + + $this->assertSame( array(), PriorityStore::readUrls() ); + } + + public function test_read_urls_returns_empty_array_when_fetched_at_is_missing(): void { + Functions\when( 'get_option' )->justReturn( array( 'urls' => array( 'https://example.test/a/' ) ) ); + + $this->assertSame( array(), PriorityStore::readUrls() ); + } + + public function test_read_urls_returns_empty_array_when_urls_key_is_not_an_array(): void { + Functions\when( 'get_option' )->justReturn( array( 'urls' => 'oops', 'fetched_at' => 123 ) ); + + $this->assertSame( array(), PriorityStore::readUrls() ); + } + + public function test_read_urls_filters_out_non_string_url_entries(): void { + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array( 'https://example.test/a/', 42, null, 'https://example.test/b/' ), + 'fetched_at' => 123, + ) + ); + + $this->assertSame( + array( 'https://example.test/a/', 'https://example.test/b/' ), + PriorityStore::readUrls() + ); + } } diff --git a/tests/Unit/BreezeWarmupSitemap/RunRefreshPriorityTest.php b/tests/Unit/BreezeWarmupSitemap/RunRefreshPriorityTest.php new file mode 100644 index 0000000..661876e --- /dev/null +++ b/tests/Unit/BreezeWarmupSitemap/RunRefreshPriorityTest.php @@ -0,0 +1,121 @@ +record( 'https://example.test/plain/' ), + $this->record( 'https://example.test/kontakt/', array( 'menu' => true ) ), + ); + + $built = BreezeWarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, 1000000000, 50 ); + + $this->assertSame( 'https://example.test/kontakt/', $built['urls'][0] ); + } + + public function test_front_page_leads(): void { + $records = array( + $this->record( 'https://example.test/kontakt/', array( 'menu' => true ) ), + $this->record( 'https://example.test/', array( 'front_page' => true ) ), + ); + + $built = BreezeWarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, 1000000000, 50 ); + + $this->assertSame( 'https://example.test/', $built['urls'][0] ); + } + + public function test_signals_are_stored_keyed_by_canonical_url(): void { + $records = array( $this->record( 'https://example.test/a/', array( 'menu' => true ) ) ); + + $built = BreezeWarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, 1000000000, 50 ); + + $this->assertArrayHasKey( 'https://example.test/a/', $built['signals'] ); + $this->assertTrue( $built['signals']['https://example.test/a/']['menu'] ); + } + + public function test_stored_signals_include_manual(): void { + // Without this the menu rescore would lose the manual weight and push + // hand-picked URLs down the list. + $records = array( $this->record( 'https://example.test/akce/', array( 'manual' => true ) ) ); + + $built = BreezeWarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, 1000000000, 50 ); + + $this->assertTrue( $built['signals']['https://example.test/akce/']['manual'] ); + } + + public function test_cap_is_applied(): void { + $records = array(); + for ( $i = 0; $i < 10; $i++ ) { + $records[] = $this->record( 'https://example.test/' . $i . '/' ); + } + + $built = BreezeWarmupSitemap::buildOrderedUrls( $records, Scorer::DEFAULT_WEIGHTS, 1000000000, 3 ); + + $this->assertCount( 3, $built['urls'] ); + } + + public function test_refresh_releases_the_lock_on_failure(): void { + Functions\when( 'get_option' )->justReturn( null ); + Functions\when( 'home_url' )->justReturn( '' ); + Functions\when( 'get_transient' )->justReturn( false ); + Functions\when( 'set_transient' )->justReturn( true ); + Functions\expect( 'delete_transient' )->once(); + + BreezeWarmupSitemap::runRefresh(); + + // Functions\expect()->once() above is the real assertion (verified on + // Mockery::close() in Monkey\tearDown()); this just keeps PHPUnit from + // flagging the test as risky for having no assertions of its own. + $this->addToAssertionCount( 1 ); + } + + /** + * @param array $overrides + * @return array + */ + private function record( string $url, array $overrides = array() ): array { + return array_merge( + array( + 'url' => $url, + 'key' => $url, + 'lastmod' => null, + 'type' => '', + 'lang' => 'cs', + 'source' => 'https://example.test/wp-sitemap.xml', + 'menu' => false, + 'front_page' => false, + 'manual' => false, + ), + $overrides + ); + } +} diff --git a/tests/Unit/BreezeWarmupSitemap/RunRefreshTest.php b/tests/Unit/BreezeWarmupSitemap/RunRefreshTest.php index 25277e2..d5171bf 100644 --- a/tests/Unit/BreezeWarmupSitemap/RunRefreshTest.php +++ b/tests/Unit/BreezeWarmupSitemap/RunRefreshTest.php @@ -26,6 +26,17 @@ protected function setUp(): void { Functions\when( 'is_wp_error' )->justReturn( false ); Functions\when( 'wp_remote_retrieve_response_code' )->alias( fn( $r ) => $r['response']['code'] ?? 200 ); Functions\when( 'wp_remote_retrieve_body' )->alias( fn( $r ) => $r['body'] ?? '' ); + // runRefresh() now also calls SignalCollector::* (via enrichRecords()) + // and apply_filters() (via weights()/maxUrls()) on a non-empty crawl. + // Both must be stubbed here regardless of whether this test's own + // assertions touch them: Brain\Monkey/Patchwork redefines a function + // process-wide the first time ANY test mocks it, so a later test in + // the same run can no longer rely on function_exists() being false — + // see the Brain\Monkey caveat in AGENTS.md. returnArg( 2 ) mirrors + // WordPress's own apply_filters() no-op default (return $value + // unchanged) for every filter this pipeline calls. + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( 'wp_get_nav_menus' )->justReturn( array() ); } protected function tearDown(): void { @@ -38,6 +49,9 @@ public function test_successful_refresh_stores_urls_and_releases_lock(): void { Functions\when( 'wp_remote_get' )->justReturn( Fixtures::response( Fixtures::urlset( array( 'https://example.test/fresh/' ) ) ) ); + // Empty store: PriorityStore::revision() needs a get_option() stub to + // read the current revision (0 here) before write() can proceed. + Functions\when( 'get_option' )->justReturn( null ); $updateOptionCalls = array(); Functions\when( 'update_option' )->alias( @@ -61,6 +75,10 @@ function ( $key ) use ( &$deletedTransients ) { $this->assertSame( 'timber_kit_breeze_warmup_sitemap_urls', $key ); $this->assertSame( array( 'https://example.test/fresh/' ), $value['urls'] ); $this->assertIsInt( $value['fetched_at'] ); + $this->assertIsArray( $value['signals'] ); + $this->assertIsString( $value['weights_hash'] ); + $this->assertNotSame( '', $value['weights_hash'] ); + $this->assertIsInt( $value['revision'] ); $this->assertFalse( $autoload ); $this->assertSame( array( 'timber_kit_breeze_warmup_sitemap_refresh_lock' ), $deletedTransients ); } @@ -68,6 +86,10 @@ function ( $key ) use ( &$deletedTransients ) { public function test_failed_crawl_does_not_overwrite_stored_data(): void { Functions\when( 'wp_remote_get' )->justReturn( 'error-marker' ); Functions\when( 'is_wp_error' )->justReturn( true ); + // PriorityStore::revision() reads get_option() before runRefresh() + // even reaches the crawl outcome — stub it so that read doesn't blow + // up; the empty store (null) is otherwise irrelevant to this test. + Functions\when( 'get_option' )->justReturn( null ); Functions\expect( 'update_option' )->never(); $deletedTransients = array(); @@ -85,6 +107,9 @@ function ( $key ) use ( &$deletedTransients ) { public function test_empty_sitemap_result_does_not_overwrite_stored_data(): void { Functions\when( 'wp_remote_get' )->justReturn( Fixtures::response( Fixtures::urlset( array() ) ) ); + // Same as above: only needed so PriorityStore::revision() has + // something to read; the empty store itself is not under test here. + Functions\when( 'get_option' )->justReturn( null ); Functions\expect( 'update_option' )->never(); $deletedTransients = array(); From bcce0f5fe075d6d6f7fc125a77fb1023417b171a Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:27:17 +0200 Subject: [PATCH 15/25] feat(warmup): merge the ordered list positionally, on canonical keys --- src/BreezeWarmupSitemap.php | 109 +++++++++++++++++- .../BreezeWarmupSitemap/MergeUrlsTest.php | 107 +++++++++++++++++ 2 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/BreezeWarmupSitemap/MergeUrlsTest.php diff --git a/src/BreezeWarmupSitemap.php b/src/BreezeWarmupSitemap.php index ab39bba..a38c857 100644 --- a/src/BreezeWarmupSitemap.php +++ b/src/BreezeWarmupSitemap.php @@ -61,6 +61,12 @@ final class BreezeWarmupSitemap { /** @var bool Prevent duplicate hook registration. */ private static bool $registered = false; + /** @var bool Whether ordering is enabled for this project. */ + private static bool $priority_enabled = false; + + /** @var string Fingerprint of the effective weights, computed once at registration. */ + private static string $weights_hash = ''; + /** @var string Transient key for the short refresh lock. */ private const LOCK_KEY = 'timber_kit_breeze_warmup_sitemap_refresh_lock'; @@ -148,11 +154,30 @@ public static function filterPreloadUrls( mixed $urls ): array { } $stored = PriorityStore::read(); - if ( null === $stored || self::isStale( $stored ) ) { + if ( null === $stored || self::isStale( $stored ) || self::weightsChanged( $stored ) ) { self::maybeScheduleRefresh(); } - return self::mergeUrls( $existing, self::getStoredUrls() ); + if ( ! self::$priority_enabled ) { + return self::legacyMerge( $existing, self::getStoredUrls() ); + } + + return self::mergeUrls( $existing, self::getStoredUrls(), function_exists( 'home_url' ) ? (string) home_url( '/' ) : '' ); + } + + /** + * Whether the stored ordering was built with a different weight map. + * + * The hash is computed once at registration and compared here, so the + * purge path pays one string comparison — not a filter call and not a + * hash. Recording *what the config was* is cheaper than tracking *when it + * changed*. + * + * @param array{weights_hash: string} $stored + * @return bool + */ + private static function weightsChanged( array $stored ): bool { + return self::$priority_enabled && '' !== self::$weights_hash && $stored['weights_hash'] !== self::$weights_hash; } /** @@ -727,7 +752,7 @@ private static function isFetchableSameHostUrl( string $url ): bool { * @param array $sitemap_urls Same-host URLs collected from the sitemap. * @return array */ - private static function mergeUrls( array $existing, array $sitemap_urls ): array { + private static function legacyMerge( array $existing, array $sitemap_urls ): array { $max = apply_filters( 'timberkit_warmup_sitemap_max_urls', self::DEFAULT_MAX_URLS ); $max = is_numeric( $max ) ? max( 0, (int) $max ) : self::DEFAULT_MAX_URLS; @@ -751,12 +776,88 @@ private static function mergeUrls( array $existing, array $sitemap_urls ): array return $merged; } + /** + * Positional merge of Breeze's own list with our ordered one. + * + * Sorting is not allowed here — this runs synchronously inside the purge + * request, so the cost must not grow with the size of the sitemap. The + * rule is therefore positional: + * + * homepage, then Breeze entries we cannot score, then our ordering. + * + * Entries Breeze supplied that *are* in the sitemap already carry the + * `manual` weight and sorted themselves; the ones that are not have no + * signals at all, so they go right behind the homepage, matching + * `manual` being the second highest weight. + * + * Membership is tested on canonical keys. Breeze builds the homepage with + * `trailingslashit()` while a sitemap may emit it bare — on raw strings + * those are two URLs and the homepage would be warmed twice. + * + * @param array $existing Breeze's own preload URL list. + * @param array $ordered Our stored, already ordered list. + * @param string $homeUrl Current language homepage. + * @return array + */ + public static function mergeUrls( array $existing, array $ordered, string $homeUrl ): array { + $homeKey = UrlCanonicalizer::canonicalize( $homeUrl ); + $orderedMap = array(); + foreach ( $ordered as $url ) { + $orderedMap[ UrlCanonicalizer::canonicalize( $url ) ] = true; + } + + // When a URL appears in both lists, Breeze's own spelling wins — the + // sitemap only supplies ordering, and Breeze must warm exactly what + // it was already going to warm. + $existingByKey = array(); + foreach ( $existing as $url ) { + $key = UrlCanonicalizer::canonicalize( $url ); + if ( ! isset( $existingByKey[ $key ] ) ) { + $existingByKey[ $key ] = $url; + } + } + + $result = array(); + $seen = array(); + + $push = static function ( string $url ) use ( &$result, &$seen ): void { + $key = UrlCanonicalizer::canonicalize( $url ); + if ( isset( $seen[ $key ] ) ) { + return; + } + $seen[ $key ] = true; + $result[] = $url; + }; + + foreach ( $existing as $url ) { + if ( UrlCanonicalizer::canonicalize( $url ) === $homeKey ) { + $push( $url ); + break; + } + } + + foreach ( $existing as $url ) { + if ( ! isset( $orderedMap[ UrlCanonicalizer::canonicalize( $url ) ] ) ) { + $push( $url ); + } + } + + foreach ( $ordered as $url ) { + $key = UrlCanonicalizer::canonicalize( $url ); + $push( $existingByKey[ $key ] ?? $url ); + } + + return $result; + } + /** * Reset internal state so tests can re-register the module. * * @return void */ public static function reset_for_tests(): void { - self::$registered = false; + self::$registered = false; + self::$priority_enabled = false; + self::$weights_hash = ''; } } diff --git a/tests/Unit/BreezeWarmupSitemap/MergeUrlsTest.php b/tests/Unit/BreezeWarmupSitemap/MergeUrlsTest.php new file mode 100644 index 0000000..092b879 --- /dev/null +++ b/tests/Unit/BreezeWarmupSitemap/MergeUrlsTest.php @@ -0,0 +1,107 @@ +assertSame( 'https://example.test/', $result[0] ); + } + + public function test_breeze_only_entries_come_before_our_list(): void { + // Entries the admin typed but which are not in the sitemap cannot be + // scored, so they sit right behind the homepage — matching the fact + // that `manual` is the second highest weight. + $result = BreezeWarmupSitemap::mergeUrls( + array( 'https://example.test/', 'https://example.test/akce/' ), + array( 'https://example.test/kontakt/' ), + self::HOME + ); + + $this->assertSame( + array( 'https://example.test/', 'https://example.test/akce/', 'https://example.test/kontakt/' ), + $result + ); + } + + public function test_our_ordering_is_preserved(): void { + $result = BreezeWarmupSitemap::mergeUrls( + array( 'https://example.test/' ), + array( 'https://example.test/first/', 'https://example.test/second/' ), + self::HOME + ); + + $this->assertSame( + array( 'https://example.test/', 'https://example.test/first/', 'https://example.test/second/' ), + $result + ); + } + + public function test_homepage_is_never_duplicated(): void { + // Breeze builds it with trailingslashit(); a sitemap may emit it + // without the slash. Keyed on the raw string those are two URLs. + $result = BreezeWarmupSitemap::mergeUrls( + array( 'https://example.test/' ), + array( 'https://example.test', 'https://example.test/a/' ), + self::HOME + ); + + $this->assertSame( + array( 'https://example.test/', 'https://example.test/a/' ), + $result + ); + } + + public function test_dedup_uses_canonical_keys(): void { + $result = BreezeWarmupSitemap::mergeUrls( + array( 'https://example.test/', 'https://example.test/akce' ), + array( 'https://example.test/akce/' ), + self::HOME + ); + + $this->assertSame( + array( 'https://example.test/', 'https://example.test/akce' ), + $result + ); + } + + public function test_homepage_missing_from_breeze_list_is_not_invented(): void { + $result = BreezeWarmupSitemap::mergeUrls( + array( 'https://example.test/shop/' ), + array( 'https://example.test/a/' ), + self::HOME + ); + + $this->assertSame( + array( 'https://example.test/shop/', 'https://example.test/a/' ), + $result + ); + } + + public function test_empty_ordered_list_returns_breeze_list_unchanged(): void { + $existing = array( 'https://example.test/', 'https://example.test/shop/' ); + + $this->assertSame( $existing, BreezeWarmupSitemap::mergeUrls( $existing, array(), self::HOME ) ); + } +} From bafd8832b4bb973101bdebc79820224297c074d0 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:32:36 +0200 Subject: [PATCH 16/25] fix(warmup): canonicalize each mergeUrls() input URL exactly once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memoize UrlCanonicalizer::canonicalize() per distinct input URL instead of recomputing it up to four times per existing entry and three times per ordered entry. Ordered can hold up to the store's cap (1000 URLs on one fleet site) and this runs synchronously inside the purge request. No observable behaviour change — existing MergeUrlsTest/FilterPreloadUrlsTest pass unedited. Also corrects PriorityStore::readUrls()'s docblock, which still described itself as serving only the legacy/ordering-off path; it now supplies the URL list on both branches of filterPreloadUrls(). --- src/BreezeWarmup/PriorityStore.php | 7 +++-- src/BreezeWarmupSitemap.php | 43 +++++++++++++++++++----------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/src/BreezeWarmup/PriorityStore.php b/src/BreezeWarmup/PriorityStore.php index 40d4f80..d435d8d 100644 --- a/src/BreezeWarmup/PriorityStore.php +++ b/src/BreezeWarmup/PriorityStore.php @@ -69,8 +69,11 @@ public static function read(): ?array { * null there so the caller schedules a refresh (that IS the migration — * there is no other conversion step). But the purge-time filter still * needs *some* URL list to hand Breeze in the window between an upgrade - * and the first cron refresh. Falling back to nothing there would be a - * regression: today's code at least keeps serving the stale list. + * and the first cron refresh — this is the URL source for BOTH branches + * of that filter (ordering off and ordering on), not only the legacy + * one; `read()` is used there solely to decide staleness and whether the + * weights changed. Falling back to nothing here would be a regression: + * today's code at least keeps serving the stale list. * * @return array */ diff --git a/src/BreezeWarmupSitemap.php b/src/BreezeWarmupSitemap.php index a38c857..406ad8b 100644 --- a/src/BreezeWarmupSitemap.php +++ b/src/BreezeWarmupSitemap.php @@ -800,18 +800,31 @@ private static function legacyMerge( array $existing, array $sitemap_urls ): arr * @return array */ public static function mergeUrls( array $existing, array $ordered, string $homeUrl ): array { - $homeKey = UrlCanonicalizer::canonicalize( $homeUrl ); - $orderedMap = array(); + $homeKey = UrlCanonicalizer::canonicalize( $homeUrl ); + + // This runs synchronously inside the purge request, and $ordered can + // hold as many URLs as the store's cap allows — up to 1000 on one + // site in this fleet. Canonicalize each distinct input URL exactly + // once here and read the memoized key everywhere below, instead of + // re-canonicalizing on every membership check and dedup lookup. + $existingKeyed = array(); + foreach ( $existing as $url ) { + $existingKeyed[] = array( $url, UrlCanonicalizer::canonicalize( $url ) ); + } + + $orderedKeyed = array(); + $orderedMap = array(); foreach ( $ordered as $url ) { - $orderedMap[ UrlCanonicalizer::canonicalize( $url ) ] = true; + $key = UrlCanonicalizer::canonicalize( $url ); + $orderedKeyed[] = array( $url, $key ); + $orderedMap[ $key ] = true; } // When a URL appears in both lists, Breeze's own spelling wins — the // sitemap only supplies ordering, and Breeze must warm exactly what // it was already going to warm. $existingByKey = array(); - foreach ( $existing as $url ) { - $key = UrlCanonicalizer::canonicalize( $url ); + foreach ( $existingKeyed as [ $url, $key ] ) { if ( ! isset( $existingByKey[ $key ] ) ) { $existingByKey[ $key ] = $url; } @@ -820,8 +833,7 @@ public static function mergeUrls( array $existing, array $ordered, string $homeU $result = array(); $seen = array(); - $push = static function ( string $url ) use ( &$result, &$seen ): void { - $key = UrlCanonicalizer::canonicalize( $url ); + $push = static function ( string $url, string $key ) use ( &$result, &$seen ): void { if ( isset( $seen[ $key ] ) ) { return; } @@ -829,22 +841,21 @@ public static function mergeUrls( array $existing, array $ordered, string $homeU $result[] = $url; }; - foreach ( $existing as $url ) { - if ( UrlCanonicalizer::canonicalize( $url ) === $homeKey ) { - $push( $url ); + foreach ( $existingKeyed as [ $url, $key ] ) { + if ( $key === $homeKey ) { + $push( $url, $key ); break; } } - foreach ( $existing as $url ) { - if ( ! isset( $orderedMap[ UrlCanonicalizer::canonicalize( $url ) ] ) ) { - $push( $url ); + foreach ( $existingKeyed as [ $url, $key ] ) { + if ( ! isset( $orderedMap[ $key ] ) ) { + $push( $url, $key ); } } - foreach ( $ordered as $url ) { - $key = UrlCanonicalizer::canonicalize( $url ); - $push( $existingByKey[ $key ] ?? $url ); + foreach ( $orderedKeyed as [ $url, $key ] ) { + $push( $existingByKey[ $key ] ?? $url, $key ); } return $result; From 401e942528ea3f2b41b9bbdb4618bc036b0f49cf Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:36:36 +0200 Subject: [PATCH 17/25] feat(warmup): rescore in place when a menu changes, before the purge --- src/BreezeWarmupSitemap.php | 72 +++++++- .../RescoreOnMenuUpdateTest.php | 168 ++++++++++++++++++ 2 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php diff --git a/src/BreezeWarmupSitemap.php b/src/BreezeWarmupSitemap.php index 406ad8b..6f30efd 100644 --- a/src/BreezeWarmupSitemap.php +++ b/src/BreezeWarmupSitemap.php @@ -109,7 +109,7 @@ final class BreezeWarmupSitemap { * * @return void */ - public static function register(): void { + public static function register( bool $priority = false ): void { if ( self::$registered ) { return; } @@ -122,6 +122,76 @@ public static function register(): void { add_filter( 'breeze_preload_urls', array( self::class, 'filterPreloadUrls' ) ); add_action( self::CRON_HOOK, array( self::class, 'runRefresh' ) ); + + if ( $priority ) { + self::$priority_enabled = true; + // Computed once here, never per purge — the hot path may only + // afford a string comparison against the stored hash. + self::$weights_hash = Scorer::weightsHash( self::weights() ); + + // Priority 5: Breeze's own menu purge and the kit's both sit at + // 10, so the rescore must land before them — the purge they + // trigger then reads an ordering that already reflects the new + // menu. + add_action( 'wp_update_nav_menu', array( self::class, 'rescoreOnMenuUpdate' ), 5 ); + } + } + + /** + * Recompute the ordering from stored signals after a menu changed. + * + * No network: menu membership is the only signal that changed, and + * everything else is already stored. With no stored signals this does + * nothing but schedule a refresh — writing a partial list would be worse + * than leaving the stale one in place. + * + * @return void + */ + public static function rescoreOnMenuUpdate(): void { + if ( ! self::isEnabled() || ! self::$priority_enabled ) { + return; + } + + $stored = PriorityStore::read(); + if ( null === $stored || array() === $stored['signals'] ) { + self::maybeScheduleRefresh(); + + return; + } + + $menu = SignalCollector::menuKeys(); + $weights = self::weights(); + $records = array(); + + foreach ( $stored['signals'] as $key => $signal ) { + if ( ! is_array( $signal ) || ! isset( $signal['url'] ) ) { + continue; + } + + $records[] = array( + 'url' => (string) $signal['url'], + 'key' => (string) $key, + 'lastmod' => isset( $signal['lastmod'] ) ? $signal['lastmod'] : null, + 'type' => (string) ( $signal['type'] ?? '' ), + 'lang' => (string) ( $signal['lang'] ?? '' ), + 'menu' => isset( $menu[ (string) $key ] ), + 'front_page' => (bool) ( $signal['front_page'] ?? false ), + 'manual' => (bool) ( $signal['manual'] ?? false ), + ); + } + + if ( array() === $records ) { + return; + } + + $built = self::buildOrderedUrls( $records, $weights, time(), self::maxUrls() ); + + PriorityStore::write( + $built['urls'], + $built['signals'], + Scorer::weightsHash( $weights ), + $stored['revision'] + ); } /** diff --git a/tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php b/tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php new file mode 100644 index 0000000..e6486dc --- /dev/null +++ b/tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php @@ -0,0 +1,168 @@ +returnArg( 2 ); + Functions\when( 'add_filter' )->justReturn( true ); + Functions\when( 'add_action' )->justReturn( true ); + + BreezeWarmupSitemap::register( true ); + } + + public function test_reorders_from_stored_signals_without_touching_the_network(): void { + $this->enablePriority(); + + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array( 'https://example.test/a/', 'https://example.test/b/' ), + 'signals' => array( + 'https://example.test/a/' => array( + 'lastmod' => null, 'type' => '', 'lang' => 'cs', + 'menu' => false, 'front_page' => false, 'manual' => false, + 'url' => 'https://example.test/a/', + ), + 'https://example.test/b/' => array( + 'lastmod' => null, 'type' => '', 'lang' => 'cs', + 'menu' => false, 'front_page' => false, 'manual' => false, + 'url' => 'https://example.test/b/', + ), + ), + 'fetched_at' => time(), + 'weights_hash' => 'h', + 'revision' => 1, + ) + ); + Functions\when( 'wp_get_nav_menus' )->justReturn( array( (object) array( 'term_id' => 3 ) ) ); + Functions\when( 'wp_get_nav_menu_items' )->justReturn( + array( (object) array( 'url' => 'https://example.test/b/' ) ) + ); + Functions\expect( 'wp_remote_get' )->never(); + + $written = null; + Functions\when( 'update_option' )->alias( + static function ( string $key, $value ) use ( &$written ): bool { + $written = $value; + + return true; + } + ); + + BreezeWarmupSitemap::rescoreOnMenuUpdate(); + + $this->assertSame( 'https://example.test/b/', $written['urls'][0], 'the new menu page leads' ); + } + + public function test_does_nothing_when_signals_are_missing(): void { + $this->enablePriority(); + + // A v1.x payload, or a refresh that has not run yet. Partial data must + // never be written: stale data beats no data, same as runRefresh(). + Functions\when( 'get_option' )->justReturn( + array( 'urls' => array( 'https://example.test/a/' ), 'fetched_at' => time() ) + ); + Functions\when( 'wp_next_scheduled' )->justReturn( false ); + Functions\when( 'get_transient' )->justReturn( false ); + Functions\when( 'set_transient' )->justReturn( true ); + Functions\when( 'wp_schedule_single_event' )->justReturn( true ); + Functions\expect( 'update_option' )->never(); + + BreezeWarmupSitemap::rescoreOnMenuUpdate(); + + $this->addToAssertionCount( 1 ); + } + + public function test_register_true_hooks_the_rescore_at_priority_5(): void { + $actions = array(); + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( 'add_filter' )->justReturn( true ); + Functions\when( 'add_action' )->alias( + function ( string $tag, $callback, int $priority = 10 ) use ( &$actions ) { + $actions[] = array( $tag, $priority ); + + return true; + } + ); + + BreezeWarmupSitemap::register( true ); + + $this->assertContains( array( 'wp_update_nav_menu', 5 ), $actions ); + } + + public function test_register_false_does_not_hook_the_rescore(): void { + $actions = array(); + Functions\when( 'apply_filters' )->returnArg( 2 ); + Functions\when( 'add_filter' )->justReturn( true ); + Functions\when( 'add_action' )->alias( + function ( string $tag ) use ( &$actions ) { + $actions[] = $tag; + + return true; + } + ); + + BreezeWarmupSitemap::register(); + + $this->assertNotContains( 'wp_update_nav_menu', $actions ); + } + + public function test_register_false_leaves_rescore_unreachable_even_with_stored_signals(): void { + // register() defaults to false: priority_enabled stays false, so + // rescoreOnMenuUpdate() must no-op regardless of what is stored. + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array( 'https://example.test/a/' ), + 'signals' => array( + 'https://example.test/a/' => array( + 'lastmod' => null, 'type' => '', 'lang' => 'cs', + 'menu' => false, 'front_page' => false, 'manual' => false, + 'url' => 'https://example.test/a/', + ), + ), + 'fetched_at' => time(), + 'weights_hash' => 'h', + 'revision' => 1, + ) + ); + Functions\expect( 'update_option' )->never(); + Functions\expect( 'wp_remote_get' )->never(); + + BreezeWarmupSitemap::rescoreOnMenuUpdate(); + + $this->addToAssertionCount( 1 ); + } +} From 4ccf4706999955f310d328042baba273da6e7658 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:41:42 +0200 Subject: [PATCH 18/25] feat(starter-base): opt into warmup ordering with declarative weights --- src/BreezeWarmupSitemap.php | 23 +++-- src/StarterBase.php | 38 ++++++++- .../BreezeWarmupPrioritySetupTest.php | 85 +++++++++++++++++++ 3 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php diff --git a/src/BreezeWarmupSitemap.php b/src/BreezeWarmupSitemap.php index 6f30efd..a106009 100644 --- a/src/BreezeWarmupSitemap.php +++ b/src/BreezeWarmupSitemap.php @@ -67,6 +67,9 @@ final class BreezeWarmupSitemap { /** @var string Fingerprint of the effective weights, computed once at registration. */ private static string $weights_hash = ''; + /** @var array|null Effective weight map for this project, set at registration. */ + private static ?array $weights = null; + /** @var string Transient key for the short refresh lock. */ private const LOCK_KEY = 'timber_kit_breeze_warmup_sitemap_refresh_lock'; @@ -107,9 +110,10 @@ final class BreezeWarmupSitemap { * so the class stays self-guarding when used directly, e.g. from tests or * a project that wires it without going through `StarterBase`. * + * @param array|null $weights * @return void */ - public static function register( bool $priority = false ): void { + public static function register( bool $priority = false, ?array $weights = null ): void { if ( self::$registered ) { return; } @@ -118,16 +122,22 @@ public static function register( bool $priority = false ): void { return; } - self::$registered = true; + self::$registered = true; + self::$priority_enabled = $priority; + self::$weights = $weights ?? Scorer::DEFAULT_WEIGHTS; add_filter( 'breeze_preload_urls', array( self::class, 'filterPreloadUrls' ) ); add_action( self::CRON_HOOK, array( self::class, 'runRefresh' ) ); if ( $priority ) { - self::$priority_enabled = true; // Computed once here, never per purge — the hot path may only - // afford a string comparison against the stored hash. - self::$weights_hash = Scorer::weightsHash( self::weights() ); + // afford a string comparison against the stored hash. Uses the + // declared weights directly rather than self::weights(): the + // `timberkit_warmup_priority_weights` filter may not have every + // hook attached yet this early, and if a filter attaches later + // the resulting mismatch against this fingerprint is exactly + // what schedules the refresh that picks it up. + self::$weights_hash = Scorer::weightsHash( self::$weights ); // Priority 5: Breeze's own menu purge and the kit's both sit at // 10, so the rescore must land before them — the purge they @@ -434,7 +444,7 @@ private static function enrichRecords( array $records ): array { * @return array */ public static function weights(): array { - $weights = Scorer::DEFAULT_WEIGHTS; + $weights = self::$weights ?? Scorer::DEFAULT_WEIGHTS; $filtered = function_exists( 'apply_filters' ) ? apply_filters( 'timberkit_warmup_priority_weights', $weights ) @@ -940,5 +950,6 @@ public static function reset_for_tests(): void { self::$registered = false; self::$priority_enabled = false; self::$weights_hash = ''; + self::$weights = null; } } diff --git a/src/StarterBase.php b/src/StarterBase.php index e680cae..b32775c 100644 --- a/src/StarterBase.php +++ b/src/StarterBase.php @@ -558,6 +558,39 @@ class StarterBase extends Site { */ protected bool $breeze_warmup_sitemap = false; + /** + * Order the sitemap-sourced warmup URLs by importance instead of leaving + * them in whatever order the sitemap generator emitted. + * + * Breeze processes its preload queue strictly front to back, so position + * in the array decides how long after a purge a visitor gets a cold page. + * Off by default: it changes which pages get warmed first, and on a site + * whose sitemap exceeds the cap it changes which get warmed at all. + * + * Requires `$breeze_warmup_sitemap` — on its own it has nothing to order. + * + * @var bool + */ + protected bool $breeze_warmup_priority = false; + + /** + * Weights behind `$breeze_warmup_priority`. + * + * Scores add up, so a fresh page in a menu outranks a menu page nobody + * has touched in a year. `freshness` maps a maximum age in days to points + * and reads `` — which is the date of the last *edit*, not of + * publication. + * + * @var array + */ + protected array $breeze_warmup_priority_weights = array( + 'front_page' => 1000, + 'manual' => 800, + 'menu' => 500, + 'types' => array(), + 'freshness' => array( 2 => 300, 7 => 200, 30 => 100, 365 => 25 ), + ); + /** * ACF Datastore ({@see https://www.advancedcustomfields.com/resources/acf-settings-enable_datastore/}). * @@ -1272,7 +1305,10 @@ protected function setup_breeze_warmup_sitemap(): void { return; } - BreezeWarmupSitemap::register(); + BreezeWarmupSitemap::register( + $this->breeze_warmup_priority, + $this->breeze_warmup_priority_weights + ); } /** diff --git a/tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php b/tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php new file mode 100644 index 0000000..f72bc69 --- /dev/null +++ b/tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php @@ -0,0 +1,85 @@ + $actions + * @return void + */ + private function captureActions( array &$actions ): void { + Functions\when( 'add_action' )->alias( + function ( string $tag, $callback = null, int $priority = 10 ) use ( &$actions ) { + $actions[] = array( $tag, $priority ); + + return true; + } + ); + Functions\when( 'add_filter' )->justReturn( true ); + } + + public function test_priority_off_does_not_wire_the_menu_hook(): void { + $actions = array(); + $this->captureActions( $actions ); + + BreezeWarmupSitemap::register( false, null ); + + $this->assertNotContains( 'wp_update_nav_menu', array_column( $actions, 0 ) ); + } + + public function test_priority_on_wires_the_menu_hook_at_priority_five(): void { + // Priority 5 so the rescore lands before both Breeze's own menu purge + // and the kit's, which sit at 10. + $actions = array(); + $this->captureActions( $actions ); + + BreezeWarmupSitemap::register( true, null ); + + $this->assertContains( array( 'wp_update_nav_menu', 5 ), $actions ); + } + + public function test_the_filter_wins_over_the_declared_weights(): void { + Filters\expectApplied( 'timberkit_warmup_priority_weights' ) + ->once() + ->andReturn( array( 'menu' => 42 ) ); + + BreezeWarmupSitemap::register( true, array( 'menu' => 7 ) ); + + $this->assertSame( 42, BreezeWarmupSitemap::weights()['menu'] ); + } +} From f047e5bf039c9f6922fd7e375079175ce53f2796 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:44:18 +0200 Subject: [PATCH 19/25] fix(warmup): hash filtered weights at registration, not raw register() previously fingerprinted self::$weights (unfiltered), while runRefresh() and rescoreOnMenuUpdate() fingerprint self::weights() (filtered). On any project using timberkit_warmup_priority_weights the two hashes could never agree, so weightsChanged() reported a mismatch on every purge and scheduled a needless sitemap refresh forever. Both sides now hash self::weights(). Also drops the over-specified ->once() filter-call-count assertion in test_the_filter_wins_over_the_declared_weights (an implementation detail, not a contract) and adds a test pinning the actual contract: the hash computed at registration must equal the hash a refresh write would store. --- src/BreezeWarmupSitemap.php | 16 ++++++----- .../BreezeWarmupPrioritySetupTest.php | 27 ++++++++++++++++++- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/BreezeWarmupSitemap.php b/src/BreezeWarmupSitemap.php index a106009..366a4d1 100644 --- a/src/BreezeWarmupSitemap.php +++ b/src/BreezeWarmupSitemap.php @@ -131,13 +131,15 @@ public static function register( bool $priority = false, ?array $weights = null if ( $priority ) { // Computed once here, never per purge — the hot path may only - // afford a string comparison against the stored hash. Uses the - // declared weights directly rather than self::weights(): the - // `timberkit_warmup_priority_weights` filter may not have every - // hook attached yet this early, and if a filter attaches later - // the resulting mismatch against this fingerprint is exactly - // what schedules the refresh that picks it up. - self::$weights_hash = Scorer::weightsHash( self::$weights ); + // afford a string comparison against the stored hash. Must be + // built from the FILTERED weights (self::weights()), not the + // raw self::$weights: the hash a write stores (runRefresh(), + // rescoreOnMenuUpdate()) is built the same way, and the two must + // agree — otherwise a project using the + // `timberkit_warmup_priority_weights` filter would see + // weightsChanged() report a mismatch on every single purge, + // scheduling a needless refresh forever. + self::$weights_hash = Scorer::weightsHash( self::weights() ); // Priority 5: Breeze's own menu purge and the kit's both sit at // 10, so the rescore must land before them — the purge they diff --git a/tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php b/tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php index f72bc69..26b0ada 100644 --- a/tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php +++ b/tests/Unit/StarterBase/BreezeWarmupPrioritySetupTest.php @@ -74,12 +74,37 @@ public function test_priority_on_wires_the_menu_hook_at_priority_five(): void { } public function test_the_filter_wins_over_the_declared_weights(): void { + // How many times the filter fires is an implementation detail, not + // part of this contract — only pin what wins. Filters\expectApplied( 'timberkit_warmup_priority_weights' ) - ->once() ->andReturn( array( 'menu' => 42 ) ); BreezeWarmupSitemap::register( true, array( 'menu' => 7 ) ); $this->assertSame( 42, BreezeWarmupSitemap::weights()['menu'] ); } + + /** + * Pins the actual contract behind `weightsChanged()`: the hash computed + * once at registration and the hash a refresh write would store both + * derive from the FILTERED weights. If registration ever hashed the raw, + * unfiltered weights instead, a project using the + * `timberkit_warmup_priority_weights` filter would see these two values + * permanently disagree — `weightsChanged()` would report a mismatch on + * every single purge, scheduling a needless sitemap refresh forever. + */ + public function test_registration_hash_agrees_with_what_a_refresh_write_would_store(): void { + Filters\expectApplied( 'timberkit_warmup_priority_weights' ) + ->andReturn( array( 'menu' => 42 ) ); + + BreezeWarmupSitemap::register( true, array( 'menu' => 7 ) ); + + $reflection = new \ReflectionClass( BreezeWarmupSitemap::class ); + $registeredHash = $reflection->getProperty( 'weights_hash' ); + $registeredHash->setAccessible( true ); + + $writeWouldStore = \Parisek\TimberKit\BreezeWarmup\Scorer::weightsHash( BreezeWarmupSitemap::weights() ); + + $this->assertSame( $writeWouldStore, $registeredHash->getValue() ); + } } From d5948e9fd351d1a0b4b8ba98a0aab067890bb4a5 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:49:00 +0200 Subject: [PATCH 20/25] feat(health): report a stalled Breeze preload chain --- src/Health/Check/PreloadChainHealthy.php | 68 ++++++++++++++++++ src/StarterBase.php | 2 + .../Health/Check/PreloadChainHealthyTest.php | 69 +++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 src/Health/Check/PreloadChainHealthy.php create mode 100644 tests/Unit/Health/Check/PreloadChainHealthyTest.php diff --git a/src/Health/Check/PreloadChainHealthy.php b/src/Health/Check/PreloadChainHealthy.php new file mode 100644 index 0000000..c1f4c8d --- /dev/null +++ b/src/Health/Check/PreloadChainHealthy.php @@ -0,0 +1,68 @@ +gtm_containers && GtmContainer::enabled() ), + new PreloadChainHealthy(), ); } diff --git a/tests/Unit/Health/Check/PreloadChainHealthyTest.php b/tests/Unit/Health/Check/PreloadChainHealthyTest.php new file mode 100644 index 0000000..14b18e7 --- /dev/null +++ b/tests/Unit/Health/Check/PreloadChainHealthyTest.php @@ -0,0 +1,69 @@ +returnArg(); + } + + protected function tearDown(): void { + Monkey\tearDown(); + parent::tearDown(); + } + + public function test_identity(): void { + $check = new PreloadChainHealthy(); + + $this->assertSame( 'preload_chain_healthy', $check->id() ); + $this->assertSame( 'caching', $check->category() ); + $this->assertSame( HealthCheck::METHOD_EFFECT, $check->method() ); + } + + public function test_empty_queue_passes(): void { + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key ? array() : 0 + ); + + $this->assertSame( Result::GOOD, ( new PreloadChainHealthy() )->run()->status() ); + } + + public function test_moving_queue_passes(): void { + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key + ? array( 'https://example.test/a/' ) + : time() - 5 + ); + + $this->assertSame( Result::GOOD, ( new PreloadChainHealthy() )->run()->status() ); + } + + public function test_stalled_queue_fails(): void { + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key + ? array( 'https://example.test/a/', 'https://example.test/b/' ) + : time() - 600 + ); + + $this->assertSame( Result::CRITICAL, ( new PreloadChainHealthy() )->run()->status() ); + } +} From ee8ac72e21c864a5c7730923e7030af359070a74 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:52:00 +0200 Subject: [PATCH 21/25] fix(health): never-warmed preload chain, pin stall boundary, explain skew --- src/Health/Check/PreloadChainHealthy.php | 24 +++++++++++- .../Health/Check/PreloadChainHealthyTest.php | 38 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/Health/Check/PreloadChainHealthy.php b/src/Health/Check/PreloadChainHealthy.php index c1f4c8d..fbd0948 100644 --- a/src/Health/Check/PreloadChainHealthy.php +++ b/src/Health/Check/PreloadChainHealthy.php @@ -36,6 +36,9 @@ public function method(): string { public function run(): Result { $queue = get_option( 'breeze_preload_queue', array() ); + + // An unreadable queue is not evidence of a stalled chain — coerce to + // empty rather than invent a problem this check cannot substantiate. $queue = is_array( $queue ) ? $queue : array(); if ( array() === $queue ) { @@ -43,8 +46,27 @@ public function run(): Result { } $last_warm = (int) get_option( 'breeze_preload_last_warm', 0 ); - $idle = time() - $last_warm; + // A missing/zero timestamp is "never warmed", not "warmed 1.79 billion + // seconds ago" — an elapsed-time figure computed against epoch zero is + // nonsense an admin cannot act on, so it gets its own wording instead. + if ( 0 === $last_warm ) { + return Result::critical( + sprintf( + /* translators: %d: number of URLs waiting to be warmed. */ + __( '%d URL(s) are queued, but the preload chain has never run.', 'timber-kit' ), + count( $queue ) + ), + __( 'The Action Scheduler loopback never completed a warm. Check that the site can reach its own public URL.', 'timber-kit' ) + ); + } + + $idle = time() - $last_warm; + + // A negative $idle means $last_warm is in the future — clock skew, not + // a stalled chain. Reading that as "just warmed" is deliberate: skew + // is not evidence of a fault, and this check must not alarm an + // administrator over something it cannot substantiate. if ( $idle <= self::STALL_AFTER ) { return Result::good( sprintf( diff --git a/tests/Unit/Health/Check/PreloadChainHealthyTest.php b/tests/Unit/Health/Check/PreloadChainHealthyTest.php index 14b18e7..c7384f3 100644 --- a/tests/Unit/Health/Check/PreloadChainHealthyTest.php +++ b/tests/Unit/Health/Check/PreloadChainHealthyTest.php @@ -66,4 +66,42 @@ public function test_stalled_queue_fails(): void { $this->assertSame( Result::CRITICAL, ( new PreloadChainHealthy() )->run()->status() ); } + + public function test_never_warmed_queue_fails_without_a_nonsensical_elapsed_time(): void { + Functions\when( 'get_option' )->alias( + static function ( string $key, mixed $default = false ) { + if ( 'breeze_preload_queue' === $key ) { + return array( 'https://example.test/a/' ); + } + + // breeze_preload_last_warm is absent; get_option falls back to $default. + return $default; + } + ); + + $result = ( new PreloadChainHealthy() )->run(); + + $this->assertSame( Result::CRITICAL, $result->status() ); + $this->assertDoesNotMatchRegularExpression( '/\d{5,}/', $result->summary() ); + } + + public function test_idle_exactly_at_the_stall_boundary_passes(): void { + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key + ? array( 'https://example.test/a/' ) + : time() - 60 + ); + + $this->assertSame( Result::GOOD, ( new PreloadChainHealthy() )->run()->status() ); + } + + public function test_idle_one_second_past_the_stall_boundary_fails(): void { + Functions\when( 'get_option' )->alias( + static fn( string $key ) => 'breeze_preload_queue' === $key + ? array( 'https://example.test/a/' ) + : time() - 61 + ); + + $this->assertSame( Result::CRITICAL, ( new PreloadChainHealthy() )->run()->status() ); + } } From 2eec282acaed5ef455b756f2a5770123ef26574b Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:54:28 +0200 Subject: [PATCH 22/25] test(warmup): pin that ordering is a permutation of its input --- .../Property/BreezeWarmup/ScorerSortTest.php | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/Property/BreezeWarmup/ScorerSortTest.php diff --git a/tests/Property/BreezeWarmup/ScorerSortTest.php b/tests/Property/BreezeWarmup/ScorerSortTest.php new file mode 100644 index 0000000..ffee5bd --- /dev/null +++ b/tests/Property/BreezeWarmup/ScorerSortTest.php @@ -0,0 +1,63 @@ +forAll( Generator\seq( Generator\nat() ) ) + ->then( function ( array $scores ): void { + $records = array(); + foreach ( array_values( $scores ) as $i => $score ) { + $records[] = array( + 'url' => 'https://example.test/' . $i . '/', + 'score' => $score, + ); + } + + $sorted = Scorer::sort( $records ); + + $this->assertCount( count( $records ), $sorted ); + + $in = array_column( $records, 'url' ); + $out = array_column( $sorted, 'url' ); + sort( $in ); + sort( $out ); + $this->assertSame( $in, $out ); + } ); + } + + public function test_sorting_never_increases_score_going_down_the_list(): void { + $this->forAll( Generator\seq( Generator\nat() ) ) + ->then( function ( array $scores ): void { + $records = array(); + foreach ( array_values( $scores ) as $i => $score ) { + $records[] = array( + 'url' => 'https://example.test/' . $i . '/', + 'score' => $score, + ); + } + + $sorted = array_column( Scorer::sort( $records ), 'score' ); + + for ( $i = 1, $n = count( $sorted ); $i < $n; $i++ ) { + $this->assertLessThanOrEqual( $sorted[ $i - 1 ], $sorted[ $i ] ); + } + } ); + } +} From 0a4ee1a8d58b7f3903caa002cd5792f82953de65 Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 15:58:25 +0200 Subject: [PATCH 23/25] docs(warmup): document the ordering, its price and the decision behind it --- CHANGELOG.md | 20 ++++++ README.md | 72 +++++++++++++++++++ ...-warmup-priority-precomputed-at-refresh.md | 64 +++++++++++++++++ docs/adr/README.md | 1 + 4 files changed, 157 insertions(+) create mode 100644 docs/adr/0006-warmup-priority-precomputed-at-refresh.md diff --git a/CHANGELOG.md b/CHANGELOG.md index da3d9d4..3ea6af8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added + +- `$breeze_warmup_priority` — order the Breeze warmup list by importance + (per-language homepages, Breeze's own manual list, menu membership, post + type and `` freshness) instead of leaving it in sitemap order. + Weights are declared in `$breeze_warmup_priority_weights` and filterable via + `timberkit_warmup_priority_weights`. Off by default. +- Site Health check `preload_chain_healthy` — reports a Breeze preload chain + that has stopped making progress. + +### Changed + +- The warmup option row now stores the ordered list, the signals behind it, a + weight fingerprint and a revision counter. A row written by an earlier + version reads as stale and is refreshed; no migration is needed. +- `fetchSitemapUrls()` now deduplicates by canonical URL form rather than + exact string, so two spellings of the same page (differing in trailing + slash, scheme case, default port or fragment) collapse to one, first-seen + spelling winning. + ## [1.38.0] - 2026-08-20 ### Added diff --git a/README.md b/README.md index 371f417..9f76d34 100644 --- a/README.md +++ b/README.md @@ -563,6 +563,78 @@ The class discovers the sitemap (AIOSEO's `/sitemap.xml` when active, otherwise core's `/wp-sitemap.xml`), follows a sitemap index recursively within bounded limits, and merges the result in. +`fetchSitemapUrls()` deduplicates by **canonical URL form**, not by exact +string: two spellings of the same page — differing only in trailing slash, +scheme case, default port, or fragment — collapse to one entry, and the +first-seen spelling wins. Warming the same page twice under two spellings +would waste a slot of the URL cap. + +### Ordering the warmup list by importance + +By default the sitemap-sourced URLs are merged in whatever order the sitemap +generator emitted them, and Breeze warms its queue strictly front to back — +so that order decides how long after a purge a page stays cold. +`$breeze_warmup_priority` (default `false`) replaces it with a computed +ordering, scored during the deferred refresh and stored ready to serve, so +the purge-time filter still pays no cost that grows with the sitemap's size. +Requires `$breeze_warmup_sitemap` — on its own it has nothing to order. + +```php +class Base extends StarterBase { + public function __construct() { + $this->breeze_warmup_sitemap = true; + $this->breeze_warmup_priority = true; + + parent::__construct(); + } +} +``` + +Score is a sum of independent signals, so a fresh page in a menu outranks a +menu page nobody has touched in a year: + +| Signal | Default weight | Notes | +| --- | --- | --- | +| `front_page` | 1000 | A language's homepage. | +| `manual` | 800 | Already in Breeze's own preload list. | +| `menu` | 500 | Linked from a registered nav menu. | +| `types` | `[]` | Per-post-type points, keyed by post type slug. Empty by default. | +| `freshness` | `2 => 300, 7 => 200, 30 => 100, 365 => 25` | Points by `` age in days, ascending buckets. Anything older, missing, or unparseable scores 0. | + +Override the map wholesale in `$breeze_warmup_priority_weights`, or adjust it +per project with the `timberkit_warmup_priority_weights` filter (runs once, +at registration — not per purge). + +Three things worth knowing before this ships to a real sitemap: + +**Freshness reads ``, which is the date of the last edit, not of +publication.** A ten-year-old page with a fixed typo therefore ranks as +fresh. The signal is free — it is already in the XML we parse — and +resolving real publication dates would mean matching URLs back to posts +through WPML and custom permalinks. + +**The cap is not the whole cost.** `timberkit_warmup_sitemap_max_urls` +(default 200) applies only to sitemap-sourced URLs. Entries Breeze itself +supplies are warmed on top of it, and every language's homepage and menu +items are guaranteed even when that pushes the total over the cap — the cap +is soft by design. Budget roughly 200 origin renders and about three and a +half minutes of warming, plus Breeze's own entries, plus any guarantee +overflow. + +**Warming a page nobody visits within the cache TTL (24 hours by default) is +wasted work** — the cache expires before the visitor arrives. A practical way +to size the cap: count how many URLs got at least one pageview yesterday; +that number is the cap. + +### Preload chain health + +The Site Health check `preload_chain_healthy` (category `caching`, needs +`$site_health`) watches Breeze's own preload queue for silent stalls. Breeze +drives that queue through an Action Scheduler loopback; when the loopback +can't reach the site, the queue simply stops advancing and nothing anywhere +reports it. The check flags a queue that hasn't made progress in the last +minute. + --- ## Usage diff --git a/docs/adr/0006-warmup-priority-precomputed-at-refresh.md b/docs/adr/0006-warmup-priority-precomputed-at-refresh.md new file mode 100644 index 0000000..40d80f3 --- /dev/null +++ b/docs/adr/0006-warmup-priority-precomputed-at-refresh.md @@ -0,0 +1,64 @@ +# 0006. Precompute warmup priority at refresh, never at purge + +## Context + +Breeze processes its preload queue strictly front to back, at roughly a +second per URL. Position in the array is the schedule: whatever sits at +index 0 is warm within a second of the purge, and whatever sits near the end +may stay cold for minutes. `BreezeWarmupSitemap` already merges thousands of +sitemap-sourced URLs into that queue, and the merge runs inside the +`breeze_preload_urls` filter — which fires **synchronously inside the purge +request**, the same request an editor is sitting in front of after clicking +Save. + +A sitemap can hold thousands of URLs on a real site. Anything this filter +does costs that editor wall-clock time, once per purge, for as long as the +site exists. That rules out any per-purge step whose cost scales with the +sitemap's size — sorting by a computed score chief among them. + +## Decision + +Ordering is computed once, during the existing deferred refresh job, and +stored as a finished list. The purge-time filter only reads that list and +splices it against Breeze's own entries positionally — homepage, then +Breeze's unscored entries, then the stored ordering — a cost independent of +how many URLs the sitemap holds. + +Two alternatives were rejected: + +- **Sort at purge time.** Cheapest to build, but it is exactly the cost this + decision exists to avoid: an O(n log n) sort of a sitemap-sized list, + paid synchronously by a user waiting on the request. +- **Drop menu membership as a signal.** Menu membership is the strongest + cheap signal of importance available — a human deliberately linked that + page from every page of the site — and it costs one option read during the + refresh. Dropping it to simplify the hot path would have thrown away a free + signal to protect a path that was never going to use it anyway. + +## Consequences + +The ordering is only ever as fresh as the last refresh, not as fresh as the +last purge. That forced three separate invalidation paths, because no single +one covers every way the ordering can go stale: a TTL for the ordinary case +of time passing, a weight fingerprint so a config change (a deploy changing +`$breeze_warmup_priority_weights`, or the `timberkit_warmup_priority_weights` +filter) is noticed without polling, and an in-place rescore on +`wp_update_nav_menu` so a menu edit — the single fastest way to invalidate +the strongest signal — doesn't wait for the TTL to catch up. + +Two writers now share one `wp_options` row: the cron refresh and the +menu-change rescore. WordPress options are last-write-wins, so a slow cron +refresh that started before a menu edit and finishes after it could silently +overwrite the newer ordering with a stale one. A revision counter guards the +case that actually happens in practice — the refresh re-reads the revision +immediately before writing and discards its result if it has moved. It does +not guard two writers landing in the same instant; closing that would need a +conditional `UPDATE` matched against the serialized option value, which is +disproportionate to a race between an hourly cron job and a human saving a +menu. + +The cap became soft. Every language's homepage and every menu item are +guaranteed a slot even when that pushes the total past +`timberkit_warmup_sitemap_max_urls` — a language losing its homepage from the +warmup list is a worse outcome than a cap overrun bounded by the number of +menu items. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7336d7d..4fec9e3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -69,3 +69,4 @@ guard (test, CI check, convention) that keeps it from drifting, if any. - [0003](0003-dev-media-origin-env-and-self-host-guard.md) — Dev-media origin via env, with a self-host guard - [0004](0004-image-downscaling-via-core-threshold.md) — Drive downscaling through core's threshold; never delete originals on upload - [0005](0005-first-party-gtm-container.md) — Load the GTM container from the kit, configured in code +- [0006](0006-warmup-priority-precomputed-at-refresh.md) — Precompute warmup priority at refresh, never at purge From 4fa2d77a7a7fa2d8feb15f5775b587581835af8b Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 16:01:51 +0200 Subject: [PATCH 24/25] docs(warmup): disambiguate TTL, cover future lastmod and per-type weight derivation --- README.md | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9f76d34..9ca3850 100644 --- a/README.md +++ b/README.md @@ -599,12 +599,44 @@ menu page nobody has touched in a year: | `manual` | 800 | Already in Breeze's own preload list. | | `menu` | 500 | Linked from a registered nav menu. | | `types` | `[]` | Per-post-type points, keyed by post type slug. Empty by default. | -| `freshness` | `2 => 300, 7 => 200, 30 => 100, 365 => 25` | Points by `` age in days, ascending buckets. Anything older, missing, or unparseable scores 0. | +| `freshness` | `2 => 300, 7 => 200, 30 => 100, 365 => 25` | Points by `` age in days, ascending buckets. Anything older, missing, unparseable, or **in the future** scores 0. | Override the map wholesale in `$breeze_warmup_priority_weights`, or adjust it per project with the `timberkit_warmup_priority_weights` filter (runs once, at registration — not per purge). +A future `` scores 0 rather than the top freshness bucket: scheduled +content is not fresh content, and a broken `lastmod` must never be able to +shoot a URL to the front of the queue. + +`types` is keyed by post type slug, for example: + +```php +class Base extends StarterBase { + public function __construct() { + $this->breeze_warmup_priority_weights = array( + 'front_page' => 1000, + 'manual' => 800, + 'menu' => 500, + 'types' => array( 'realizace' => 150 ), + 'freshness' => array( 2 => 300, 7 => 200, 30 => 100, 365 => 25 ), + ); + + parent::__construct(); + } +} +``` + +The post type is derived from the **sub-sitemap filename**, not looked up in +the database: `wp-sitemap-posts--N.xml` for core, +`-sitemap.xml` for AIOSEO. A URL whose sub-sitemap doesn't match either +shape gets no type, so its `types` weight is 0. AIOSEO's structural indexes +(`author`, `date`, `product_attributes`, `rss`, `additional`) are excluded on +purpose — they share the `-sitemap.xml` shape but aren't post types. A +taxonomy sitemap can't be told apart from a post-type one by filename either, +so it falls through to weight 0 as well. If a `types` weight seems to have no +effect, check the sub-sitemap's filename first. + Three things worth knowing before this ships to a real sitemap: **Freshness reads ``, which is the date of the last edit, not of @@ -622,9 +654,12 @@ half minutes of warming, plus Breeze's own entries, plus any guarantee overflow. **Warming a page nobody visits within the cache TTL (24 hours by default) is -wasted work** — the cache expires before the visitor arrives. A practical way -to size the cap: count how many URLs got at least one pageview yesterday; -that number is the cap. +wasted work** — the cache expires before the visitor arrives. That 24 hours +is Breeze's own page-cache expiry setting; it is unrelated to this class's +`CACHE_TTL` (one hour), which governs a different clock — how long the +stored URL list itself is trusted before a refresh is scheduled. A practical +way to size the cap: count how many URLs got at least one pageview +yesterday; that number is the cap. ### Preload chain health From d68b6e28e626a1ffc3af41f2f3c5c626e6d17cdd Mon Sep 17 00:00:00 2001 From: Petr Parimucha Date: Mon, 24 Aug 2026 16:07:57 +0200 Subject: [PATCH 25/25] fix(breeze-warmup): swallow exceptions in the menu-save rescore hook rescoreOnMenuUpdate() runs synchronously in wp_update_nav_menu at priority 5, so an exception from any of its collaborators would fatal the editor's Save request. Wrap it the same way runRefresh() already is, minus the finally: this path holds no lock. Also document that the timberkit_warmup_priority_weights filter must be pure, since its result is fingerprinted across requests to detect staleness. --- src/BreezeWarmupSitemap.php | 78 +++++++++++-------- .../RescoreOnMenuUpdateTest.php | 30 +++++++ 2 files changed, 75 insertions(+), 33 deletions(-) diff --git a/src/BreezeWarmupSitemap.php b/src/BreezeWarmupSitemap.php index 366a4d1..bf14b0e 100644 --- a/src/BreezeWarmupSitemap.php +++ b/src/BreezeWarmupSitemap.php @@ -164,46 +164,52 @@ public static function rescoreOnMenuUpdate(): void { return; } - $stored = PriorityStore::read(); - if ( null === $stored || array() === $stored['signals'] ) { - self::maybeScheduleRefresh(); + try { + $stored = PriorityStore::read(); + if ( null === $stored || array() === $stored['signals'] ) { + self::maybeScheduleRefresh(); - return; - } + return; + } - $menu = SignalCollector::menuKeys(); - $weights = self::weights(); - $records = array(); + $menu = SignalCollector::menuKeys(); + $weights = self::weights(); + $records = array(); + + foreach ( $stored['signals'] as $key => $signal ) { + if ( ! is_array( $signal ) || ! isset( $signal['url'] ) ) { + continue; + } + + $records[] = array( + 'url' => (string) $signal['url'], + 'key' => (string) $key, + 'lastmod' => isset( $signal['lastmod'] ) ? $signal['lastmod'] : null, + 'type' => (string) ( $signal['type'] ?? '' ), + 'lang' => (string) ( $signal['lang'] ?? '' ), + 'menu' => isset( $menu[ (string) $key ] ), + 'front_page' => (bool) ( $signal['front_page'] ?? false ), + 'manual' => (bool) ( $signal['manual'] ?? false ), + ); + } - foreach ( $stored['signals'] as $key => $signal ) { - if ( ! is_array( $signal ) || ! isset( $signal['url'] ) ) { - continue; + if ( array() === $records ) { + return; } - $records[] = array( - 'url' => (string) $signal['url'], - 'key' => (string) $key, - 'lastmod' => isset( $signal['lastmod'] ) ? $signal['lastmod'] : null, - 'type' => (string) ( $signal['type'] ?? '' ), - 'lang' => (string) ( $signal['lang'] ?? '' ), - 'menu' => isset( $menu[ (string) $key ] ), - 'front_page' => (bool) ( $signal['front_page'] ?? false ), - 'manual' => (bool) ( $signal['manual'] ?? false ), - ); - } + $built = self::buildOrderedUrls( $records, $weights, time(), self::maxUrls() ); - if ( array() === $records ) { - return; + PriorityStore::write( + $built['urls'], + $built['signals'], + Scorer::weightsHash( $weights ), + $stored['revision'] + ); + } catch ( \Throwable $e ) { + // Best-effort by contract: this runs synchronously inside the + // editor's Save request, and a failure here must never surface + // as a fatal in that request. } - - $built = self::buildOrderedUrls( $records, $weights, time(), self::maxUrls() ); - - PriorityStore::write( - $built['urls'], - $built['signals'], - Scorer::weightsHash( $weights ), - $stored['revision'] - ); } /** @@ -443,6 +449,12 @@ private static function enrichRecords( array $records ): array { /** * Effective weight map: the defaults, filterable per project. * + * The `timberkit_warmup_priority_weights` filter must be a pure function + * of its input: its result is fingerprinted and that fingerprint is + * compared across requests to decide whether the stored ordering is + * stale. A callback that varies between requests (reading mutable state + * such as an option that changes) will schedule a refresh on every purge. + * * @return array */ public static function weights(): array { diff --git a/tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php b/tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php index e6486dc..7c6ad5f 100644 --- a/tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php +++ b/tests/Unit/BreezeWarmupSitemap/RescoreOnMenuUpdateTest.php @@ -140,6 +140,36 @@ function ( string $tag ) use ( &$actions ) { $this->assertNotContains( 'wp_update_nav_menu', $actions ); } + public function test_a_throwing_collaborator_never_escapes_the_hook(): void { + $this->enablePriority(); + + Functions\when( 'get_option' )->justReturn( + array( + 'urls' => array( 'https://example.test/a/' ), + 'signals' => array( + 'https://example.test/a/' => array( + 'lastmod' => null, 'type' => '', 'lang' => 'cs', + 'menu' => false, 'front_page' => false, 'manual' => false, + 'url' => 'https://example.test/a/', + ), + ), + 'fetched_at' => time(), + 'weights_hash' => 'h', + 'revision' => 1, + ) + ); + Functions\when( 'wp_get_nav_menus' )->justReturn( array( (object) array( 'term_id' => 3 ) ) ); + Functions\when( 'wp_get_nav_menu_items' )->alias( + static function () { + throw new \RuntimeException( 'nav menu lookup exploded' ); + } + ); + + BreezeWarmupSitemap::rescoreOnMenuUpdate(); + + $this->addToAssertionCount( 1 ); + } + public function test_register_false_leaves_rescore_unreachable_even_with_stored_signals(): void { // register() defaults to false: priority_enabled stays false, so // rescoreOnMenuUpdate() must no-op regardless of what is stored.