PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 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 week ago
class-sitemaps-abilities.php
454 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 namespace Automattic\Jetpack\Plugin\Abilities;
11
12 use Automattic\Jetpack\WP_Abilities\Registrar;
13 use Jetpack;
14
15 /**
16 * Registers Jetpack Sitemaps abilities with the WordPress Abilities API.
17 *
18 * Exposes a zero-arg sitemap status read (`get-status`) and a zero-arg rebuild
19 * dispatch (`request-rebuild`) so AI agents can inspect sitemap freshness and
20 * trigger a regeneration through the standard `wp-abilities/v1` REST surface.
21 *
22 * Both abilities only register while the Sitemaps module is active — the
23 * surrounding `modules/sitemaps.php` is only loaded by Jetpack when the module
24 * is on, so the `Sitemaps_Abilities::init()` call at the bottom of that file
25 * is the gate.
26 */
27 class Sitemaps_Abilities extends Registrar {
28
29 private const MODULE_SLUG = 'sitemaps';
30
31 /**
32 * Cron hook name used by the Sitemaps module to drive incremental builds.
33 *
34 * Kept as a const here rather than imported from `Jetpack_Sitemap_Manager`
35 * because the manager registers it as an action name only — there is no
36 * canonical PHP constant to reference, and the value is part of the
37 * module's stable public surface (it shows up in `wp cron list`).
38 */
39 private const CRON_HOOK = 'jp_sitemap_cron_hook';
40
41 /**
42 * Transient written by `Jetpack_Sitemap_State::check_out()` while a build
43 * step is in progress. Presence of this transient is the canonical
44 * "build currently running" signal; its 15-minute TTL means the signal
45 * self-clears if a build crashes without unlocking.
46 */
47 private const STATE_LOCK_TRANSIENT = 'jetpack-sitemap-state-lock';
48
49 /**
50 * {@inheritDoc}
51 *
52 * Sitemaps abilities live under the WordPress core `site` category — it is
53 * registered by the Abilities API itself, so we reference it by slug and
54 * never register it ourselves (see the no-op `register_category()` below).
55 */
56 public static function get_category_slug(): string {
57 return 'site';
58 }
59
60 /**
61 * {@inheritDoc}
62 *
63 * Unused: the `site` category is owned by WordPress core, so
64 * `register_category()` is a no-op and this definition is never passed to
65 * `wp_register_ability_category()`. It remains only to satisfy the abstract
66 * Registrar contract.
67 */
68 public static function get_category_definition(): array {
69 return array();
70 }
71
72 /**
73 * No-op: the `site` ability category is registered by the WordPress core
74 * Abilities API. Re-registering it here would clobber the core definition,
75 * so this registrar only references the category by slug.
76 *
77 * @return void
78 */
79 public static function register_category() {}
80
81 /**
82 * {@inheritDoc}
83 */
84 public static function get_abilities(): array {
85 return array(
86 'jetpack-sitemaps/get-status' => array(
87 'label' => __( 'Get Jetpack Sitemaps status', 'jetpack' ),
88 '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' ),
89 'input_schema' => array(
90 'type' => 'object',
91 'additionalProperties' => false,
92 ),
93 'output_schema' => array(
94 'type' => 'object',
95 'properties' => array(
96 'active' => array( 'type' => 'boolean' ),
97 'url' => array( 'type' => 'string' ),
98 'post_count' => array( 'type' => 'integer' ),
99 'page_count' => array( 'type' => 'integer' ),
100 'news_sitemap_enabled' => array( 'type' => 'boolean' ),
101 'sitemaps' => array(
102 'type' => 'array',
103 'items' => array(
104 'type' => 'object',
105 'properties' => array(
106 'loc' => array( 'type' => 'string' ),
107 'lastmod' => array( 'type' => array( 'string', 'null' ) ),
108 ),
109 ),
110 ),
111 ),
112 ),
113 'execute_callback' => array( __CLASS__, 'get_status' ),
114 'permission_callback' => array( __CLASS__, 'can_view_sitemaps' ),
115 'meta' => array(
116 'annotations' => array(
117 'readonly' => true,
118 'destructive' => false,
119 'idempotent' => true,
120 ),
121 'show_in_rest' => true,
122 'mcp' => array(
123 'public' => true,
124 'type' => 'tool', // default is already "tool", but can be explicit.
125 ),
126 ),
127 ),
128
129 'jetpack-sitemaps/request-rebuild' => array(
130 'label' => __( 'Request a Jetpack Sitemaps rebuild', 'jetpack' ),
131 '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' ),
132 'input_schema' => array(
133 'type' => 'object',
134 'additionalProperties' => false,
135 ),
136 'output_schema' => array(
137 'type' => 'object',
138 'properties' => array(
139 'dispatched' => array( 'type' => 'boolean' ),
140 'status' => array(
141 'type' => 'string',
142 'enum' => array( 'queued', 'running', 'already_running' ),
143 ),
144 'next_scheduled_at' => array( 'type' => array( 'string', 'null' ) ),
145 ),
146 ),
147 'execute_callback' => array( __CLASS__, 'request_rebuild' ),
148 'permission_callback' => array( __CLASS__, 'can_manage_sitemaps' ),
149 'meta' => array(
150 'annotations' => array(
151 'readonly' => false,
152 'destructive' => false,
153 'idempotent' => true,
154 ),
155 'show_in_rest' => true,
156 'mcp' => array(
157 'public' => true,
158 'type' => 'tool', // default is already "tool", but can be explicit.
159 ),
160 ),
161 ),
162 );
163 }
164
165 /**
166 * Permission check: can the current user read sitemap status?
167 *
168 * Sitemap status is metadata about publicly-served XML — anyone who can
169 * manage content (`edit_posts`) is allowed to see it. Reads do not modify
170 * state and do not expose secrets.
171 */
172 public static function can_view_sitemaps(): bool {
173 return current_user_can( 'edit_posts' );
174 }
175
176 /**
177 * Permission check: can the current user dispatch a sitemap rebuild?
178 *
179 * Rebuild scheduling writes to cron + transient state and can run for
180 * minutes on large sites, so it is gated on `manage_options` (admin only).
181 */
182 public static function can_manage_sitemaps(): bool {
183 return current_user_can( 'manage_options' );
184 }
185
186 /**
187 * Execute: status read.
188 *
189 * Surfaces an opinionated, agent-friendly projection of the module's state:
190 * - `active` from `Jetpack::is_module_active`.
191 * - `url` from `jetpack_sitemap_uri()`, the same helper the public sitemap
192 * router uses.
193 * - `post_count` / `page_count` from `wp_count_posts()->publish`, the
194 * same baseline used by the WordPress core sitemap. Cheap; no joins.
195 * - `news_sitemap_enabled` from the `jetpack_news_sitemap_include_in_robotstxt`
196 * filter (the same filter that controls news-sitemap robots.txt inclusion).
197 * - `sitemaps` from the served master sitemap document itself (see
198 * `get_sitemap_entries()`) — the real child-sitemap list with each
199 * entry's own `lastmod`, rather than a synthetic last-build timestamp.
200 *
201 * @param array|null $input Ability input (no parameters accepted).
202 * @return array
203 */
204 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.
205 return array(
206 'active' => Jetpack::is_module_active( self::MODULE_SLUG ),
207 'url' => static::get_master_sitemap_url(),
208 'post_count' => static::count_published( 'post' ),
209 'page_count' => static::count_published( 'page' ),
210 'news_sitemap_enabled' => static::is_news_sitemap_enabled(),
211 'sitemaps' => static::get_sitemap_entries(),
212 );
213 }
214
215 /**
216 * Execute: rebuild dispatch.
217 *
218 * Three-state idempotent dispatch:
219 *
220 * 1. If the state lock transient is set, a build step is currently
221 * running. Return `dispatched=false`, `status=running`. We also surface
222 * `already_running` as the alias the plan documents; this function
223 * returns `running` as the canonical value so callers that branch on
224 * one or the other both work — the output_schema enum permits both.
225 * 2. Else if a cron event is already scheduled in the future for our hook,
226 * a build is queued. Return `dispatched=false`, `status=queued`.
227 * 3. Otherwise schedule a single-event cron tick to fire immediately and
228 * return `dispatched=true`, `status=queued`.
229 *
230 * Every branch also returns `next_scheduled_at` (see
231 * `get_next_scheduled_at()`) so the caller learns when the queued/pending
232 * build will actually run without a follow-up status read.
233 *
234 * @param array|null $input Ability input (no parameters accepted).
235 * @return array
236 */
237 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.
238 if ( static::is_build_running() ) {
239 return array(
240 'dispatched' => false,
241 'status' => 'running',
242 'next_scheduled_at' => static::get_next_scheduled_at(),
243 );
244 }
245
246 if ( static::is_build_queued() ) {
247 return array(
248 'dispatched' => false,
249 'status' => 'queued',
250 'next_scheduled_at' => static::get_next_scheduled_at(),
251 );
252 }
253
254 static::schedule_rebuild();
255
256 return array(
257 'dispatched' => true,
258 'status' => 'queued',
259 'next_scheduled_at' => static::get_next_scheduled_at(),
260 );
261 }
262
263 /**
264 * Public sitemap URL for the master sitemap.
265 *
266 * Extracted as a protected seam so tests can override without booting the
267 * rewrite/permalink stack.
268 */
269 protected static function get_master_sitemap_url(): string {
270 if ( function_exists( 'jetpack_sitemap_uri' ) ) {
271 return (string) jetpack_sitemap_uri( 'sitemap.xml' );
272 }
273 // Defensive: when the sitemaps module file is loaded the helper
274 // exists. This branch only runs if a caller invokes the ability
275 // outside the normal bootstrap path.
276 return (string) home_url( '/sitemap.xml' );
277 }
278
279 /**
280 * Raw master-sitemap XML — the exact document the public `sitemap.xml`
281 * router serves, read straight from storage via the librarian (no HTTP
282 * loopback). Returns an empty string when no master sitemap has been
283 * generated yet, or when the Sitemaps module helpers are unavailable.
284 *
285 * Extracted as a protected seam so tests can feed a known document without
286 * a librarian / wp_posts.
287 */
288 protected static function get_master_sitemap_xml(): string {
289 if (
290 ! class_exists( 'Jetpack_Sitemap_Librarian' )
291 || ! function_exists( 'jp_sitemap_filename' )
292 || ! defined( 'JP_MASTER_SITEMAP_TYPE' )
293 ) {
294 return '';
295 }
296
297 $librarian = new \Jetpack_Sitemap_Librarian();
298
299 // jp_sitemap_filename() is documented `@param string $number`; for the
300 // master type it returns 'sitemap.xml' and ignores the number, but it
301 // must be non-null and string-typed to satisfy the contract (the
302 // int-`0` router call site predates this and is Phan-baselined).
303 return (string) $librarian->get_sitemap_text(
304 \jp_sitemap_filename( JP_MASTER_SITEMAP_TYPE, '0' ),
305 JP_MASTER_SITEMAP_TYPE
306 );
307 }
308
309 /**
310 * The child-sitemap entries actually present in the served master
311 * sitemap, as a list of `[ 'loc' => string, 'lastmod' => string|null ]`.
312 *
313 * Parses the same `<sitemapindex>` document `sitemap.xml` serves rather
314 * than deriving freshness from the `jetpack-sitemap-state` option: that
315 * option can read its initial/reset shape (no `max` projection) even while
316 * a fully-built sitemap.xml is being served, so it is not a reliable
317 * "what does the sitemap actually contain" source.
318 *
319 * Returns an empty array when no master sitemap exists yet or the stored
320 * document does not parse.
321 *
322 * @return array<int, array{loc:string, lastmod:string|null}>
323 */
324 protected static function get_sitemap_entries(): array {
325 $xml = static::get_master_sitemap_xml();
326 if ( '' === $xml ) {
327 return array();
328 }
329
330 $previous = libxml_use_internal_errors( true );
331 $document = new \DOMDocument();
332 // Source is Jetpack's own stored sitemap (not user input) and PHP 8+
333 // disables external-entity loading by default; LIBXML_NONET is belt-
334 // and-suspenders against any network/entity fetch during parsing.
335 $loaded = $document->loadXML( $xml, LIBXML_NONET );
336 libxml_clear_errors();
337 libxml_use_internal_errors( $previous );
338
339 if ( ! $loaded ) {
340 return array();
341 }
342
343 $entries = array();
344 foreach ( $document->getElementsByTagName( 'sitemap' ) as $sitemap_node ) {
345 $loc_nodes = $sitemap_node->getElementsByTagName( 'loc' );
346 if ( 0 === $loc_nodes->length ) {
347 continue;
348 }
349
350 $loc = trim( $loc_nodes->item( 0 )->textContent );
351 if ( '' === $loc ) {
352 continue;
353 }
354
355 $lastmod_nodes = $sitemap_node->getElementsByTagName( 'lastmod' );
356 $lastmod = $lastmod_nodes->length > 0
357 ? trim( $lastmod_nodes->item( 0 )->textContent )
358 : '';
359
360 $entries[] = array(
361 'loc' => $loc,
362 'lastmod' => '' === $lastmod ? null : $lastmod,
363 );
364 }
365
366 return $entries;
367 }
368
369 /**
370 * Whether news-sitemap inclusion is enabled.
371 *
372 * Mirrors the filter chain in `Jetpack_Sitemap_Manager::callback_action_do_robotstxt`
373 * but only resolves the modern filter — the deprecated 7.4.0 alias is
374 * already merged into the modern filter by the time it runs in production.
375 */
376 protected static function is_news_sitemap_enabled(): bool {
377 /** This filter is documented in modules/sitemaps/sitemaps.php */
378 return (bool) apply_filters( 'jetpack_news_sitemap_include_in_robotstxt', true );
379 }
380
381 /**
382 * Count published posts of a given post type.
383 *
384 * Wraps `wp_count_posts()` so tests can override without a real WP_Posts
385 * factory.
386 *
387 * @param string $post_type Post type slug.
388 */
389 protected static function count_published( string $post_type ): int {
390 $counts = wp_count_posts( $post_type );
391 if ( ! is_object( $counts ) || ! isset( $counts->publish ) ) {
392 return 0;
393 }
394 return (int) $counts->publish;
395 }
396
397 /**
398 * Whether a sitemap build step is currently running.
399 *
400 * The Sitemaps module sets a 15-minute transient lock at the start of
401 * `Jetpack_Sitemap_State::check_out()` and deletes it on `unlock()` /
402 * `reset()`. Presence of the transient is the canonical "in flight" signal.
403 */
404 protected static function is_build_running(): bool {
405 return true === get_transient( self::STATE_LOCK_TRANSIENT );
406 }
407
408 /**
409 * Whether a sitemap build is already scheduled for a future cron tick.
410 *
411 * Uses `wp_next_scheduled` so we don't stack duplicate single-event cron
412 * entries when the recurring `jp_sitemap_cron_hook` is already pending.
413 */
414 protected static function is_build_queued(): bool {
415 return false !== wp_next_scheduled( self::CRON_HOOK );
416 }
417
418 /**
419 * Schedule a single-event cron tick to drive the next build step.
420 *
421 * Matches the dispatch pattern used by
422 * `Jetpack_Sitemap_Manager::callback_action_purge_data` — `wp_schedule_single_event`
423 * with an immediate execution time. The recurring `sitemap-interval`
424 * schedule still fires on its normal cadence; this just front-runs the
425 * next tick.
426 */
427 protected static function schedule_rebuild(): void {
428 wp_schedule_single_event( time(), self::CRON_HOOK );
429 }
430
431 /**
432 * When the next `jp_sitemap_cron_hook` build tick is scheduled, as an
433 * ISO 8601 UTC string (e.g. `2026-05-19T19:33:20Z`), or null when nothing
434 * is scheduled.
435 *
436 * Returned alongside the dispatch result so callers immediately know when
437 * the build they queued (or the one already pending) will actually run,
438 * without a second round-trip. Null in the `running` case when the lock is
439 * held but no future tick is queued.
440 *
441 * ISO 8601 with the explicit `Z` zone designator (not `human_time_diff()`,
442 * not a bare "Y-m-d H:i:s") so the timezone is unambiguous and the value is
443 * locale-stable and machine-parseable — the same format the `sitemaps[]`
444 * `lastmod` values use in `get-status`.
445 */
446 protected static function get_next_scheduled_at(): ?string {
447 $timestamp = wp_next_scheduled( self::CRON_HOOK );
448 if ( false === $timestamp ) {
449 return null;
450 }
451 return gmdate( 'Y-m-d\TH:i:s\Z', $timestamp );
452 }
453 }
454