PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.0-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.0-beta
16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / modules / sitemaps / abilities / class-sitemaps-abilities.php
jetpack / modules / sitemaps / abilities Last commit date
class-sitemaps-abilities.php 1 month ago
class-sitemaps-abilities.php
456 lines
1 <?php
2 /**
3 * Jetpack Sitemaps Abilities Registration
4 *
5 * Registers Jetpack Sitemaps abilities with the WordPress Abilities API.
6 *
7 * @package automattic/jetpack
8 */
9
10 // @phan-file-suppress PhanUndeclaredFunction, PhanUndeclaredClassMethod @phan-suppress-current-line UnusedSuppression -- Abilities API added in WP 6.9; suppressions needed for older-WP compatibility runs.
11
12 namespace Automattic\Jetpack\Plugin\Abilities;
13
14 use Automattic\Jetpack\WP_Abilities\Registrar;
15 use Jetpack;
16
17 /**
18 * Registers Jetpack Sitemaps abilities with the WordPress Abilities API.
19 *
20 * Exposes a zero-arg sitemap status read (`get-status`) and a zero-arg rebuild
21 * dispatch (`request-rebuild`) so AI agents can inspect sitemap freshness and
22 * trigger a regeneration through the standard `wp-abilities/v1` REST surface.
23 *
24 * Both abilities only register while the Sitemaps module is active — the
25 * surrounding `modules/sitemaps.php` is only loaded by Jetpack when the module
26 * is on, so the `Sitemaps_Abilities::init()` call at the bottom of that file
27 * is the gate.
28 */
29 class Sitemaps_Abilities extends Registrar {
30
31 private const MODULE_SLUG = 'sitemaps';
32
33 /**
34 * Cron hook name used by the Sitemaps module to drive incremental builds.
35 *
36 * Kept as a const here rather than imported from `Jetpack_Sitemap_Manager`
37 * because the manager registers it as an action name only — there is no
38 * canonical PHP constant to reference, and the value is part of the
39 * module's stable public surface (it shows up in `wp cron list`).
40 */
41 private const CRON_HOOK = 'jp_sitemap_cron_hook';
42
43 /**
44 * Transient written by `Jetpack_Sitemap_State::check_out()` while a build
45 * step is in progress. Presence of this transient is the canonical
46 * "build currently running" signal; its 15-minute TTL means the signal
47 * self-clears if a build crashes without unlocking.
48 */
49 private const STATE_LOCK_TRANSIENT = 'jetpack-sitemap-state-lock';
50
51 /**
52 * {@inheritDoc}
53 *
54 * Sitemaps abilities live under the WordPress core `site` category — it is
55 * registered by the Abilities API itself, so we reference it by slug and
56 * never register it ourselves (see the no-op `register_category()` below).
57 */
58 public static function get_category_slug(): string {
59 return 'site';
60 }
61
62 /**
63 * {@inheritDoc}
64 *
65 * Unused: the `site` category is owned by WordPress core, so
66 * `register_category()` is a no-op and this definition is never passed to
67 * `wp_register_ability_category()`. It remains only to satisfy the abstract
68 * Registrar contract.
69 */
70 public static function get_category_definition(): array {
71 return array();
72 }
73
74 /**
75 * No-op: the `site` ability category is registered by the WordPress core
76 * Abilities API. Re-registering it here would clobber the core definition,
77 * so this registrar only references the category by slug.
78 *
79 * @return void
80 */
81 public static function register_category() {}
82
83 /**
84 * {@inheritDoc}
85 */
86 public static function get_abilities(): array {
87 return array(
88 'jetpack-sitemaps/get-status' => array(
89 'label' => __( 'Get Jetpack Sitemaps status', 'jetpack' ),
90 'description' => __( 'Return the current state of the Jetpack-generated XML sitemaps as { active, url, post_count, page_count, news_sitemap_enabled, sitemaps }. `active` reflects whether the Sitemaps module is on. `url` is the public sitemap.xml entry point. `post_count` / `page_count` are the published `post` / `page` counts (the same baseline the WordPress core sitemap uses). `news_sitemap_enabled` reflects the `jetpack_news_sitemap_include_in_robotstxt` filter (default true). `sitemaps` is the list of child sitemaps actually present in the served sitemap.xml index — each entry is `{ loc, lastmod }`, where `lastmod` is the W3C datetime string the sitemap exposes (or null when that entry omits one). `sitemaps` is an empty array until a master sitemap has been generated. These abilities are only registered while the Sitemaps module is active; if they are absent from wp_get_abilities(), activate the Sitemaps module first.', 'jetpack' ),
91 'input_schema' => array(
92 'type' => 'object',
93 'additionalProperties' => false,
94 ),
95 'output_schema' => array(
96 'type' => 'object',
97 'properties' => array(
98 'active' => array( 'type' => 'boolean' ),
99 'url' => array( 'type' => 'string' ),
100 'post_count' => array( 'type' => 'integer' ),
101 'page_count' => array( 'type' => 'integer' ),
102 'news_sitemap_enabled' => array( 'type' => 'boolean' ),
103 'sitemaps' => array(
104 'type' => 'array',
105 'items' => array(
106 'type' => 'object',
107 'properties' => array(
108 'loc' => array( 'type' => 'string' ),
109 'lastmod' => array( 'type' => array( 'string', 'null' ) ),
110 ),
111 ),
112 ),
113 ),
114 ),
115 'execute_callback' => array( __CLASS__, 'get_status' ),
116 'permission_callback' => array( __CLASS__, 'can_view_sitemaps' ),
117 'meta' => array(
118 'annotations' => array(
119 'readonly' => true,
120 'destructive' => false,
121 'idempotent' => true,
122 ),
123 'show_in_rest' => true,
124 'mcp' => array(
125 'public' => true,
126 'type' => 'tool', // default is already "tool", but can be explicit.
127 ),
128 ),
129 ),
130
131 'jetpack-sitemaps/request-rebuild' => array(
132 'label' => __( 'Request a Jetpack Sitemaps rebuild', 'jetpack' ),
133 'description' => __( 'Dispatch a full sitemap regeneration by scheduling the existing `jp_sitemap_cron_hook` cron event. Returns { dispatched, status, next_scheduled_at } where status is one of "queued" (a single-event cron tick was just scheduled), "running" (a build is already in flight per the `jetpack-sitemap-state-lock` transient), or "already_running" (alias of "running"; surfaced so callers can branch on either spelling). `next_scheduled_at` is the next `jp_sitemap_cron_hook` tick as an ISO 8601 UTC string with an explicit `Z` zone designator (e.g. `2026-05-19T19:33:20Z`), or null when nothing is scheduled (e.g. status=running with no future tick queued) — it tells the caller when the build they queued (or the one already pending) will actually run. Idempotent — calling this while a build is already in flight or already queued returns dispatched=false and the matching status rather than stacking duplicate cron events.', 'jetpack' ),
134 'input_schema' => array(
135 'type' => 'object',
136 'additionalProperties' => false,
137 ),
138 'output_schema' => array(
139 'type' => 'object',
140 'properties' => array(
141 'dispatched' => array( 'type' => 'boolean' ),
142 'status' => array(
143 'type' => 'string',
144 'enum' => array( 'queued', 'running', 'already_running' ),
145 ),
146 'next_scheduled_at' => array( 'type' => array( 'string', 'null' ) ),
147 ),
148 ),
149 'execute_callback' => array( __CLASS__, 'request_rebuild' ),
150 'permission_callback' => array( __CLASS__, 'can_manage_sitemaps' ),
151 'meta' => array(
152 'annotations' => array(
153 'readonly' => false,
154 'destructive' => false,
155 'idempotent' => true,
156 ),
157 'show_in_rest' => true,
158 'mcp' => array(
159 'public' => true,
160 'type' => 'tool', // default is already "tool", but can be explicit.
161 ),
162 ),
163 ),
164 );
165 }
166
167 /**
168 * Permission check: can the current user read sitemap status?
169 *
170 * Sitemap status is metadata about publicly-served XML — anyone who can
171 * manage content (`edit_posts`) is allowed to see it. Reads do not modify
172 * state and do not expose secrets.
173 */
174 public static function can_view_sitemaps(): bool {
175 return current_user_can( 'edit_posts' );
176 }
177
178 /**
179 * Permission check: can the current user dispatch a sitemap rebuild?
180 *
181 * Rebuild scheduling writes to cron + transient state and can run for
182 * minutes on large sites, so it is gated on `manage_options` (admin only).
183 */
184 public static function can_manage_sitemaps(): bool {
185 return current_user_can( 'manage_options' );
186 }
187
188 /**
189 * Execute: status read.
190 *
191 * Surfaces an opinionated, agent-friendly projection of the module's state:
192 * - `active` from `Jetpack::is_module_active`.
193 * - `url` from `jetpack_sitemap_uri()`, the same helper the public sitemap
194 * router uses.
195 * - `post_count` / `page_count` from `wp_count_posts()->publish`, the
196 * same baseline used by the WordPress core sitemap. Cheap; no joins.
197 * - `news_sitemap_enabled` from the `jetpack_news_sitemap_include_in_robotstxt`
198 * filter (the same filter that controls news-sitemap robots.txt inclusion).
199 * - `sitemaps` from the served master sitemap document itself (see
200 * `get_sitemap_entries()`) — the real child-sitemap list with each
201 * entry's own `lastmod`, rather than a synthetic last-build timestamp.
202 *
203 * @param array|null $input Ability input (no parameters accepted).
204 * @return array
205 */
206 public static function get_status( $input = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Abilities API contract requires execute callbacks to accept the input array even when the schema declares no parameters.
207 return array(
208 'active' => Jetpack::is_module_active( self::MODULE_SLUG ),
209 'url' => static::get_master_sitemap_url(),
210 'post_count' => static::count_published( 'post' ),
211 'page_count' => static::count_published( 'page' ),
212 'news_sitemap_enabled' => static::is_news_sitemap_enabled(),
213 'sitemaps' => static::get_sitemap_entries(),
214 );
215 }
216
217 /**
218 * Execute: rebuild dispatch.
219 *
220 * Three-state idempotent dispatch:
221 *
222 * 1. If the state lock transient is set, a build step is currently
223 * running. Return `dispatched=false`, `status=running`. We also surface
224 * `already_running` as the alias the plan documents; this function
225 * returns `running` as the canonical value so callers that branch on
226 * one or the other both work — the output_schema enum permits both.
227 * 2. Else if a cron event is already scheduled in the future for our hook,
228 * a build is queued. Return `dispatched=false`, `status=queued`.
229 * 3. Otherwise schedule a single-event cron tick to fire immediately and
230 * return `dispatched=true`, `status=queued`.
231 *
232 * Every branch also returns `next_scheduled_at` (see
233 * `get_next_scheduled_at()`) so the caller learns when the queued/pending
234 * build will actually run without a follow-up status read.
235 *
236 * @param array|null $input Ability input (no parameters accepted).
237 * @return array
238 */
239 public static function request_rebuild( $input = null ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Abilities API contract requires execute callbacks to accept the input array even when the schema declares no parameters.
240 if ( static::is_build_running() ) {
241 return array(
242 'dispatched' => false,
243 'status' => 'running',
244 'next_scheduled_at' => static::get_next_scheduled_at(),
245 );
246 }
247
248 if ( static::is_build_queued() ) {
249 return array(
250 'dispatched' => false,
251 'status' => 'queued',
252 'next_scheduled_at' => static::get_next_scheduled_at(),
253 );
254 }
255
256 static::schedule_rebuild();
257
258 return array(
259 'dispatched' => true,
260 'status' => 'queued',
261 'next_scheduled_at' => static::get_next_scheduled_at(),
262 );
263 }
264
265 /**
266 * Public sitemap URL for the master sitemap.
267 *
268 * Extracted as a protected seam so tests can override without booting the
269 * rewrite/permalink stack.
270 */
271 protected static function get_master_sitemap_url(): string {
272 if ( function_exists( 'jetpack_sitemap_uri' ) ) {
273 return (string) jetpack_sitemap_uri( 'sitemap.xml' );
274 }
275 // Defensive: when the sitemaps module file is loaded the helper
276 // exists. This branch only runs if a caller invokes the ability
277 // outside the normal bootstrap path.
278 return (string) home_url( '/sitemap.xml' );
279 }
280
281 /**
282 * Raw master-sitemap XML — the exact document the public `sitemap.xml`
283 * router serves, read straight from storage via the librarian (no HTTP
284 * loopback). Returns an empty string when no master sitemap has been
285 * generated yet, or when the Sitemaps module helpers are unavailable.
286 *
287 * Extracted as a protected seam so tests can feed a known document without
288 * a librarian / wp_posts.
289 */
290 protected static function get_master_sitemap_xml(): string {
291 if (
292 ! class_exists( 'Jetpack_Sitemap_Librarian' )
293 || ! function_exists( 'jp_sitemap_filename' )
294 || ! defined( 'JP_MASTER_SITEMAP_TYPE' )
295 ) {
296 return '';
297 }
298
299 $librarian = new \Jetpack_Sitemap_Librarian();
300
301 // jp_sitemap_filename() is documented `@param string $number`; for the
302 // master type it returns 'sitemap.xml' and ignores the number, but it
303 // must be non-null and string-typed to satisfy the contract (the
304 // int-`0` router call site predates this and is Phan-baselined).
305 return (string) $librarian->get_sitemap_text(
306 \jp_sitemap_filename( JP_MASTER_SITEMAP_TYPE, '0' ),
307 JP_MASTER_SITEMAP_TYPE
308 );
309 }
310
311 /**
312 * The child-sitemap entries actually present in the served master
313 * sitemap, as a list of `[ 'loc' => string, 'lastmod' => string|null ]`.
314 *
315 * Parses the same `<sitemapindex>` document `sitemap.xml` serves rather
316 * than deriving freshness from the `jetpack-sitemap-state` option: that
317 * option can read its initial/reset shape (no `max` projection) even while
318 * a fully-built sitemap.xml is being served, so it is not a reliable
319 * "what does the sitemap actually contain" source.
320 *
321 * Returns an empty array when no master sitemap exists yet or the stored
322 * document does not parse.
323 *
324 * @return array<int, array{loc:string, lastmod:string|null}>
325 */
326 protected static function get_sitemap_entries(): array {
327 $xml = static::get_master_sitemap_xml();
328 if ( '' === $xml ) {
329 return array();
330 }
331
332 $previous = libxml_use_internal_errors( true );
333 $document = new \DOMDocument();
334 // Source is Jetpack's own stored sitemap (not user input) and PHP 8+
335 // disables external-entity loading by default; LIBXML_NONET is belt-
336 // and-suspenders against any network/entity fetch during parsing.
337 $loaded = $document->loadXML( $xml, LIBXML_NONET );
338 libxml_clear_errors();
339 libxml_use_internal_errors( $previous );
340
341 if ( ! $loaded ) {
342 return array();
343 }
344
345 $entries = array();
346 foreach ( $document->getElementsByTagName( 'sitemap' ) as $sitemap_node ) {
347 $loc_nodes = $sitemap_node->getElementsByTagName( 'loc' );
348 if ( 0 === $loc_nodes->length ) {
349 continue;
350 }
351
352 $loc = trim( $loc_nodes->item( 0 )->textContent );
353 if ( '' === $loc ) {
354 continue;
355 }
356
357 $lastmod_nodes = $sitemap_node->getElementsByTagName( 'lastmod' );
358 $lastmod = $lastmod_nodes->length > 0
359 ? trim( $lastmod_nodes->item( 0 )->textContent )
360 : '';
361
362 $entries[] = array(
363 'loc' => $loc,
364 'lastmod' => '' === $lastmod ? null : $lastmod,
365 );
366 }
367
368 return $entries;
369 }
370
371 /**
372 * Whether news-sitemap inclusion is enabled.
373 *
374 * Mirrors the filter chain in `Jetpack_Sitemap_Manager::callback_action_do_robotstxt`
375 * but only resolves the modern filter — the deprecated 7.4.0 alias is
376 * already merged into the modern filter by the time it runs in production.
377 */
378 protected static function is_news_sitemap_enabled(): bool {
379 /** This filter is documented in modules/sitemaps/sitemaps.php */
380 return (bool) apply_filters( 'jetpack_news_sitemap_include_in_robotstxt', true );
381 }
382
383 /**
384 * Count published posts of a given post type.
385 *
386 * Wraps `wp_count_posts()` so tests can override without a real WP_Posts
387 * factory.
388 *
389 * @param string $post_type Post type slug.
390 */
391 protected static function count_published( string $post_type ): int {
392 $counts = wp_count_posts( $post_type );
393 if ( ! is_object( $counts ) || ! isset( $counts->publish ) ) {
394 return 0;
395 }
396 return (int) $counts->publish;
397 }
398
399 /**
400 * Whether a sitemap build step is currently running.
401 *
402 * The Sitemaps module sets a 15-minute transient lock at the start of
403 * `Jetpack_Sitemap_State::check_out()` and deletes it on `unlock()` /
404 * `reset()`. Presence of the transient is the canonical "in flight" signal.
405 */
406 protected static function is_build_running(): bool {
407 return true === get_transient( self::STATE_LOCK_TRANSIENT );
408 }
409
410 /**
411 * Whether a sitemap build is already scheduled for a future cron tick.
412 *
413 * Uses `wp_next_scheduled` so we don't stack duplicate single-event cron
414 * entries when the recurring `jp_sitemap_cron_hook` is already pending.
415 */
416 protected static function is_build_queued(): bool {
417 return false !== wp_next_scheduled( self::CRON_HOOK );
418 }
419
420 /**
421 * Schedule a single-event cron tick to drive the next build step.
422 *
423 * Matches the dispatch pattern used by
424 * `Jetpack_Sitemap_Manager::callback_action_purge_data` — `wp_schedule_single_event`
425 * with an immediate execution time. The recurring `sitemap-interval`
426 * schedule still fires on its normal cadence; this just front-runs the
427 * next tick.
428 */
429 protected static function schedule_rebuild(): void {
430 wp_schedule_single_event( time(), self::CRON_HOOK );
431 }
432
433 /**
434 * When the next `jp_sitemap_cron_hook` build tick is scheduled, as an
435 * ISO 8601 UTC string (e.g. `2026-05-19T19:33:20Z`), or null when nothing
436 * is scheduled.
437 *
438 * Returned alongside the dispatch result so callers immediately know when
439 * the build they queued (or the one already pending) will actually run,
440 * without a second round-trip. Null in the `running` case when the lock is
441 * held but no future tick is queued.
442 *
443 * ISO 8601 with the explicit `Z` zone designator (not `human_time_diff()`,
444 * not a bare "Y-m-d H:i:s") so the timezone is unambiguous and the value is
445 * locale-stable and machine-parseable — the same format the `sitemaps[]`
446 * `lastmod` values use in `get-status`.
447 */
448 protected static function get_next_scheduled_at(): ?string {
449 $timestamp = wp_next_scheduled( self::CRON_HOOK );
450 if ( false === $timestamp ) {
451 return null;
452 }
453 return gmdate( 'Y-m-d\TH:i:s\Z', $timestamp );
454 }
455 }
456