From bd728a6248af1094f8454003557c539a79733135 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 12 May 2026 21:34:09 -0700 Subject: [PATCH 1/7] Sitemaps abilities: register status reads + rebuild dispatch via Registrar --- .../jetpack/changelog/add-sitemaps-abilities | 4 + .../abilities/class-sitemaps-abilities.php | 352 +++++++++++ .../jetpack/modules/sitemaps/sitemaps.php | 4 + .../tests/php/src/Sitemaps_Abilities_Test.php | 556 ++++++++++++++++++ .../class-sitemaps-abilities-test-stub.php | 97 +++ 5 files changed, 1013 insertions(+) create mode 100644 projects/plugins/jetpack/changelog/add-sitemaps-abilities create mode 100644 projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php create mode 100644 projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php create mode 100644 projects/plugins/jetpack/tests/php/src/class-sitemaps-abilities-test-stub.php diff --git a/projects/plugins/jetpack/changelog/add-sitemaps-abilities b/projects/plugins/jetpack/changelog/add-sitemaps-abilities new file mode 100644 index 000000000000..718704e4fb62 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-sitemaps-abilities @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Abilities API: register sitemaps reads + rebuild dispatch diff --git a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php new file mode 100644 index 000000000000..87664a5f5756 --- /dev/null +++ b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php @@ -0,0 +1,352 @@ + __( 'Jetpack Sitemaps', 'jetpack' ), + 'description' => __( 'Abilities for inspecting and rebuilding Jetpack-generated XML sitemaps.', 'jetpack' ), + ); + } + + /** + * {@inheritDoc} + */ + public static function get_abilities(): array { + return array( + 'jetpack-sitemaps/get-status' => array( + 'label' => __( 'Get Jetpack Sitemaps status', 'jetpack' ), + 'description' => __( 'Return the current state of the Jetpack-generated XML sitemaps as { active, url, last_build_at, post_count, page_count, news_sitemap_enabled, master_sitemap_url, last_error }. `active` reflects whether the Sitemaps module is on. `url` and `master_sitemap_url` both point at the public sitemap.xml entry point (kept as separate keys for forward-compatibility with per-type URLs). `last_build_at` is the most recent master-sitemap timestamp in "YYYY-MM-DD HH:mm:ss" UTC format, or null when no master sitemap has been generated yet. `news_sitemap_enabled` reflects the `jetpack_news_sitemap_include_in_robotstxt` filter (default true). `last_error` is currently always null — the module does not surface a structured error log; the field is reserved for forward compatibility. 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' ), + 'input_schema' => array( + 'type' => 'object', + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'active' => array( 'type' => 'boolean' ), + 'url' => array( 'type' => 'string' ), + 'last_build_at' => array( 'type' => array( 'string', 'null' ) ), + 'post_count' => array( 'type' => 'integer' ), + 'page_count' => array( 'type' => 'integer' ), + 'news_sitemap_enabled' => array( 'type' => 'boolean' ), + 'master_sitemap_url' => array( 'type' => 'string' ), + 'last_error' => array( 'type' => array( 'string', 'null' ) ), + ), + ), + 'execute_callback' => array( __CLASS__, 'get_status' ), + 'permission_callback' => array( __CLASS__, 'can_view_sitemaps' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ), + + 'jetpack-sitemaps/request-rebuild' => array( + 'label' => __( 'Request a Jetpack Sitemaps rebuild', 'jetpack' ), + 'description' => __( 'Dispatch a full sitemap regeneration by scheduling the existing `jp_sitemap_cron_hook` cron event. Returns { dispatched, status } 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). 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' ), + 'input_schema' => array( + 'type' => 'object', + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'dispatched' => array( 'type' => 'boolean' ), + 'status' => array( + 'type' => 'string', + 'enum' => array( 'queued', 'running', 'already_running' ), + ), + ), + ), + 'execute_callback' => array( __CLASS__, 'request_rebuild' ), + 'permission_callback' => array( __CLASS__, 'can_manage_sitemaps' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ), + ); + } + + /** + * Permission check: can the current user read sitemap status? + * + * Sitemap status is metadata about publicly-served XML — anyone who can + * manage content (`edit_posts`) is allowed to see it. Reads do not modify + * state and do not expose secrets. + */ + public static function can_view_sitemaps(): bool { + return current_user_can( 'edit_posts' ); + } + + /** + * Permission check: can the current user dispatch a sitemap rebuild? + * + * Rebuild scheduling writes to cron + transient state and can run for + * minutes on large sites, so it is gated on `manage_options` (admin only). + */ + public static function can_manage_sitemaps(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Execute: status read. + * + * Surfaces an opinionated, agent-friendly projection of the module's state: + * - `active` from `Jetpack::is_module_active`. + * - `url` / `master_sitemap_url` from `jetpack_sitemap_uri()`, the same + * helper the public sitemap router uses. + * - `last_build_at` from `jetpack-sitemap-state.max[JP_MASTER_SITEMAP_TYPE].lastmod`, + * the state machine's own projection — null until the first master + * sitemap completes. + * - `post_count` / `page_count` from `wp_count_posts()->publish`, the + * same baseline used by the WordPress core sitemap. Cheap; no joins. + * - `news_sitemap_enabled` from the `jetpack_news_sitemap_include_in_robotstxt` + * filter (the same filter that controls news-sitemap robots.txt inclusion). + * - `last_error` always null for now; reserved for forward compatibility + * when the module starts tracking a structured error log. + * + * @param array|null $input Ability input (no parameters accepted). + * @return array + */ + 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. + $active = Jetpack::is_module_active( self::MODULE_SLUG ); + $master_sitemap_url = static::get_master_sitemap_url(); + + return array( + 'active' => $active, + 'url' => $master_sitemap_url, + 'last_build_at' => static::get_last_build_at(), + 'post_count' => static::count_published( 'post' ), + 'page_count' => static::count_published( 'page' ), + 'news_sitemap_enabled' => static::is_news_sitemap_enabled(), + 'master_sitemap_url' => $master_sitemap_url, + 'last_error' => null, + ); + } + + /** + * Execute: rebuild dispatch. + * + * Three-state idempotent dispatch: + * + * 1. If the state lock transient is set, a build step is currently + * running. Return `dispatched=false`, `status=running`. We also surface + * `already_running` as the alias the plan documents; this function + * returns `running` as the canonical value so callers that branch on + * one or the other both work — the output_schema enum permits both. + * 2. Else if a cron event is already scheduled in the future for our hook, + * a build is queued. Return `dispatched=false`, `status=queued`. + * 3. Otherwise schedule a single-event cron tick to fire immediately and + * return `dispatched=true`, `status=queued`. + * + * @param array|null $input Ability input (no parameters accepted). + * @return array + */ + 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. + if ( static::is_build_running() ) { + return array( + 'dispatched' => false, + 'status' => 'running', + ); + } + + if ( static::is_build_queued() ) { + return array( + 'dispatched' => false, + 'status' => 'queued', + ); + } + + static::schedule_rebuild(); + + return array( + 'dispatched' => true, + 'status' => 'queued', + ); + } + + /** + * Public sitemap URL for the master sitemap. + * + * Extracted as a protected seam so tests can override without booting the + * rewrite/permalink stack. + */ + protected static function get_master_sitemap_url(): string { + if ( function_exists( 'jetpack_sitemap_uri' ) ) { + return (string) jetpack_sitemap_uri( 'sitemap.xml' ); + } + // Defensive: when the sitemaps module file is loaded the helper + // exists. This branch only runs if a caller invokes the ability + // outside the normal bootstrap path. + return (string) home_url( '/sitemap.xml' ); + } + + /** + * Most recent master-sitemap timestamp from the state machine's + * `max[JP_MASTER_SITEMAP_TYPE].lastmod` projection, or null when no + * master sitemap has been completed yet. + * + * @return string|null + */ + protected static function get_last_build_at() { + $state = get_option( self::STATE_OPTION ); + if ( ! is_array( $state ) || empty( $state['max'] ) || ! is_array( $state['max'] ) ) { + return null; + } + + $master_type = defined( 'JP_MASTER_SITEMAP_TYPE' ) ? JP_MASTER_SITEMAP_TYPE : 'jp_sitemap_master'; + if ( empty( $state['max'][ $master_type ] ) || ! is_array( $state['max'][ $master_type ] ) ) { + return null; + } + + $lastmod = $state['max'][ $master_type ]['lastmod'] ?? null; + if ( ! is_string( $lastmod ) || '' === $lastmod ) { + return null; + } + + // `Jetpack_Sitemap_State::initial()` seeds `last-modified` to the unix + // epoch as a sentinel for "never". Surface that as null here so agents + // don't misread the epoch as a real timestamp. + if ( '1970-01-01 00:00:00' === $lastmod ) { + return null; + } + + return $lastmod; + } + + /** + * Whether news-sitemap inclusion is enabled. + * + * Mirrors the filter chain in `Jetpack_Sitemap_Manager::callback_action_do_robotstxt` + * but only resolves the modern filter — the deprecated 7.4.0 alias is + * already merged into the modern filter by the time it runs in production. + */ + protected static function is_news_sitemap_enabled(): bool { + /** This filter is documented in modules/sitemaps/sitemaps.php */ + return (bool) apply_filters( 'jetpack_news_sitemap_include_in_robotstxt', true ); + } + + /** + * Count published posts of a given post type. + * + * Wraps `wp_count_posts()` so tests can override without a real WP_Posts + * factory. + * + * @param string $post_type Post type slug. + */ + protected static function count_published( string $post_type ): int { + $counts = wp_count_posts( $post_type ); + if ( ! is_object( $counts ) || ! isset( $counts->publish ) ) { + return 0; + } + return (int) $counts->publish; + } + + /** + * Whether a sitemap build step is currently running. + * + * The Sitemaps module sets a 15-minute transient lock at the start of + * `Jetpack_Sitemap_State::check_out()` and deletes it on `unlock()` / + * `reset()`. Presence of the transient is the canonical "in flight" signal. + */ + protected static function is_build_running(): bool { + return true === get_transient( self::STATE_LOCK_TRANSIENT ); + } + + /** + * Whether a sitemap build is already scheduled for a future cron tick. + * + * Uses `wp_next_scheduled` so we don't stack duplicate single-event cron + * entries when the recurring `jp_sitemap_cron_hook` is already pending. + */ + protected static function is_build_queued(): bool { + return false !== wp_next_scheduled( self::CRON_HOOK ); + } + + /** + * Schedule a single-event cron tick to drive the next build step. + * + * Matches the dispatch pattern used by + * `Jetpack_Sitemap_Manager::callback_action_purge_data` — `wp_schedule_single_event` + * with an immediate execution time. The recurring `sitemap-interval` + * schedule still fires on its normal cadence; this just front-runs the + * next tick. + */ + protected static function schedule_rebuild(): void { + wp_schedule_single_event( time(), self::CRON_HOOK ); + } +} diff --git a/projects/plugins/jetpack/modules/sitemaps/sitemaps.php b/projects/plugins/jetpack/modules/sitemaps/sitemaps.php index 7252dedbb0d6..8af0c6d368bb 100644 --- a/projects/plugins/jetpack/modules/sitemaps/sitemaps.php +++ b/projects/plugins/jetpack/modules/sitemaps/sitemaps.php @@ -607,3 +607,7 @@ function jetpack_sitemap_uri( $filename = 'sitemap.xml' ) { */ return apply_filters( 'jetpack_sitemap_location', $sitemap_url ); } + +// Register Jetpack Sitemaps abilities (WordPress Abilities API, WP 6.9+). +require_once __DIR__ . '/abilities/class-sitemaps-abilities.php'; +\Automattic\Jetpack\Plugin\Abilities\Sitemaps_Abilities::init(); diff --git a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php new file mode 100644 index 000000000000..4baab05eaf36 --- /dev/null +++ b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php @@ -0,0 +1,556 @@ +admin_id = wp_insert_user( + array( + 'user_login' => 'sitemaps_abilities_admin_' . wp_generate_password( 8, false, false ), + 'user_pass' => 'pw', + 'user_email' => 'admin_' . wp_generate_password( 6, false, false ) . '@example.test', + 'role' => 'administrator', + ) + ); + $this->editor_id = wp_insert_user( + array( + 'user_login' => 'sitemaps_abilities_editor_' . wp_generate_password( 8, false, false ), + 'user_pass' => 'pw', + 'user_email' => 'editor_' . wp_generate_password( 6, false, false ) . '@example.test', + 'role' => 'editor', + ) + ); + $this->subscriber_id = wp_insert_user( + array( + 'user_login' => 'sitemaps_abilities_sub_' . wp_generate_password( 8, false, false ), + 'user_pass' => 'pw', + 'user_email' => 'sub_' . wp_generate_password( 6, false, false ) . '@example.test', + 'role' => 'subscriber', + ) + ); + + // Default: gate open for most test cases. + add_filter( 'jetpack_wp_abilities_enabled', '__return_true' ); + } + + public function tear_down() { + remove_filter( 'jetpack_wp_abilities_enabled', '__return_true' ); + remove_filter( 'jetpack_wp_abilities_enabled', '__return_false' ); + remove_all_filters( 'jetpack_wp_abilities_should_register' ); + remove_all_filters( 'jetpack_active_modules' ); + remove_all_filters( 'jetpack_news_sitemap_include_in_robotstxt' ); + wp_set_current_user( 0 ); + + remove_action( Registrar::CATEGORIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_category' ) ); + remove_action( Registrar::ABILITIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_abilities' ) ); + + // Unregister anything this test left behind so the singleton registry stays clean + // between tests (the WP Abilities Registry persists across tests within a process). + if ( function_exists( 'wp_unregister_ability' ) ) { + foreach ( array_keys( Sitemaps_Abilities::get_abilities() ) as $slug ) { + wp_unregister_ability( $slug ); + } + } + if ( function_exists( 'wp_unregister_ability_category' ) ) { + wp_unregister_ability_category( Sitemaps_Abilities::get_category_slug() ); + } + + delete_transient( 'jetpack-sitemap-state-lock' ); + delete_option( 'jetpack-sitemap-state' ); + wp_clear_scheduled_hook( 'jp_sitemap_cron_hook' ); + + Sitemaps_Abilities_Test_Stub::reset(); + + parent::tear_down(); + } + + /** + * Hook the registrar callbacks and fire the API lifecycle actions so registrations + * happen inside the action callstack — `wp_register_ability(_category)` enforces + * `doing_action()`, so direct invocation outside the hook is rejected. + */ + private function fire_abilities_lifecycle(): void { + add_action( Registrar::CATEGORIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_category' ) ); + add_action( Registrar::ABILITIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_abilities' ) ); + do_action( Registrar::CATEGORIES_INIT_ACTION ); + do_action( Registrar::ABILITIES_INIT_ACTION ); + } + + /** + * Helper: pretend the Sitemaps module is active. Mirrors the pattern used + * by Monitor_Abilities_Test — filter the active modules list rather than + * reaching into the Jetpack option directly. + */ + private function activate_sitemaps_module(): void { + add_filter( + 'jetpack_active_modules', + static function ( $mods ) { + $mods = is_array( $mods ) ? $mods : array(); + $mods[] = 'sitemaps'; + return array_values( array_unique( $mods ) ); + } + ); + } + + /** + * Section: Abstract getters + */ + public function test_category_slug_is_plugin_scoped() { + $this->assertSame( 'jetpack-sitemaps', Sitemaps_Abilities::get_category_slug() ); + } + + public function test_category_definition_has_label_and_description() { + $def = Sitemaps_Abilities::get_category_definition(); + $this->assertArrayHasKey( 'label', $def ); + $this->assertArrayHasKey( 'description', $def ); + $this->assertIsString( $def['label'] ); + $this->assertIsString( $def['description'] ); + } + + public function test_abilities_map_is_non_empty_and_namespaced() { + $abilities = Sitemaps_Abilities::get_abilities(); + $this->assertNotEmpty( $abilities ); + foreach ( array_keys( $abilities ) as $slug ) { + $this->assertStringStartsWith( 'jetpack-sitemaps/', $slug ); + } + } + + public function test_no_spec_sets_category_explicitly() { + // Registrar auto-injects category; specs that set it are redundant and drift. + foreach ( Sitemaps_Abilities::get_abilities() as $slug => $spec ) { + $this->assertArrayNotHasKey( + 'category', + $spec, + "Ability {$slug} should not set its own category — Registrar injects it." + ); + } + } + + public function test_surface_exposes_get_status_and_request_rebuild() { + $abilities = Sitemaps_Abilities::get_abilities(); + $this->assertArrayHasKey( 'jetpack-sitemaps/get-status', $abilities ); + $this->assertArrayHasKey( 'jetpack-sitemaps/request-rebuild', $abilities ); + } + + public function test_get_status_is_annotated_readonly_idempotent() { + $spec = Sitemaps_Abilities::get_abilities()['jetpack-sitemaps/get-status']; + $this->assertTrue( $spec['meta']['annotations']['readonly'] ); + $this->assertFalse( $spec['meta']['annotations']['destructive'] ); + $this->assertTrue( $spec['meta']['annotations']['idempotent'] ); + } + + public function test_request_rebuild_is_annotated_non_readonly_idempotent() { + $spec = Sitemaps_Abilities::get_abilities()['jetpack-sitemaps/request-rebuild']; + $this->assertFalse( $spec['meta']['annotations']['readonly'] ); + $this->assertFalse( $spec['meta']['annotations']['destructive'] ); + $this->assertTrue( $spec['meta']['annotations']['idempotent'] ); + } + + /** + * Section: Registrar wiring + */ + public function test_init_registers_nothing_when_gate_filter_is_false() { + remove_filter( 'jetpack_wp_abilities_enabled', '__return_true' ); + add_filter( 'jetpack_wp_abilities_enabled', '__return_false' ); + + Sitemaps_Abilities::init(); + + $this->assertFalse( + has_action( Registrar::CATEGORIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_category' ) ) + ); + $this->assertFalse( + has_action( Registrar::ABILITIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_abilities' ) ) + ); + } + + public function test_init_hooks_lifecycle_actions_when_gate_is_true() { + Sitemaps_Abilities::init(); + + $this->assertNotFalse( + has_action( Registrar::CATEGORIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_category' ) ) + ); + $this->assertNotFalse( + has_action( Registrar::ABILITIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_abilities' ) ) + ); + } + + public function test_register_abilities_registers_every_slug() { + if ( ! function_exists( 'wp_get_abilities' ) || ! function_exists( 'wp_register_ability' ) ) { + $this->markTestSkipped( 'Abilities API not available in this WP version.' ); + } + + $this->fire_abilities_lifecycle(); + + $registered_slugs = array(); + foreach ( wp_get_abilities() as $ability ) { + $name = $ability->get_name(); + if ( str_starts_with( $name, 'jetpack-sitemaps/' ) ) { + $registered_slugs[] = $name; + } + } + + foreach ( array_keys( Sitemaps_Abilities::get_abilities() ) as $slug ) { + $this->assertContains( $slug, $registered_slugs, "Ability {$slug} should be registered." ); + } + } + + public function test_per_ability_allow_list_filter_is_respected() { + if ( ! function_exists( 'wp_get_ability' ) || ! function_exists( 'wp_register_ability' ) ) { + $this->markTestSkipped( 'Abilities API not available in this WP version.' ); + } + + add_filter( + 'jetpack_wp_abilities_should_register', + static function ( $enabled, $type, $slug ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Filter signature requires this parameter even when we don't branch on it. + if ( 'ability' === $type ) { + return false; + } + return $enabled; + }, + 10, + 3 + ); + + $this->fire_abilities_lifecycle(); + + $registered_slugs = array_map( + static function ( $a ) { + return $a->get_name(); + }, + wp_get_abilities() + ); + foreach ( array_keys( Sitemaps_Abilities::get_abilities() ) as $slug ) { + $this->assertNotContains( $slug, $registered_slugs, "Ability {$slug} must be filtered out." ); + } + } + + public function test_register_abilities_auto_injects_category() { + if ( ! function_exists( 'wp_get_ability' ) || ! function_exists( 'wp_register_ability' ) ) { + $this->markTestSkipped( 'Abilities API not available in this WP version.' ); + } + + $this->fire_abilities_lifecycle(); + + foreach ( array_keys( Sitemaps_Abilities::get_abilities() ) as $slug ) { + $registered = wp_get_ability( $slug ); + $this->assertNotNull( $registered, "Ability {$slug} should be registered." ); + $this->assertSame( + 'jetpack-sitemaps', + $registered->get_category(), + "Ability {$slug} should have category auto-injected." + ); + } + } + + /** + * Section: Permission callbacks + * + * Status reads are gated on `edit_posts` (anyone managing content can see + * sitemap state). Rebuild dispatch is gated on `manage_options` — admin only. + */ + public function test_can_view_sitemaps_allows_admin() { + wp_set_current_user( $this->admin_id ); + $this->assertTrue( Sitemaps_Abilities::can_view_sitemaps() ); + } + + public function test_can_view_sitemaps_allows_editor() { + // Editors have edit_posts but not manage_options. + wp_set_current_user( $this->editor_id ); + $this->assertTrue( Sitemaps_Abilities::can_view_sitemaps() ); + } + + public function test_can_view_sitemaps_denies_subscriber() { + wp_set_current_user( $this->subscriber_id ); + $this->assertFalse( Sitemaps_Abilities::can_view_sitemaps() ); + } + + public function test_can_view_sitemaps_denies_anonymous() { + wp_set_current_user( 0 ); + $this->assertFalse( Sitemaps_Abilities::can_view_sitemaps() ); + } + + public function test_can_manage_sitemaps_allows_admin() { + wp_set_current_user( $this->admin_id ); + $this->assertTrue( Sitemaps_Abilities::can_manage_sitemaps() ); + } + + public function test_can_manage_sitemaps_denies_editor() { + // Editors can read sitemap state but cannot dispatch a rebuild — + // manage_options is admin-only. + wp_set_current_user( $this->editor_id ); + $this->assertFalse( Sitemaps_Abilities::can_manage_sitemaps() ); + } + + public function test_can_manage_sitemaps_denies_subscriber() { + wp_set_current_user( $this->subscriber_id ); + $this->assertFalse( Sitemaps_Abilities::can_manage_sitemaps() ); + } + + public function test_can_manage_sitemaps_denies_anonymous() { + wp_set_current_user( 0 ); + $this->assertFalse( Sitemaps_Abilities::can_manage_sitemaps() ); + } + + /** + * Section: get_status execute callback + */ + + /** + * Module inactive: returns the full shape with `active=false` and + * `last_build_at=null`. Status reads are not gated on the module — agents + * should be able to see "sitemaps are off" without an error. + */ + public function test_get_status_returns_inactive_when_module_off() { + wp_set_current_user( $this->admin_id ); + + Sitemaps_Abilities_Test_Stub::$master_sitemap_url = 'https://example.test/sitemap.xml'; + + $result = Sitemaps_Abilities_Test_Stub::get_status(); + + $this->assertIsArray( $result ); + $this->assertFalse( $result['active'] ); + $this->assertSame( 'https://example.test/sitemap.xml', $result['url'] ); + $this->assertSame( 'https://example.test/sitemap.xml', $result['master_sitemap_url'] ); + $this->assertNull( $result['last_build_at'] ); + $this->assertSame( 0, $result['post_count'] ); + $this->assertSame( 0, $result['page_count'] ); + $this->assertTrue( $result['news_sitemap_enabled'] ); + $this->assertNull( $result['last_error'] ); + } + + /** + * Happy path: module active, state machine has recorded a master-sitemap + * timestamp, counts come back from the wp_count_posts seam, and the news + * filter is left at its default-true. + */ + public function test_get_status_returns_full_shape_when_module_active() { + wp_set_current_user( $this->admin_id ); + $this->activate_sitemaps_module(); + + Sitemaps_Abilities_Test_Stub::$master_sitemap_url = 'https://example.test/sitemap.xml'; + Sitemaps_Abilities_Test_Stub::$last_build_at = '2026-01-15 12:34:56'; + Sitemaps_Abilities_Test_Stub::$counts = array( + 'post' => 42, + 'page' => 7, + ); + + $result = Sitemaps_Abilities_Test_Stub::get_status(); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['active'] ); + $this->assertSame( '2026-01-15 12:34:56', $result['last_build_at'] ); + $this->assertSame( 42, $result['post_count'] ); + $this->assertSame( 7, $result['page_count'] ); + $this->assertTrue( $result['news_sitemap_enabled'] ); + $this->assertNull( $result['last_error'] ); + } + + /** + * News filter false → reflected in the read. + */ + public function test_get_status_reflects_news_sitemap_filter_disabled() { + wp_set_current_user( $this->admin_id ); + $this->activate_sitemaps_module(); + add_filter( 'jetpack_news_sitemap_include_in_robotstxt', '__return_false' ); + + Sitemaps_Abilities_Test_Stub::$master_sitemap_url = 'https://example.test/sitemap.xml'; + + $result = Sitemaps_Abilities_Test_Stub::get_status(); + + $this->assertFalse( $result['news_sitemap_enabled'] ); + } + + /** + * `last_build_at` derivation from `jetpack-sitemap-state`: the state + * machine seeds `lastmod` to the epoch sentinel when nothing has been + * built yet, and we must surface that as null (not a real-looking + * timestamp). + */ + public function test_get_last_build_at_returns_null_for_epoch_sentinel() { + update_option( + 'jetpack-sitemap-state', + array( + 'max' => array( + 'jp_sitemap_master' => array( + 'number' => 0, + 'lastmod' => '1970-01-01 00:00:00', + ), + ), + ) + ); + + $this->assertNull( Sitemaps_Abilities_Test_Stub::call_get_last_build_at() ); + } + + public function test_get_last_build_at_returns_state_machine_timestamp() { + update_option( + 'jetpack-sitemap-state', + array( + 'max' => array( + 'jp_sitemap_master' => array( + 'number' => 3, + 'lastmod' => '2026-02-01 03:14:15', + ), + ), + ) + ); + + $this->assertSame( '2026-02-01 03:14:15', Sitemaps_Abilities_Test_Stub::call_get_last_build_at() ); + } + + public function test_get_last_build_at_returns_null_when_state_option_missing() { + // No option seeded — fresh install, no builds ever. + $this->assertNull( Sitemaps_Abilities_Test_Stub::call_get_last_build_at() ); + } + + /** + * Section: request_rebuild execute callback + */ + + /** + * Lock transient present → status=running, no new cron event. + */ + public function test_request_rebuild_returns_running_when_build_in_flight() { + wp_set_current_user( $this->admin_id ); + set_transient( 'jetpack-sitemap-state-lock', true, 15 * MINUTE_IN_SECONDS ); + + $result = Sitemaps_Abilities::request_rebuild(); + + $this->assertIsArray( $result ); + $this->assertFalse( $result['dispatched'] ); + $this->assertSame( 'running', $result['status'] ); + $this->assertFalse( + wp_next_scheduled( 'jp_sitemap_cron_hook' ), + 'No new cron event should be scheduled when a build is in flight.' + ); + } + + /** + * Existing scheduled cron event → status=queued, no duplicate scheduling. + */ + public function test_request_rebuild_returns_queued_when_cron_already_scheduled() { + wp_set_current_user( $this->admin_id ); + + $future = time() + 600; + wp_schedule_single_event( $future, 'jp_sitemap_cron_hook' ); + + $result = Sitemaps_Abilities::request_rebuild(); + + $this->assertIsArray( $result ); + $this->assertFalse( $result['dispatched'] ); + $this->assertSame( 'queued', $result['status'] ); + // The previously-scheduled tick should still be the next one (i.e. we + // didn't stack a new one at time() that would overshadow it). + $this->assertSame( $future, wp_next_scheduled( 'jp_sitemap_cron_hook' ) ); + } + + /** + * Nothing running, nothing queued → schedule a single-event cron tick + * and return dispatched=true. + */ + public function test_request_rebuild_dispatches_when_nothing_running_or_queued() { + wp_set_current_user( $this->admin_id ); + + $this->assertFalse( wp_next_scheduled( 'jp_sitemap_cron_hook' ) ); + + $result = Sitemaps_Abilities::request_rebuild(); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['dispatched'] ); + $this->assertSame( 'queued', $result['status'] ); + $this->assertNotFalse( + wp_next_scheduled( 'jp_sitemap_cron_hook' ), + 'A cron event should be scheduled after a successful dispatch.' + ); + } + + /** + * Idempotency: a second call right after a successful dispatch returns + * `status=queued` and `dispatched=false` (because the first call's cron + * event is still pending). + */ + public function test_request_rebuild_is_idempotent_after_dispatch() { + wp_set_current_user( $this->admin_id ); + + $first = Sitemaps_Abilities::request_rebuild(); + $this->assertTrue( $first['dispatched'] ); + + $second = Sitemaps_Abilities::request_rebuild(); + $this->assertFalse( $second['dispatched'] ); + $this->assertSame( 'queued', $second['status'] ); + } + + /** + * Section: Real bootstrap path + * + * When the Sitemaps module is inactive, modules/sitemaps.php is never + * loaded by Jetpack, so Sitemaps_Abilities::init() never runs and the + * abilities are not registered. This codifies the gated-registration + * contract. + */ + public function test_abilities_are_not_registered_when_sitemaps_module_is_inactive() { + if ( ! function_exists( 'wp_get_abilities' ) ) { + $this->markTestSkipped( 'Abilities API not available in this WP version.' ); + } + + // Do NOT fire the lifecycle or call init() — this mirrors the real + // bootstrap path when modules/sitemaps.php has not been included. + $registered_slugs = array(); + foreach ( wp_get_abilities() as $ability ) { + $name = $ability->get_name(); + if ( str_starts_with( $name, 'jetpack-sitemaps/' ) ) { + $registered_slugs[] = $name; + } + } + + $this->assertSame( + array(), + $registered_slugs, + 'Sitemaps abilities must not be registered while the Sitemaps module is inactive (modules/sitemaps.php not loaded).' + ); + } +} diff --git a/projects/plugins/jetpack/tests/php/src/class-sitemaps-abilities-test-stub.php b/projects/plugins/jetpack/tests/php/src/class-sitemaps-abilities-test-stub.php new file mode 100644 index 000000000000..53ced1ce96e1 --- /dev/null +++ b/projects/plugins/jetpack/tests/php/src/class-sitemaps-abilities-test-stub.php @@ -0,0 +1,97 @@ + + */ + public static $counts = array(); + + /** + * Reset every seam back to its default. Called from the test's tear_down(). + */ + public static function reset(): void { + self::$master_sitemap_url = ''; + self::$last_build_at = null; + self::$counts = array(); + } + + /** + * Expose the parent's `get_last_build_at()` for direct unit tests. + * + * @return string|null + */ + public static function call_get_last_build_at() { + return parent::get_last_build_at(); + } + + /** + * Return the seeded master sitemap URL. + */ + protected static function get_master_sitemap_url(): string { + return self::$master_sitemap_url; + } + + /** + * Return the seeded timestamp, or fall through to the real implementation + * when no override is set. + * + * @return string|null + */ + protected static function get_last_build_at() { + if ( null !== self::$last_build_at ) { + return self::$last_build_at; + } + return parent::get_last_build_at(); + } + + /** + * Return the seeded count for the given post type, defaulting to 0. + * + * @param string $post_type Post type slug. + */ + protected static function count_published( string $post_type ): int { + return (int) ( self::$counts[ $post_type ] ?? 0 ); + } +} From 553a7a4e8d9f5c57386924ec1d9371c2d011e626 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Fri, 15 May 2026 12:21:10 -0700 Subject: [PATCH 2/7] Sitemaps abilities: use core site category --- .../add-sitemaps-abilities-core-site-category | 4 +++ .../abilities/class-sitemaps-abilities.php | 26 +++++++++++---- .../tests/php/src/Sitemaps_Abilities_Test.php | 33 +++++++++++-------- 3 files changed, 44 insertions(+), 19 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/add-sitemaps-abilities-core-site-category diff --git a/projects/plugins/jetpack/changelog/add-sitemaps-abilities-core-site-category b/projects/plugins/jetpack/changelog/add-sitemaps-abilities-core-site-category new file mode 100644 index 000000000000..47cdd2b62151 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-sitemaps-abilities-core-site-category @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Sitemaps abilities: use the core site ability category diff --git a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php index 87664a5f5756..bc368d8d393d 100644 --- a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php +++ b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php @@ -57,22 +57,36 @@ class Sitemaps_Abilities extends Registrar { /** * {@inheritDoc} + * + * Sitemaps abilities live under the WordPress core `site` category — it is + * registered by the Abilities API itself, so we reference it by slug and + * never register it ourselves (see the no-op `register_category()` below). */ public static function get_category_slug(): string { - return 'jetpack-sitemaps'; + return 'site'; } /** * {@inheritDoc} + * + * Unused: the `site` category is owned by WordPress core, so + * `register_category()` is a no-op and this definition is never passed to + * `wp_register_ability_category()`. It remains only to satisfy the abstract + * Registrar contract. */ public static function get_category_definition(): array { - return array( - // translators: "Jetpack" is a product name and should not be translated. - 'label' => __( 'Jetpack Sitemaps', 'jetpack' ), - 'description' => __( 'Abilities for inspecting and rebuilding Jetpack-generated XML sitemaps.', 'jetpack' ), - ); + return array(); } + /** + * No-op: the `site` ability category is registered by the WordPress core + * Abilities API. Re-registering it here would clobber the core definition, + * so this registrar only references the category by slug. + * + * @return void + */ + public static function register_category() {} + /** * {@inheritDoc} */ diff --git a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php index 4baab05eaf36..56b1026fafbf 100644 --- a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php +++ b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php @@ -95,9 +95,9 @@ public function tear_down() { wp_unregister_ability( $slug ); } } - if ( function_exists( 'wp_unregister_ability_category' ) ) { - wp_unregister_ability_category( Sitemaps_Abilities::get_category_slug() ); - } + // Note: we intentionally do NOT unregister the category here — Sitemaps + // abilities live under the WordPress core `site` category, which this + // registrar never registers and must never tear down. delete_transient( 'jetpack-sitemap-state-lock' ); delete_option( 'jetpack-sitemap-state' ); @@ -139,16 +139,23 @@ static function ( $mods ) { /** * Section: Abstract getters */ - public function test_category_slug_is_plugin_scoped() { - $this->assertSame( 'jetpack-sitemaps', Sitemaps_Abilities::get_category_slug() ); + public function test_category_slug_is_core_site_category() { + $this->assertSame( 'site', Sitemaps_Abilities::get_category_slug() ); } - public function test_category_definition_has_label_and_description() { - $def = Sitemaps_Abilities::get_category_definition(); - $this->assertArrayHasKey( 'label', $def ); - $this->assertArrayHasKey( 'description', $def ); - $this->assertIsString( $def['label'] ); - $this->assertIsString( $def['description'] ); + public function test_register_category_is_a_noop() { + // The `site` category is owned by WordPress core; this registrar must + // not register (and thus clobber) it. + if ( ! function_exists( 'wp_get_ability_category' ) ) { + $this->markTestSkipped( 'Abilities API not available in this WP version.' ); + } + + add_action( Registrar::CATEGORIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_category' ) ); + do_action( Registrar::CATEGORIES_INIT_ACTION ); + + // Nothing this registrar did should have created a `jetpack-sitemaps` + // category, and the core `site` category (if present) is untouched. + $this->assertNull( wp_get_ability_category( 'jetpack-sitemaps' ) ); } public function test_abilities_map_is_non_empty_and_namespaced() { @@ -279,9 +286,9 @@ public function test_register_abilities_auto_injects_category() { $registered = wp_get_ability( $slug ); $this->assertNotNull( $registered, "Ability {$slug} should be registered." ); $this->assertSame( - 'jetpack-sitemaps', + 'site', $registered->get_category(), - "Ability {$slug} should have category auto-injected." + "Ability {$slug} should be assigned the core `site` category." ); } } From 76ad8e7f7b495e8b1ba0e554d22b4e0040bfe314 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Fri, 15 May 2026 12:35:33 -0700 Subject: [PATCH 3/7] Sitemaps abilities tests: register core site category in lifecycle helper --- .../tests/php/src/Sitemaps_Abilities_Test.php | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php index 56b1026fafbf..93fdbac81166 100644 --- a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php +++ b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php @@ -114,10 +114,39 @@ public function tear_down() { * `doing_action()`, so direct invocation outside the hook is rejected. */ private function fire_abilities_lifecycle(): void { + // Sitemaps abilities live under the core `site` category, which in + // production is registered by WordPress core on the + // `wp_abilities_api_categories_init` action. The isolated unit-test + // lifecycle does not carry core's registration over, so without this + // the category is absent when `register_abilities()` runs and + // `wp_register_ability()` rejects every ability ("category not + // registered"). Register the `site` category here, scoped to the + // categories-init action (where `wp_register_ability_category()` is + // permitted) and guarded so a re-fire — or a build where core already + // registered it — does not trigger an "already registered" notice. + $ensure_site_category = static function () { + if ( + function_exists( 'wp_has_ability_category' ) + && function_exists( 'wp_register_ability_category' ) + && ! wp_has_ability_category( 'site' ) + ) { + wp_register_ability_category( + 'site', + array( + 'label' => 'Site', + 'description' => 'Abilities that retrieve or modify site information and settings.', + ) + ); + } + }; + add_action( Registrar::CATEGORIES_INIT_ACTION, $ensure_site_category, 1 ); + add_action( Registrar::CATEGORIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_category' ) ); add_action( Registrar::ABILITIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_abilities' ) ); do_action( Registrar::CATEGORIES_INIT_ACTION ); do_action( Registrar::ABILITIES_INIT_ACTION ); + + remove_action( Registrar::CATEGORIES_INIT_ACTION, $ensure_site_category, 1 ); } /** @@ -145,17 +174,19 @@ public function test_category_slug_is_core_site_category() { public function test_register_category_is_a_noop() { // The `site` category is owned by WordPress core; this registrar must - // not register (and thus clobber) it. - if ( ! function_exists( 'wp_get_ability_category' ) ) { + // not register a plugin-scoped category of its own. + if ( ! function_exists( 'wp_has_ability_category' ) ) { $this->markTestSkipped( 'Abilities API not available in this WP version.' ); } add_action( Registrar::CATEGORIES_INIT_ACTION, array( Sitemaps_Abilities::class, 'register_category' ) ); do_action( Registrar::CATEGORIES_INIT_ACTION ); - // Nothing this registrar did should have created a `jetpack-sitemaps` - // category, and the core `site` category (if present) is untouched. - $this->assertNull( wp_get_ability_category( 'jetpack-sitemaps' ) ); + // Firing the registrar's (no-op) category callback must not have created + // a legacy `jetpack-sitemaps` category. `wp_has_ability_category()` + // returns a clean bool (no `_doing_it_wrong`), unlike + // `wp_get_ability_category()` which flags an unregistered lookup. + $this->assertFalse( wp_has_ability_category( 'jetpack-sitemaps' ) ); } public function test_abilities_map_is_non_empty_and_namespaced() { From d0c4fd4b4d80ea2ff04bbf902c0e2a86f69f364f Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 19 May 2026 11:54:35 -0700 Subject: [PATCH 4/7] Sitemaps abilities: simplify get-status to reflect the served sitemap.xml (drop dup URL / synthetic last-build / reserved error; add real sitemaps[]) --- .../changelog/sitemaps-abilities-status-shape | 4 + .../abilities/class-sitemaps-abilities.php | 138 ++++++++++++------ .../tests/php/src/Sitemaps_Abilities_Test.php | 127 +++++++++++----- .../class-sitemaps-abilities-test-stub.php | 43 +++--- 4 files changed, 202 insertions(+), 110 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/sitemaps-abilities-status-shape diff --git a/projects/plugins/jetpack/changelog/sitemaps-abilities-status-shape b/projects/plugins/jetpack/changelog/sitemaps-abilities-status-shape new file mode 100644 index 000000000000..38745ee3e399 --- /dev/null +++ b/projects/plugins/jetpack/changelog/sitemaps-abilities-status-shape @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Sitemaps abilities: simplify get-status output — drop the duplicate master_sitemap_url and the reserved last_error, replace the state-derived last_build_at with a `sitemaps` list reflecting the child sitemaps actually present in the served sitemap.xml (each with its own lastmod) diff --git a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php index bc368d8d393d..6498021b1cf0 100644 --- a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php +++ b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php @@ -48,13 +48,6 @@ class Sitemaps_Abilities extends Registrar { */ private const STATE_LOCK_TRANSIENT = 'jetpack-sitemap-state-lock'; - /** - * Option written by the sitemap state machine. Used to derive - * `last_build_at` from the master sitemap's `lastmod` projection without - * touching the librarian / wp_posts. - */ - private const STATE_OPTION = 'jetpack-sitemap-state'; - /** * {@inheritDoc} * @@ -94,7 +87,7 @@ public static function get_abilities(): array { return array( 'jetpack-sitemaps/get-status' => array( 'label' => __( 'Get Jetpack Sitemaps status', 'jetpack' ), - 'description' => __( 'Return the current state of the Jetpack-generated XML sitemaps as { active, url, last_build_at, post_count, page_count, news_sitemap_enabled, master_sitemap_url, last_error }. `active` reflects whether the Sitemaps module is on. `url` and `master_sitemap_url` both point at the public sitemap.xml entry point (kept as separate keys for forward-compatibility with per-type URLs). `last_build_at` is the most recent master-sitemap timestamp in "YYYY-MM-DD HH:mm:ss" UTC format, or null when no master sitemap has been generated yet. `news_sitemap_enabled` reflects the `jetpack_news_sitemap_include_in_robotstxt` filter (default true). `last_error` is currently always null — the module does not surface a structured error log; the field is reserved for forward compatibility. 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' ), + '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' ), 'input_schema' => array( 'type' => 'object', 'additionalProperties' => false, @@ -104,12 +97,19 @@ public static function get_abilities(): array { 'properties' => array( 'active' => array( 'type' => 'boolean' ), 'url' => array( 'type' => 'string' ), - 'last_build_at' => array( 'type' => array( 'string', 'null' ) ), 'post_count' => array( 'type' => 'integer' ), 'page_count' => array( 'type' => 'integer' ), 'news_sitemap_enabled' => array( 'type' => 'boolean' ), - 'master_sitemap_url' => array( 'type' => 'string' ), - 'last_error' => array( 'type' => array( 'string', 'null' ) ), + 'sitemaps' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'loc' => array( 'type' => 'string' ), + 'lastmod' => array( 'type' => array( 'string', 'null' ) ), + ), + ), + ), ), ), 'execute_callback' => array( __CLASS__, 'get_status' ), @@ -181,34 +181,27 @@ public static function can_manage_sitemaps(): bool { * * Surfaces an opinionated, agent-friendly projection of the module's state: * - `active` from `Jetpack::is_module_active`. - * - `url` / `master_sitemap_url` from `jetpack_sitemap_uri()`, the same - * helper the public sitemap router uses. - * - `last_build_at` from `jetpack-sitemap-state.max[JP_MASTER_SITEMAP_TYPE].lastmod`, - * the state machine's own projection — null until the first master - * sitemap completes. + * - `url` from `jetpack_sitemap_uri()`, the same helper the public sitemap + * router uses. * - `post_count` / `page_count` from `wp_count_posts()->publish`, the * same baseline used by the WordPress core sitemap. Cheap; no joins. * - `news_sitemap_enabled` from the `jetpack_news_sitemap_include_in_robotstxt` * filter (the same filter that controls news-sitemap robots.txt inclusion). - * - `last_error` always null for now; reserved for forward compatibility - * when the module starts tracking a structured error log. + * - `sitemaps` from the served master sitemap document itself (see + * `get_sitemap_entries()`) — the real child-sitemap list with each + * entry's own `lastmod`, rather than a synthetic last-build timestamp. * * @param array|null $input Ability input (no parameters accepted). * @return array */ 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. - $active = Jetpack::is_module_active( self::MODULE_SLUG ); - $master_sitemap_url = static::get_master_sitemap_url(); - return array( - 'active' => $active, - 'url' => $master_sitemap_url, - 'last_build_at' => static::get_last_build_at(), + 'active' => Jetpack::is_module_active( self::MODULE_SLUG ), + 'url' => static::get_master_sitemap_url(), 'post_count' => static::count_published( 'post' ), 'page_count' => static::count_published( 'page' ), 'news_sitemap_enabled' => static::is_news_sitemap_enabled(), - 'master_sitemap_url' => $master_sitemap_url, - 'last_error' => null, + 'sitemaps' => static::get_sitemap_entries(), ); } @@ -270,36 +263,89 @@ protected static function get_master_sitemap_url(): string { } /** - * Most recent master-sitemap timestamp from the state machine's - * `max[JP_MASTER_SITEMAP_TYPE].lastmod` projection, or null when no - * master sitemap has been completed yet. + * Raw master-sitemap XML — the exact document the public `sitemap.xml` + * router serves, read straight from storage via the librarian (no HTTP + * loopback). Returns an empty string when no master sitemap has been + * generated yet, or when the Sitemaps module helpers are unavailable. * - * @return string|null + * Extracted as a protected seam so tests can feed a known document without + * a librarian / wp_posts. */ - protected static function get_last_build_at() { - $state = get_option( self::STATE_OPTION ); - if ( ! is_array( $state ) || empty( $state['max'] ) || ! is_array( $state['max'] ) ) { - return null; + protected static function get_master_sitemap_xml(): string { + if ( + ! class_exists( 'Jetpack_Sitemap_Librarian' ) + || ! function_exists( 'jp_sitemap_filename' ) + || ! defined( 'JP_MASTER_SITEMAP_TYPE' ) + ) { + return ''; } - $master_type = defined( 'JP_MASTER_SITEMAP_TYPE' ) ? JP_MASTER_SITEMAP_TYPE : 'jp_sitemap_master'; - if ( empty( $state['max'][ $master_type ] ) || ! is_array( $state['max'][ $master_type ] ) ) { - return null; + $librarian = new \Jetpack_Sitemap_Librarian(); + + return (string) $librarian->get_sitemap_text( + \jp_sitemap_filename( JP_MASTER_SITEMAP_TYPE, 0 ), + JP_MASTER_SITEMAP_TYPE + ); + } + + /** + * The child-sitemap entries actually present in the served master + * sitemap, as a list of `[ 'loc' => string, 'lastmod' => string|null ]`. + * + * Parses the same `` document `sitemap.xml` serves rather + * than deriving freshness from the `jetpack-sitemap-state` option: that + * option can read its initial/reset shape (no `max` projection) even while + * a fully-built sitemap.xml is being served, so it is not a reliable + * "what does the sitemap actually contain" source. + * + * Returns an empty array when no master sitemap exists yet or the stored + * document does not parse. + * + * @return array + */ + protected static function get_sitemap_entries(): array { + $xml = static::get_master_sitemap_xml(); + if ( '' === $xml ) { + return array(); } - $lastmod = $state['max'][ $master_type ]['lastmod'] ?? null; - if ( ! is_string( $lastmod ) || '' === $lastmod ) { - return null; + $previous = libxml_use_internal_errors( true ); + $document = new \DOMDocument(); + // Source is Jetpack's own stored sitemap (not user input) and PHP 8+ + // disables external-entity loading by default; LIBXML_NONET is belt- + // and-suspenders against any network/entity fetch during parsing. + $loaded = $document->loadXML( $xml, LIBXML_NONET ); + libxml_clear_errors(); + libxml_use_internal_errors( $previous ); + + if ( ! $loaded ) { + return array(); } - // `Jetpack_Sitemap_State::initial()` seeds `last-modified` to the unix - // epoch as a sentinel for "never". Surface that as null here so agents - // don't misread the epoch as a real timestamp. - if ( '1970-01-01 00:00:00' === $lastmod ) { - return null; + $entries = array(); + foreach ( $document->getElementsByTagName( 'sitemap' ) as $sitemap_node ) { + $loc_nodes = $sitemap_node->getElementsByTagName( 'loc' ); + if ( 0 === $loc_nodes->length ) { + continue; + } + + $loc = trim( $loc_nodes->item( 0 )->textContent ); + if ( '' === $loc ) { + continue; + } + + $lastmod_nodes = $sitemap_node->getElementsByTagName( 'lastmod' ); + $lastmod = $lastmod_nodes->length > 0 + ? trim( $lastmod_nodes->item( 0 )->textContent ) + : ''; + + $entries[] = array( + 'loc' => $loc, + 'lastmod' => '' === $lastmod ? null : $lastmod, + ); } - return $lastmod; + return $entries; } /** diff --git a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php index 93fdbac81166..595f644e58f4 100644 --- a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php +++ b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php @@ -48,6 +48,15 @@ class Sitemaps_Abilities_Test extends WP_UnitTestCase { public function set_up() { parent::set_up(); + // Defensive isolation: cron events / sitemap state can leak across + // tests within a shared process if a prior test's tear_down did not + // run (fatal/out-of-order). Mirror tear_down's cleanup at the start of + // every test so request_rebuild assertions on wp_next_scheduled() + // start from a known-empty state regardless of run order. + delete_transient( 'jetpack-sitemap-state-lock' ); + delete_option( 'jetpack-sitemap-state' ); + wp_clear_scheduled_hook( 'jp_sitemap_cron_hook' ); + $this->admin_id = wp_insert_user( array( 'user_login' => 'sitemaps_abilities_admin_' . wp_generate_password( 8, false, false ), @@ -378,8 +387,8 @@ public function test_can_manage_sitemaps_denies_anonymous() { */ /** - * Module inactive: returns the full shape with `active=false` and - * `last_build_at=null`. Status reads are not gated on the module — agents + * Module inactive: returns the simplified shape with `active=false` and an + * empty `sitemaps` list. Status reads are not gated on the module — agents * should be able to see "sitemaps are off" without an error. */ public function test_get_status_returns_inactive_when_module_off() { @@ -392,25 +401,29 @@ public function test_get_status_returns_inactive_when_module_off() { $this->assertIsArray( $result ); $this->assertFalse( $result['active'] ); $this->assertSame( 'https://example.test/sitemap.xml', $result['url'] ); - $this->assertSame( 'https://example.test/sitemap.xml', $result['master_sitemap_url'] ); - $this->assertNull( $result['last_build_at'] ); $this->assertSame( 0, $result['post_count'] ); $this->assertSame( 0, $result['page_count'] ); $this->assertTrue( $result['news_sitemap_enabled'] ); - $this->assertNull( $result['last_error'] ); + $this->assertSame( array(), $result['sitemaps'] ); + + // Simplified shape: no URL duplication, no synthetic last-build + // scalar, no reserved-but-always-null error field. + $this->assertArrayNotHasKey( 'master_sitemap_url', $result ); + $this->assertArrayNotHasKey( 'last_build_at', $result ); + $this->assertArrayNotHasKey( 'last_error', $result ); } /** - * Happy path: module active, state machine has recorded a master-sitemap - * timestamp, counts come back from the wp_count_posts seam, and the news - * filter is left at its default-true. + * Happy path: module active, a master sitemap exists, and `sitemaps` + * reflects the child-sitemap entries actually present in the served + * sitemap.xml (loc + lastmod), parsed from the stored master. */ public function test_get_status_returns_full_shape_when_module_active() { wp_set_current_user( $this->admin_id ); $this->activate_sitemaps_module(); Sitemaps_Abilities_Test_Stub::$master_sitemap_url = 'https://example.test/sitemap.xml'; - Sitemaps_Abilities_Test_Stub::$last_build_at = '2026-01-15 12:34:56'; + Sitemaps_Abilities_Test_Stub::$master_sitemap_xml = self::sample_sitemapindex(); Sitemaps_Abilities_Test_Stub::$counts = array( 'post' => 42, 'page' => 7, @@ -420,11 +433,23 @@ public function test_get_status_returns_full_shape_when_module_active() { $this->assertIsArray( $result ); $this->assertTrue( $result['active'] ); - $this->assertSame( '2026-01-15 12:34:56', $result['last_build_at'] ); + $this->assertSame( 'https://example.test/sitemap.xml', $result['url'] ); $this->assertSame( 42, $result['post_count'] ); $this->assertSame( 7, $result['page_count'] ); $this->assertTrue( $result['news_sitemap_enabled'] ); - $this->assertNull( $result['last_error'] ); + $this->assertSame( + array( + array( + 'loc' => 'https://example.test/sitemap-1.xml', + 'lastmod' => '2026-04-19T14:09:16Z', + ), + array( + 'loc' => 'https://example.test/image-sitemap-1.xml', + 'lastmod' => '2026-03-19T14:48:36Z', + ), + ), + $result['sitemaps'] + ); } /** @@ -443,46 +468,70 @@ public function test_get_status_reflects_news_sitemap_filter_disabled() { } /** - * `last_build_at` derivation from `jetpack-sitemap-state`: the state - * machine seeds `lastmod` to the epoch sentinel when nothing has been - * built yet, and we must surface that as null (not a real-looking - * timestamp). + * Section: get_sitemap_entries — reflect the real served sitemap.xml + * + * `get-status` surfaces the child-sitemap entries actually present in the + * stored master sitemap (the same document the public sitemap.xml router + * serves), rather than a synthetic last-build timestamp derived from the + * `jetpack-sitemap-state` option (which reads null even while a real + * sitemap.xml is being served). */ - public function test_get_last_build_at_returns_null_for_epoch_sentinel() { - update_option( - 'jetpack-sitemap-state', + public function test_get_sitemap_entries_parses_sitemapindex() { + Sitemaps_Abilities_Test_Stub::$master_sitemap_xml = self::sample_sitemapindex(); + + $this->assertSame( array( - 'max' => array( - 'jp_sitemap_master' => array( - 'number' => 0, - 'lastmod' => '1970-01-01 00:00:00', - ), + array( + 'loc' => 'https://example.test/sitemap-1.xml', + 'lastmod' => '2026-04-19T14:09:16Z', ), - ) + array( + 'loc' => 'https://example.test/image-sitemap-1.xml', + 'lastmod' => '2026-03-19T14:48:36Z', + ), + ), + Sitemaps_Abilities_Test_Stub::call_get_sitemap_entries() ); + } + + public function test_get_sitemap_entries_returns_empty_array_when_no_master() { + Sitemaps_Abilities_Test_Stub::$master_sitemap_xml = ''; + $this->assertSame( array(), Sitemaps_Abilities_Test_Stub::call_get_sitemap_entries() ); + } - $this->assertNull( Sitemaps_Abilities_Test_Stub::call_get_last_build_at() ); + public function test_get_sitemap_entries_returns_empty_array_for_malformed_xml() { + Sitemaps_Abilities_Test_Stub::$master_sitemap_xml = 'broken'; + $this->assertSame( array(), Sitemaps_Abilities_Test_Stub::call_get_sitemap_entries() ); } - public function test_get_last_build_at_returns_state_machine_timestamp() { - update_option( - 'jetpack-sitemap-state', + public function test_get_sitemap_entries_tolerates_missing_lastmod() { + Sitemaps_Abilities_Test_Stub::$master_sitemap_xml = + '' + . '' + . 'https://example.test/sitemap-1.xml' + . ''; + + $this->assertSame( array( - 'max' => array( - 'jp_sitemap_master' => array( - 'number' => 3, - 'lastmod' => '2026-02-01 03:14:15', - ), + array( + 'loc' => 'https://example.test/sitemap-1.xml', + 'lastmod' => null, ), - ) + ), + Sitemaps_Abilities_Test_Stub::call_get_sitemap_entries() ); - - $this->assertSame( '2026-02-01 03:14:15', Sitemaps_Abilities_Test_Stub::call_get_last_build_at() ); } - public function test_get_last_build_at_returns_null_when_state_option_missing() { - // No option seeded — fresh install, no builds ever. - $this->assertNull( Sitemaps_Abilities_Test_Stub::call_get_last_build_at() ); + /** + * Two-entry sitemapindex fixture mirroring the real master sitemap shape + * (default sitemaps.org namespace, loc + lastmod per child). + */ + private static function sample_sitemapindex(): string { + return '' + . '' + . 'https://example.test/sitemap-1.xml2026-04-19T14:09:16Z' + . 'https://example.test/image-sitemap-1.xml2026-03-19T14:48:36Z' + . ''; } /** diff --git a/projects/plugins/jetpack/tests/php/src/class-sitemaps-abilities-test-stub.php b/projects/plugins/jetpack/tests/php/src/class-sitemaps-abilities-test-stub.php index 53ced1ce96e1..e3335b9acae6 100644 --- a/projects/plugins/jetpack/tests/php/src/class-sitemaps-abilities-test-stub.php +++ b/projects/plugins/jetpack/tests/php/src/class-sitemaps-abilities-test-stub.php @@ -1,8 +1,9 @@ */ - public static function call_get_last_build_at() { - return parent::get_last_build_at(); + public static function call_get_sitemap_entries(): array { + return parent::get_sitemap_entries(); } /** @@ -74,16 +73,10 @@ protected static function get_master_sitemap_url(): string { } /** - * Return the seeded timestamp, or fall through to the real implementation - * when no override is set. - * - * @return string|null + * Return the seeded raw master-sitemap XML instead of reading the librarian. */ - protected static function get_last_build_at() { - if ( null !== self::$last_build_at ) { - return self::$last_build_at; - } - return parent::get_last_build_at(); + protected static function get_master_sitemap_xml(): string { + return self::$master_sitemap_xml; } /** From 46c59da4f59842b19f8980e344ae703fc712b476 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 19 May 2026 12:10:51 -0700 Subject: [PATCH 5/7] Sitemaps abilities: pass string '0' to jp_sitemap_filename() to satisfy Phan type contract --- .../modules/sitemaps/abilities/class-sitemaps-abilities.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php index 6498021b1cf0..955e1bc4ab5f 100644 --- a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php +++ b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php @@ -282,8 +282,12 @@ protected static function get_master_sitemap_xml(): string { $librarian = new \Jetpack_Sitemap_Librarian(); + // jp_sitemap_filename() is documented `@param string $number`; for the + // master type it returns 'sitemap.xml' and ignores the number, but it + // must be non-null and string-typed to satisfy the contract (the + // int-`0` router call site predates this and is Phan-baselined). return (string) $librarian->get_sitemap_text( - \jp_sitemap_filename( JP_MASTER_SITEMAP_TYPE, 0 ), + \jp_sitemap_filename( JP_MASTER_SITEMAP_TYPE, '0' ), JP_MASTER_SITEMAP_TYPE ); } From f883ad9ade46cbc6f6dba6040b444156efd0d765 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 19 May 2026 12:29:17 -0700 Subject: [PATCH 6/7] Sitemaps abilities: return next_scheduled_at (next jp_sitemap_cron_hook tick, UTC) from request-rebuild --- .../abilities/class-sitemaps-abilities.php | 46 +++++++++++++++---- .../tests/php/src/Sitemaps_Abilities_Test.php | 9 +++- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php index 955e1bc4ab5f..1c4e6519ff24 100644 --- a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php +++ b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php @@ -126,7 +126,7 @@ public static function get_abilities(): array { 'jetpack-sitemaps/request-rebuild' => array( 'label' => __( 'Request a Jetpack Sitemaps rebuild', 'jetpack' ), - 'description' => __( 'Dispatch a full sitemap regeneration by scheduling the existing `jp_sitemap_cron_hook` cron event. Returns { dispatched, status } 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). 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' ), + '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 a "YYYY-MM-DD HH:mm:ss" UTC string, 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' ), 'input_schema' => array( 'type' => 'object', 'additionalProperties' => false, @@ -134,11 +134,12 @@ public static function get_abilities(): array { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'dispatched' => array( 'type' => 'boolean' ), - 'status' => array( + 'dispatched' => array( 'type' => 'boolean' ), + 'status' => array( 'type' => 'string', 'enum' => array( 'queued', 'running', 'already_running' ), ), + 'next_scheduled_at' => array( 'type' => array( 'string', 'null' ) ), ), ), 'execute_callback' => array( __CLASS__, 'request_rebuild' ), @@ -220,29 +221,36 @@ public static function get_status( $input = null ) { // phpcs:ignore VariableAna * 3. Otherwise schedule a single-event cron tick to fire immediately and * return `dispatched=true`, `status=queued`. * + * Every branch also returns `next_scheduled_at` (see + * `get_next_scheduled_at()`) so the caller learns when the queued/pending + * build will actually run without a follow-up status read. + * * @param array|null $input Ability input (no parameters accepted). * @return array */ 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. if ( static::is_build_running() ) { return array( - 'dispatched' => false, - 'status' => 'running', + 'dispatched' => false, + 'status' => 'running', + 'next_scheduled_at' => static::get_next_scheduled_at(), ); } if ( static::is_build_queued() ) { return array( - 'dispatched' => false, - 'status' => 'queued', + 'dispatched' => false, + 'status' => 'queued', + 'next_scheduled_at' => static::get_next_scheduled_at(), ); } static::schedule_rebuild(); return array( - 'dispatched' => true, - 'status' => 'queued', + 'dispatched' => true, + 'status' => 'queued', + 'next_scheduled_at' => static::get_next_scheduled_at(), ); } @@ -413,4 +421,24 @@ protected static function is_build_queued(): bool { protected static function schedule_rebuild(): void { wp_schedule_single_event( time(), self::CRON_HOOK ); } + + /** + * When the next `jp_sitemap_cron_hook` build tick is scheduled, as a UTC + * "Y-m-d H:i:s" string, or null when nothing is scheduled. + * + * Returned alongside the dispatch result so callers immediately know when + * the build they queued (or the one already pending) will actually run, + * without a second round-trip. Null in the `running` case when the lock is + * held but no future tick is queued. + * + * UTC string (not `human_time_diff()`) for an unambiguous, locale-stable, + * machine-parseable value consistent with the rest of the sitemaps surface. + */ + protected static function get_next_scheduled_at(): ?string { + $timestamp = wp_next_scheduled( self::CRON_HOOK ); + if ( false === $timestamp ) { + return null; + } + return gmdate( 'Y-m-d H:i:s', $timestamp ); + } } diff --git a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php index 595f644e58f4..95855c8fa991 100644 --- a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php +++ b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php @@ -554,6 +554,8 @@ public function test_request_rebuild_returns_running_when_build_in_flight() { wp_next_scheduled( 'jp_sitemap_cron_hook' ), 'No new cron event should be scheduled when a build is in flight.' ); + // Nothing queued → no next run time to report. + $this->assertNull( $result['next_scheduled_at'] ); } /** @@ -573,6 +575,8 @@ public function test_request_rebuild_returns_queued_when_cron_already_scheduled( // The previously-scheduled tick should still be the next one (i.e. we // didn't stack a new one at time() that would overshadow it). $this->assertSame( $future, wp_next_scheduled( 'jp_sitemap_cron_hook' ) ); + // next_scheduled_at reflects that pending tick as a UTC string. + $this->assertSame( gmdate( 'Y-m-d H:i:s', $future ), $result['next_scheduled_at'] ); } /** @@ -589,10 +593,13 @@ public function test_request_rebuild_dispatches_when_nothing_running_or_queued() $this->assertIsArray( $result ); $this->assertTrue( $result['dispatched'] ); $this->assertSame( 'queued', $result['status'] ); + $scheduled = wp_next_scheduled( 'jp_sitemap_cron_hook' ); $this->assertNotFalse( - wp_next_scheduled( 'jp_sitemap_cron_hook' ), + $scheduled, 'A cron event should be scheduled after a successful dispatch.' ); + // The freshly-scheduled tick is reported as a UTC string. + $this->assertSame( gmdate( 'Y-m-d H:i:s', $scheduled ), $result['next_scheduled_at'] ); } /** From 8bbafdd0967a61f2365187d018b40c7bf1307aef Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 19 May 2026 12:35:06 -0700 Subject: [PATCH 7/7] Sitemaps abilities: format next_scheduled_at as ISO 8601 UTC (Z suffix) for unambiguous timezone --- .../abilities/class-sitemaps-abilities.php | 15 +++++++++------ .../tests/php/src/Sitemaps_Abilities_Test.php | 8 ++++---- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php index 1c4e6519ff24..167fbaeb0255 100644 --- a/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php +++ b/projects/plugins/jetpack/modules/sitemaps/abilities/class-sitemaps-abilities.php @@ -126,7 +126,7 @@ public static function get_abilities(): array { 'jetpack-sitemaps/request-rebuild' => array( 'label' => __( 'Request a Jetpack Sitemaps rebuild', 'jetpack' ), - '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 a "YYYY-MM-DD HH:mm:ss" UTC string, 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' ), + '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' ), 'input_schema' => array( 'type' => 'object', 'additionalProperties' => false, @@ -423,22 +423,25 @@ protected static function schedule_rebuild(): void { } /** - * When the next `jp_sitemap_cron_hook` build tick is scheduled, as a UTC - * "Y-m-d H:i:s" string, or null when nothing is scheduled. + * When the next `jp_sitemap_cron_hook` build tick is scheduled, as an + * ISO 8601 UTC string (e.g. `2026-05-19T19:33:20Z`), or null when nothing + * is scheduled. * * Returned alongside the dispatch result so callers immediately know when * the build they queued (or the one already pending) will actually run, * without a second round-trip. Null in the `running` case when the lock is * held but no future tick is queued. * - * UTC string (not `human_time_diff()`) for an unambiguous, locale-stable, - * machine-parseable value consistent with the rest of the sitemaps surface. + * ISO 8601 with the explicit `Z` zone designator (not `human_time_diff()`, + * not a bare "Y-m-d H:i:s") so the timezone is unambiguous and the value is + * locale-stable and machine-parseable — the same format the `sitemaps[]` + * `lastmod` values use in `get-status`. */ protected static function get_next_scheduled_at(): ?string { $timestamp = wp_next_scheduled( self::CRON_HOOK ); if ( false === $timestamp ) { return null; } - return gmdate( 'Y-m-d H:i:s', $timestamp ); + return gmdate( 'Y-m-d\TH:i:s\Z', $timestamp ); } } diff --git a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php index 95855c8fa991..df7816e9f359 100644 --- a/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php +++ b/projects/plugins/jetpack/tests/php/src/Sitemaps_Abilities_Test.php @@ -575,8 +575,8 @@ public function test_request_rebuild_returns_queued_when_cron_already_scheduled( // The previously-scheduled tick should still be the next one (i.e. we // didn't stack a new one at time() that would overshadow it). $this->assertSame( $future, wp_next_scheduled( 'jp_sitemap_cron_hook' ) ); - // next_scheduled_at reflects that pending tick as a UTC string. - $this->assertSame( gmdate( 'Y-m-d H:i:s', $future ), $result['next_scheduled_at'] ); + // next_scheduled_at reflects that pending tick as an ISO 8601 UTC string. + $this->assertSame( gmdate( 'Y-m-d\TH:i:s\Z', $future ), $result['next_scheduled_at'] ); } /** @@ -598,8 +598,8 @@ public function test_request_rebuild_dispatches_when_nothing_running_or_queued() $scheduled, 'A cron event should be scheduled after a successful dispatch.' ); - // The freshly-scheduled tick is reported as a UTC string. - $this->assertSame( gmdate( 'Y-m-d H:i:s', $scheduled ), $result['next_scheduled_at'] ); + // The freshly-scheduled tick is reported as an ISO 8601 UTC string. + $this->assertSame( gmdate( 'Y-m-d\TH:i:s\Z', $scheduled ), $result['next_scheduled_at'] ); } /**