PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 All 504 releases
← All changes | _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-admin-menu.php +635 -50 12.7.316.3-a.1 View file →
@@ -5,8 +5,14 @@
5 5 * @package automattic/jetpack
6 6 * @since 9.1.0
7 7 */
8 8
9 +use Automattic\Jetpack\Status\Host;
10 +
11 +if ( ! defined( 'ABSPATH' ) ) {
12 + exit( 0 );
13 +}
14 +
9 15 /**
10 16 * Class WPCOM_REST_API_V2_Endpoint_Admin_Menu
11 17 */
12 18 class WPCOM_REST_API_V2_Endpoint_Admin_Menu extends WP_REST_Controller {
@@ -33,8 +39,27 @@
33 39 */
34 40 private $dashicon_list;
35 41
36 42 /**
43 + * Lazily-built map of `menu_slug => nav-model entry`, populated from the
44 + * `Sidebar_Classifier` nav model when the public `wp-admin-sidebar` plugin
45 + * is loaded on the host. `null` means we have not yet attempted to build
46 + * the index for this request; an empty array means the classifier ran but
47 + * produced no items (e.g., gating denied).
48 + *
49 + * @var array<string, array>|null
50 + */
51 + private $sidebar_nav_index = null;
52 +
53 + /**
54 + * Group rows from the classifier nav model. Sibling to
55 + * `$sidebar_nav_index`; `null` when not built, empty array when no groups.
56 + *
57 + * @var array<string, array>|null
58 + */
59 + private $sidebar_nav_groups = null;
60 +
61 + /**
37 62 * WPCOM_REST_API_V2_Endpoint_Admin_Menu constructor.
38 63 */
39 64 public function __construct() {
40 65 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
@@ -82,10 +107,20 @@
82 107 * @param WP_REST_Request $request Full details about the request.
83 108 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
84 109 */
85 110 public function get_item( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
86 - require_once JETPACK__PLUGIN_DIR . '/modules/masterbar/admin-menu/load.php';
87 111
112 + /*
113 + * Load the `Jetpack_Admin` class, since it's only loaded on admin requests (not on API requests), and this is where
114 + * many Jetpack menus are registered. We don't need to run this on WPCOM because we replicate an admin request there.
115 + *
116 + * @see https://github.com/Automattic/jetpack/blob/dcdeb8fe772215b514bbbd6c4ddb38f6446e7ea1/projects/plugins/jetpack/load-jetpack.php#L61-L64
117 + * @see https://github.com/Automattic/jetpack/blob/dcdeb8fe772215b514bbbd6c4ddb38f6446e7ea1/projects/plugins/wpcomsh/feature-plugins/masterbar.php#L29
118 + */
119 + if ( ! ( new Host() )->is_wpcom_platform() ) {
120 + require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
121 + }
122 +
88 123 // All globals need to be declared for menu items to properly register.
89 124 global $admin_page_hooks, $menu, $menu_order, $submenu, $_wp_menu_nopriv, $_wp_submenu_nopriv; // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
90 125
91 126 require_once ABSPATH . 'wp-admin/includes/admin.php';
@@ -102,8 +137,12 @@
102 137 */
103 138 public function prepare_menu_for_response( array $menu ) {
104 139 global $submenu;
105 140
141 + // Best-effort hydration of the classifier nav model. Safe no-op when the
142 + // public `wp-admin-sidebar` plugin is not installed (non-WPCOM Jetpack).
143 + $this->maybe_build_sidebar_nav_index();
144 +
106 145 $data = array();
107 146
108 147 /**
109 148 * Note: if the shape of the API endpoint data changes it is important to also update
@@ -118,8 +157,14 @@
118 157 $submenu_items = array_values( $submenu[ $menu_item[2] ] );
119 158
120 159 // Add submenu items.
121 160 foreach ( $submenu_items as $submenu_item ) {
161 + // As $submenu_item can be null or false due to combination of plugins/themes, its value
162 + // must be checked before passing it to the prepare_submenu_item method. It may be related
163 + // to the common usage of null as a "hidden" submenu item like was fixed in CRM in #29945.
164 + if ( ! is_array( $submenu_item ) ) {
165 + continue;
166 + }
122 167 $submenu_item = $this->prepare_submenu_item( $submenu_item, $menu_item );
123 168 if ( ! empty( $submenu_item ) ) {
124 169 $item['children'][] = $submenu_item;
125 170 }
@@ -130,12 +175,355 @@
130 175 $data[] = $item;
131 176 }
132 177 }
133 178
134 - return array_filter( $data );
179 + $data = array_values( array_filter( $data ) );
180 +
181 + // When the public `wp-admin-sidebar` plugin is loaded (WPCOM and any
182 + // host that opts in), the response carries a sibling `groups[]` array
183 + // describing the synthetic group rows the classifier emitted. Wrapping
184 + // only happens when classifier data is available so non-WPCOM Jetpack
185 + // installs continue to receive the legacy flat-array shape.
186 + if ( null !== $this->sidebar_nav_groups ) {
187 + $data = $this->order_sidebar_grouped_items( $data );
188 + $response = array(
189 + 'menu' => $data,
190 + 'groups' => $this->prepare_groups_for_response(),
191 + );
192 +
193 + $layout_delta = $this->get_sidebar_layout_delta();
194 + if ( null !== $layout_delta ) {
195 + $response['layoutDelta'] = $layout_delta;
196 + }
197 +
198 + return $response;
199 + }
200 +
201 + return $data;
135 202 }
136 203
137 204 /**
205 + * Hydrate the classifier-driven nav index from the public
206 + * `wp-admin-sidebar` plugin. No-op when the plugin is not installed or
207 + * gating denied building a model on this request.
208 + */
209 + private function maybe_build_sidebar_nav_index() {
210 + if ( null !== $this->sidebar_nav_index ) {
211 + return;
212 + }
213 +
214 + if ( ! class_exists( 'Sidebar_Classifier' ) || ! class_exists( 'Sidebar_Signals' ) ) {
215 + return;
216 + }
217 +
218 + // Trigger a build if the classifier hasn't run yet on this request.
219 + // The classifier short-circuits when `wp_admin_sidebar_enabled` is
220 + // false, which is the default for any host that hasn't opted in.
221 + // @phan-suppress-next-line PhanUndeclaredClassMethod -- Sidebar_Classifier is provided by WPCOM's wp-admin-sidebar mu-plugin; guarded by class_exists() above.
222 + if ( null === Sidebar_Classifier::get_nav_model() ) {
223 + // @phan-suppress-next-line PhanUndeclaredClassMethod -- Sidebar_Classifier is provided by WPCOM's wp-admin-sidebar mu-plugin.
224 + Sidebar_Classifier::build_nav_model();
225 + }
226 +
227 + // @phan-suppress-next-line PhanUndeclaredClassMethod -- Sidebar_Classifier is provided by WPCOM's wp-admin-sidebar mu-plugin.
228 + $nav_model = Sidebar_Classifier::get_nav_model();
229 + if ( ! is_array( $nav_model ) ) {
230 + return;
231 + }
232 +
233 + $index = array();
234 + $collect = function ( array $items ) use ( &$index, &$collect ) {
235 + foreach ( $items as $position => $item ) {
236 + if ( isset( $item['menuSlug'] ) && '' !== $item['menuSlug'] ) {
237 + if ( ! isset( $item['default_weight'] ) && ! isset( $item['_weight'] ) ) {
238 + $item['default_weight'] = (int) $position;
239 + }
240 + $parent_slug = array_key_exists( 'parent', $item ) && null !== $item['parent'] ? (string) $item['parent'] : null;
241 + $index[ $this->get_sidebar_nav_index_key( $item['menuSlug'], $parent_slug ) ] = $item;
242 + if ( ! isset( $index[ $item['menuSlug'] ] ) ) {
243 + $index[ $item['menuSlug'] ] = $item;
244 + }
245 + }
246 + if ( ! empty( $item['children'] ) && is_array( $item['children'] ) ) {
247 + $collect( $item['children'] );
248 + }
249 + }
250 + };
251 +
252 + if ( ! empty( $nav_model['top_level'] ) && is_array( $nav_model['top_level'] ) ) {
253 + $collect( $nav_model['top_level'] );
254 + }
255 + if ( ! empty( $nav_model['groups'] ) && is_array( $nav_model['groups'] ) ) {
256 + foreach ( $nav_model['groups'] as $group ) {
257 + if ( ! empty( $group['children'] ) && is_array( $group['children'] ) ) {
258 + $collect( $group['children'] );
259 + }
260 + }
261 + }
262 +
263 + $this->sidebar_nav_index = $index;
264 + $this->sidebar_nav_groups = ! empty( $nav_model['groups'] ) && is_array( $nav_model['groups'] )
265 + ? $nav_model['groups']
266 + : array();
267 + }
268 +
269 + /**
270 + * Keep grouped items in the classifier's child order while preserving the
271 + * relative position of ungrouped top-level rows. The public classifier
272 + * sorts each group before exposing the nav model, but Calypso receives a
273 + * flat `menu` array and buckets grouped items in response order.
274 + *
275 + * @param array $items Prepared top-level menu items.
276 + * @return array Prepared items with grouped rows sorted per group.
277 + */
278 + private function order_sidebar_grouped_items( array $items ) {
279 + $grouped_items = array();
280 + foreach ( $items as $item ) {
281 + if ( isset( $item['group_id'] ) && is_string( $item['group_id'] ) && '' !== $item['group_id'] ) {
282 + $grouped_items[ $item['group_id'] ][] = $item;
283 + }
284 + }
285 +
286 + if ( empty( $grouped_items ) ) {
287 + return $items;
288 + }
289 +
290 + foreach ( $grouped_items as &$group_items ) {
291 + usort(
292 + $group_items,
293 + static function ( $a, $b ) {
294 + $wa = (int) ( $a['default_weight'] ?? PHP_INT_MAX );
295 + $wb = (int) ( $b['default_weight'] ?? PHP_INT_MAX );
296 + if ( $wa === $wb ) {
297 + return strcmp( (string) ( $a['itemId'] ?? $a['slug'] ?? '' ), (string) ( $b['itemId'] ?? $b['slug'] ?? '' ) );
298 + }
299 + return $wa <=> $wb;
300 + }
301 + );
302 + }
303 + unset( $group_items );
304 +
305 + $group_offsets = array();
306 + foreach ( $items as $index => $item ) {
307 + if ( ! isset( $item['group_id'] ) || ! is_string( $item['group_id'] ) || '' === $item['group_id'] ) {
308 + continue;
309 + }
310 +
311 + $group_id = $item['group_id'];
312 + $offset = $group_offsets[ $group_id ] ?? 0;
313 + if ( isset( $grouped_items[ $group_id ][ $offset ] ) ) {
314 + $items[ $index ] = $grouped_items[ $group_id ][ $offset ];
315 + }
316 + $group_offsets[ $group_id ] = $offset + 1;
317 + }
318 +
319 + return $items;
320 + }
321 +
322 + /**
323 + * Look up the classifier nav-model entry for a given menu slug.
324 + *
325 + * @param string $menu_slug Menu slug as registered in `$menu` / `$submenu`.
326 + * @param string|null $parent_slug Parent menu slug, or null for top-level items.
327 + * @return array|null Nav-model entry or null if the slug is not in the index.
328 + */
329 + private function get_sidebar_nav_entry( $menu_slug, $parent_slug = null ) {
330 + if ( null === $this->sidebar_nav_index || '' === $menu_slug ) {
331 + return null;
332 + }
333 + $key = $this->get_sidebar_nav_index_key( $menu_slug, $parent_slug );
334 + if ( isset( $this->sidebar_nav_index[ $key ] ) ) {
335 + return $this->sidebar_nav_index[ $key ];
336 + }
337 + return $this->sidebar_nav_index[ $menu_slug ] ?? null;
338 + }
339 +
340 + /**
341 + * Compose an index key that distinguishes top-level rows from submenu rows
342 + * sharing the same raw menu slug.
343 + *
344 + * @param string $menu_slug Menu slug as registered in `$menu` / `$submenu`.
345 + * @param string|null $parent_slug Parent menu slug, or null for top-level items.
346 + * @return string Index key.
347 + */
348 + private function get_sidebar_nav_index_key( $menu_slug, $parent_slug = null ) {
349 + return ( null === $parent_slug ? '' : (string) $parent_slug ) . "\0" . (string) $menu_slug;
350 + }
351 +
352 + /**
353 + * Build the schema-shaped `signal` subobject for an item from a classifier
354 + * nav-model entry. Returns null when the entry has no signal data.
355 + *
356 + * The returned shape is fixed (all keys present, missing fields null) so
357 + * Calypso can read it without optional-chaining gymnastics.
358 + *
359 + * @param array $nav_entry Classifier nav-model entry.
360 + * @return array|null
361 + */
362 + private function map_signal_from_nav_entry( array $nav_entry ) {
363 + if ( empty( $nav_entry['signal'] ) || ! is_array( $nav_entry['signal'] ) ) {
364 + return null;
365 + }
366 + $signal = $nav_entry['signal'];
367 + return array(
368 + 'count' => $signal['count'] ?? null,
369 + 'numeric_badge' => $signal['numeric_badge'] ?? null,
370 + 'badge' => $signal['badge'] ?? null,
371 + 'inline_text' => $signal['inline_text'] ?? null,
372 + 'inline_icon' => $signal['inline_icon'] ?? null,
373 + 'attention' => ! empty( $signal['attention'] ),
374 + );
375 + }
376 +
377 + /**
378 + * Attach classifier-derived `group_id` + `signal` fields to a prepared item.
379 + * No-op when the classifier index is not available or the slug isn't found.
380 + *
381 + * @param array $item Prepared item (top-level or submenu).
382 + * @param string $menu_slug Raw menu slug used to look up the nav entry.
383 + * @param string|null $parent_slug Parent menu slug, or null for top-level items.
384 + * @return array Item with fields possibly added.
385 + */
386 + private function attach_sidebar_fields( array $item, $menu_slug, $parent_slug = null ) {
387 + $nav_entry = $this->get_sidebar_nav_entry( $menu_slug, $parent_slug );
388 + if ( null === $nav_entry ) {
389 + return $item;
390 + }
391 +
392 + // `default_group` in the classifier maps to `group_id` on the wire.
393 + $default_group = $nav_entry['default_group'] ?? null;
394 + $item['group_id'] = is_string( $default_group ) && '' !== $default_group ? $default_group : null;
395 + $item['signal'] = $this->map_signal_from_nav_entry( $nav_entry );
396 +
397 + if ( isset( $nav_entry['itemId'] ) && '' !== $nav_entry['itemId'] ) {
398 + $item['itemId'] = (string) $nav_entry['itemId'];
399 + }
400 + if ( isset( $nav_entry['source'] ) && '' !== $nav_entry['source'] ) {
401 + $item['source'] = (string) $nav_entry['source'];
402 + }
403 + if ( isset( $nav_entry['reassignable'] ) ) {
404 + $item['reassignable'] = (bool) $nav_entry['reassignable'];
405 + }
406 + if ( isset( $nav_entry['default_weight'] ) ) {
407 + $item['default_weight'] = (int) $nav_entry['default_weight'];
408 + } elseif ( isset( $nav_entry['_weight'] ) ) {
409 + $item['default_weight'] = (int) $nav_entry['_weight'];
410 + }
411 +
412 + return $item;
413 + }
414 +
415 + /**
416 + * Build the response-shape `groups[]` array from the cached classifier rows.
417 + *
418 + * Each element carries the group's stable id, label, default collapsed
419 + * state and the aggregated signal that the classifier already computed.
420 + *
421 + * @return array
422 + */
423 + private function prepare_groups_for_response() {
424 + if ( empty( $this->sidebar_nav_groups ) ) {
425 + return array();
426 + }
427 +
428 + $groups = array();
429 + foreach ( $this->sidebar_nav_groups as $group ) {
430 + if ( empty( $group['id'] ) ) {
431 + continue;
432 + }
433 + $signal = isset( $group['signal'] ) && is_array( $group['signal'] ) ? $group['signal'] : array();
434 + $groups[] = array(
435 + 'id' => (string) $group['id'],
436 + 'label' => isset( $group['title'] ) ? (string) $group['title'] : '',
437 + 'default_expanded' => false,
438 + 'signal' => array(
439 + 'attention' => ! empty( $signal['attention'] ),
440 + 'count' => isset( $signal['count'] ) ? (int) $signal['count'] : 0,
441 + ),
442 + );
443 + }
444 + return $groups;
445 + }
446 +
447 + /**
448 + * Read the saved Calypso-compatible layout delta from the public plugin's
449 + * storage adapter. Returns null when the public plugin storage API is not
450 + * loaded, which keeps non-redesigned hosts on the legacy contract.
451 + *
452 + * @return array|null
453 + */
454 + private function get_sidebar_layout_delta() {
455 + if ( ! interface_exists( 'Sidebar_Layout_Storage' ) || ! class_exists( 'WP_User_Meta_Storage' ) ) {
456 + return null;
457 + }
458 +
459 + $user_id = get_current_user_id();
460 + if ( ! $user_id ) {
461 + return null;
462 + }
463 +
464 + $site_id = (int) get_current_blog_id();
465 + $storage = $this->get_sidebar_layout_storage();
466 + $layouts = $storage->get_layouts( $user_id );
467 +
468 + if ( isset( $layouts[ $site_id ] ) && is_array( $layouts[ $site_id ] ) ) {
469 + return $this->normalize_sidebar_layout_delta( $layouts[ $site_id ] );
470 + }
471 +
472 + return $this->empty_sidebar_layout_delta();
473 + }
474 +
475 + /**
476 + * Resolve the public plugin storage adapter. Mirrors Sidebar_Data_Planner.
477 + *
478 + * @return object
479 + */
480 + private function get_sidebar_layout_storage() {
481 + // @phan-suppress-next-line PhanUndeclaredClassMethod -- WP_User_Meta_Storage is provided by WPCOM's wp-admin-sidebar mu-plugin; guarded by class_exists() above.
482 + $default = new WP_User_Meta_Storage();
483 + $bound = apply_filters( 'wp_admin_sidebar_storage', $default );
484 + $bound = apply_filters_deprecated(
485 + 'wpcom_admin_sidebar_storage',
486 + array( $bound ),
487 + '0.1.0',
488 + 'wp_admin_sidebar_storage'
489 + );
490 + // @phan-suppress-next-line PhanUndeclaredClassInstanceof -- Sidebar_Layout_Storage is provided by WPCOM's wp-admin-sidebar mu-plugin; guarded by interface_exists() above.
491 + return $bound instanceof Sidebar_Layout_Storage ? $bound : $default;
492 + }
493 +
494 + /**
495 + * Normalize a stored LayoutDelta into the response shape Calypso expects.
496 + *
497 + * @param array $delta Stored layout delta.
498 + * @return array
499 + */
500 + private function normalize_sidebar_layout_delta( array $delta ) {
501 + if ( ! isset( $delta['overrides'] ) || ! is_array( $delta['overrides'] ) ) {
502 + return $this->empty_sidebar_layout_delta();
503 + }
504 +
505 + return array(
506 + 'version' => isset( $delta['version'] ) ? (int) $delta['version'] : 1,
507 + 'updated_at' => isset( $delta['updated_at'] ) ? (int) $delta['updated_at'] : 0,
508 + 'overrides' => array_values( $delta['overrides'] ),
509 + );
510 + }
511 +
512 + /**
513 + * Empty-delta shape used when a redesigned host has no saved layout yet.
514 + *
515 + * @return array
516 + */
517 + private function empty_sidebar_layout_delta() {
518 + return array(
519 + 'version' => 1,
520 + 'updated_at' => 0,
521 + 'overrides' => array(),
522 + );
523 + }
524 +
525 + /**
138 526 * Retrieves the admin menu's schema, conforming to JSON Schema.
139 527 *
140 528 * Note: if the shape of the API endpoint data changes it is important to also update
141 529 * the corresponding schema.js file.
@@ -144,71 +532,218 @@
144 532 *
145 533 * @return array Item schema data.
146 534 */
147 535 public function get_item_schema() {
148 - return array(
149 - '$schema' => 'http://json-schema.org/draft-04/schema#',
150 - 'title' => 'Admin Menu',
536 + // Shape of the optional `signal` subobject attached to each item by the
537 + // public `wp-admin-sidebar` classifier (when loaded). Mirrors the snake
538 + // case shape produced by `Sidebar_Signals::map_to_nav` so Calypso can
539 + // consume one canonical field set end-to-end. All keys are present and
540 + // nullable; consumers don't need optional-chaining.
541 + $signal_schema = array(
542 + 'description' => 'Per-item attention/count/badge data emitted by the wp-admin-sidebar classifier. Null when no signal data was extracted.',
543 + 'type' => array( 'object', 'null' ),
544 + 'properties' => array(
545 + 'count' => array( 'type' => array( 'integer', 'null' ) ),
546 + 'numeric_badge' => array( 'type' => array( 'integer', 'null' ) ),
547 + 'badge' => array( 'type' => array( 'string', 'null' ) ),
548 + 'inline_text' => array( 'type' => array( 'string', 'null' ) ),
549 + 'inline_icon' => array( 'type' => array( 'string', 'null' ) ),
550 + 'attention' => array( 'type' => 'boolean' ),
551 + ),
552 + );
553 +
554 + $submenu_item_schema = array(
151 555 'type' => 'object',
152 556 'properties' => array(
153 - 'count' => array(
557 + 'count' => array(
154 558 'description' => 'Core/Plugin/Theme update count or unread comments count.',
155 559 'type' => 'integer',
156 560 ),
157 - 'icon' => array(
561 + 'parent' => array(
562 + 'type' => 'string',
563 + ),
564 + 'slug' => array(
565 + 'type' => 'string',
566 + ),
567 + 'title' => array(
568 + 'type' => 'string',
569 + ),
570 + 'type' => array(
571 + 'enum' => array( 'submenu-item' ),
572 + 'type' => 'string',
573 + ),
574 + 'url' => array(
575 + 'format' => 'uri',
576 + 'type' => 'string',
577 + ),
578 + 'itemId' => array(
579 + 'description' => 'Compound sidebar item id emitted by the wp-admin-sidebar classifier. Only present when the classifier is loaded.',
580 + 'type' => 'string',
581 + ),
582 + 'source' => array(
583 + 'description' => 'Classifier source kind, e.g. core, plugin, or wpcom. Only present when the classifier is loaded.',
584 + 'type' => 'string',
585 + ),
586 + 'default_weight' => array(
587 + 'description' => 'Default ordering hint from the classifier. Only present when the classifier exposes it.',
588 + 'type' => 'integer',
589 + ),
590 + 'reassignable' => array(
591 + 'description' => 'Whether the sidebar customizer may move this item. Only present when the classifier is loaded.',
592 + 'type' => 'boolean',
593 + ),
594 + 'group_id' => array(
595 + 'description' => 'Group this submenu belongs to (e.g., "plugins"). Null for non-grouped items. Only present when the wp-admin-sidebar classifier is loaded.',
596 + 'type' => array( 'string', 'null' ),
597 + ),
598 + 'signal' => $signal_schema,
599 + ),
600 + );
601 +
602 + $menu_item_schema = array(
603 + 'type' => 'object',
604 + 'properties' => array(
605 + 'count' => array(
606 + 'description' => 'Core/Plugin/Theme update count or unread comments count.',
607 + 'type' => 'integer',
608 + ),
609 + 'icon' => array(
158 610 'description' => 'Menu item icon. Dashicon slug or base64-encoded SVG.',
159 611 'type' => 'string',
160 612 ),
161 - 'inlineText' => array(
613 + 'inlineText' => array(
162 614 'description' => 'Additional text to be added inline with the menu title.',
163 615 'type' => 'string',
164 616 ),
165 - 'badge' => array(
617 + 'inlineIcon' => array(
618 + 'description' => 'Dashicon slug to be displayed inline with the menu title.',
619 + 'type' => 'string',
620 + ),
621 + 'badge' => array(
166 622 'description' => 'Badge to be added inline with the menu title.',
167 623 'type' => 'string',
168 624 ),
169 - 'slug' => array(
625 + 'slug' => array(
170 626 'type' => 'string',
171 627 ),
172 - 'children' => array(
173 - 'items' => array(
174 - 'count' => array(
175 - 'description' => 'Core/Plugin/Theme update count or unread comments count.',
176 - 'type' => 'integer',
177 - ),
178 - 'parent' => array(
179 - 'type' => 'string',
180 - ),
181 - 'slug' => array(
182 - 'type' => 'string',
183 - ),
184 - 'title' => array(
185 - 'type' => 'string',
186 - ),
187 - 'type' => array(
188 - 'enum' => array( 'submenu-item' ),
189 - 'type' => 'string',
190 - ),
191 - 'url' => array(
192 - 'format' => 'uri',
193 - 'type' => 'string',
194 - ),
195 - ),
628 + 'children' => array(
196 629 'type' => 'array',
630 + 'items' => $submenu_item_schema,
197 631 ),
198 - 'title' => array(
632 + 'title' => array(
199 633 'type' => 'string',
200 634 ),
201 - 'type' => array(
635 + 'type' => array(
202 636 'enum' => array( 'separator', 'menu-item' ),
203 637 'type' => 'string',
204 638 ),
205 - 'url' => array(
639 + 'url' => array(
206 640 'format' => 'uri',
207 641 'type' => 'string',
208 642 ),
643 + 'itemId' => array(
644 + 'description' => 'Compound sidebar item id emitted by the wp-admin-sidebar classifier. Only present when the classifier is loaded.',
645 + 'type' => 'string',
646 + ),
647 + 'source' => array(
648 + 'description' => 'Classifier source kind, e.g. core, plugin, or wpcom. Only present when the classifier is loaded.',
649 + 'type' => 'string',
650 + ),
651 + 'default_weight' => array(
652 + 'description' => 'Default ordering hint from the classifier. Only present when the classifier exposes it.',
653 + 'type' => 'integer',
654 + ),
655 + 'reassignable' => array(
656 + 'description' => 'Whether the sidebar customizer may move this item. Only present when the classifier is loaded.',
657 + 'type' => 'boolean',
658 + ),
659 + 'group_id' => array(
660 + 'description' => 'Group this top-level item belongs to (e.g., "plugins"). Null for non-grouped items. Only present when the wp-admin-sidebar classifier is loaded.',
661 + 'type' => array( 'string', 'null' ),
662 + ),
663 + 'signal' => $signal_schema,
209 664 ),
210 665 );
666 +
667 + $group_schema = array(
668 + 'type' => 'object',
669 + 'properties' => array(
670 + 'id' => array( 'type' => 'string' ),
671 + 'label' => array( 'type' => 'string' ),
672 + 'default_expanded' => array( 'type' => 'boolean' ),
673 + 'signal' => array(
674 + 'type' => 'object',
675 + 'properties' => array(
676 + 'attention' => array( 'type' => 'boolean' ),
677 + 'count' => array( 'type' => 'integer' ),
678 + ),
679 + ),
680 + ),
681 + );
682 +
683 + $layout_delta_schema = array(
684 + 'description' => 'Saved per-user sidebar layout delta. Only present when the wp-admin-sidebar storage API is loaded.',
685 + 'type' => array( 'object', 'null' ),
686 + 'properties' => array(
687 + 'version' => array( 'type' => 'integer' ),
688 + 'updated_at' => array( 'type' => 'integer' ),
689 + 'overrides' => array(
690 + 'type' => 'array',
691 + 'items' => array(
692 + 'type' => 'object',
693 + 'properties' => array(
694 + 'itemId' => array( 'type' => 'string' ),
695 + 'position' => array(
696 + 'type' => 'object',
697 + 'properties' => array(
698 + 'kind' => array(
699 + 'type' => 'string',
700 + 'enum' => array( 'top_level', 'in_group' ),
701 + ),
702 + 'group_id' => array( 'type' => 'string' ),
703 + 'index' => array( 'type' => 'integer' ),
704 + ),
705 + ),
706 + ),
707 + ),
708 + ),
709 + ),
710 + );
711 +
712 + $legacy_response_schema = array(
713 + 'description' => 'Legacy flat admin menu response used when the wp-admin-sidebar classifier is not loaded or not enabled.',
714 + 'type' => 'array',
715 + 'items' => $menu_item_schema,
716 + );
717 +
718 + $redesigned_response_schema = array(
719 + 'description' => 'Wrapped admin menu response used when the wp-admin-sidebar classifier is loaded and enabled.',
720 + 'type' => 'object',
721 + 'required' => array( 'menu', 'groups' ),
722 + 'properties' => array(
723 + 'menu' => array(
724 + 'description' => 'Top-level menu items.',
725 + 'type' => 'array',
726 + 'items' => $menu_item_schema,
727 + ),
728 + 'groups' => array(
729 + 'description' => 'Synthetic group rows describing the sidebar grouping shape. Empty array if no plugin items grouped.',
730 + 'type' => 'array',
731 + 'items' => $group_schema,
732 + ),
733 + 'layoutDelta' => $layout_delta_schema,
734 + ),
735 + );
736 +
737 + return array(
738 + '$schema' => 'http://json-schema.org/draft-04/schema#',
739 + 'title' => 'Admin Menu',
740 + 'type' => array( 'array', 'object' ),
741 + 'anyOf' => array(
742 + $legacy_response_schema,
743 + $redesigned_response_schema,
744 + ),
745 + );
211 746 }
212 747
213 748 /**
214 749 * Sets up a menu item for consumption by Calypso.
@@ -233,9 +768,9 @@
233 768 return array();
234 769 }
235 770
236 771 // Exclude hidden menu items.
237 - if ( false !== strpos( $menu_item[4], 'hide-if-js' ) ) {
772 + if ( str_contains( $menu_item[4], 'hide-if-js' ) ) {
238 773 // Exclude submenu items as well.
239 774 if ( ! empty( $submenu[ $menu_item[2] ] ) ) {
240 775 // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
241 776 $submenu[ $menu_item[2] ] = array();
@@ -243,9 +778,9 @@
243 778 return array();
244 779 }
245 780
246 781 // Handle menu separators.
247 - if ( false !== strpos( $menu_item[4], 'wp-menu-separator' ) ) {
782 + if ( str_contains( $menu_item[4], 'wp-menu-separator' ) ) {
248 783 return array(
249 784 'type' => 'separator',
250 785 );
251 786 }
@@ -273,8 +808,23 @@
273 808 if ( ! empty( $parsed_item ) ) {
274 809 $item = array_merge( $item, $parsed_item );
275 810 }
276 811
812 + // Additive: authoritative top-level total from the central menu-badges
813 + // registry, when the package is loaded. Overlays any count parsed from
814 + // title markup above.
815 + if ( 'jetpack' === $menu_item[2] && class_exists( '\Automattic\Jetpack\Menu_Badges\Notification_Counts' ) ) {
816 + $registry_total = \Automattic\Jetpack\Menu_Badges\Notification_Counts::get_total();
817 + if ( $registry_total > 0 ) {
818 + $item['count'] = $registry_total;
819 + }
820 + }
821 +
822 + // Additive: classifier-derived `group_id` + `signal` for the redesigned
823 + // sidebar. Match key is the raw menu slug (`$menu_item[2]`), which is
824 + // what the public plugin's `Sidebar_Classifier` keys nav items by.
825 + $item = $this->attach_sidebar_fields( $item, $menu_item[2] );
826 +
277 827 return $item;
278 828 }
279 829
280 830 /**
@@ -290,9 +840,9 @@
290 840 return array();
291 841 }
292 842
293 843 // Exclude hidden submenu items.
294 - if ( isset( $submenu_item[4] ) && false !== strpos( $submenu_item[4], 'hide-if-js' ) ) {
844 + if ( isset( $submenu_item[4] ) && str_contains( $submenu_item[4], 'hide-if-js' ) ) {
295 845 return array();
296 846 }
297 847
298 848 $item = array(
@@ -307,8 +857,23 @@
307 857 if ( ! empty( $parsed_item ) ) {
308 858 $item = array_merge( $item, $parsed_item );
309 859 }
310 860
861 + // Additive: authoritative count from the central menu-badges registry,
862 + // when the package is loaded. Overlays any count parsed from title
863 + // markup above.
864 + if ( class_exists( '\Automattic\Jetpack\Menu_Badges\Notification_Counts' ) ) {
865 + $registry_count = \Automattic\Jetpack\Menu_Badges\Notification_Counts::get_for_menu( $submenu_item[2] );
866 + if ( $registry_count > 0 ) {
867 + $item['count'] = $registry_count;
868 + }
869 + }
870 +
871 + // Additive: classifier-derived fields. Same lookup contract as the
872 + // top-level branch above; submenu rows live under their own `menuSlug`
873 + // in the nav model.
874 + $item = $this->attach_sidebar_fields( $item, $submenu_item[2], $menu_item[2] );
875 +
311 876 return $item;
312 877 }
313 878
314 879 /**
@@ -322,11 +887,11 @@
322 887
323 888 if ( ! empty( $icon ) && 'none' !== $icon && 'div' !== $icon ) {
324 889 $img = esc_url( $icon );
325 890
326 - if ( 0 === strpos( $icon, 'data:image/svg+xml' ) ) {
891 + if ( str_starts_with( $icon, 'data:image/svg+xml' ) ) {
327 892 $img = $icon;
328 - } elseif ( 0 === strpos( $icon, 'dashicons-' ) ) {
893 + } elseif ( str_starts_with( $icon, 'dashicons-' ) ) {
329 894 $img = $this->prepare_dashicon( $icon );
330 895 }
331 896 }
332 897
@@ -342,9 +907,9 @@
342 907 * @return string If the dashicon exists in core we return the dashicon, otherwise we return the default dashicon.
343 908 */
344 909 private function prepare_dashicon( $icon ) {
345 910 if ( empty( $this->dashicon_set ) ) {
346 - $this->dashicon_list = include JETPACK__PLUGIN_DIR . '/modules/masterbar/admin-menu/dashicon-set.php';
911 + $this->dashicon_list = include JETPACK__PLUGIN_DIR . 'jetpack_vendor/automattic/jetpack-masterbar/src/admin-menu/dashicon-set.php';
347 912 }
348 913
349 914 if ( isset( $this->dashicon_list[ $icon ] ) && $this->dashicon_list[ $icon ] ) {
350 915 return $icon;
@@ -363,9 +928,9 @@
363 928 private function prepare_menu_item_url( $url, $parent_slug = '' ) {
364 929 // External URLS.
365 930 if ( preg_match( '/^https?:\/\//', $url ) ) {
366 931 // Allow URLs pointing to WordPress.com.
367 - if ( 0 === strpos( $url, 'https://wordpress.com/' ) ) {
932 + if ( str_starts_with( $url, 'https://wordpress.com/' ) ) {
368 933 // Calypso needs the domain removed so they're not interpreted as external links.
369 934 $url = str_replace( 'https://wordpress.com', '', $url );
370 935 // Replace special characters with their correct entities e.g. &amp; to &.
371 936 return wp_specialchars_decode( esc_url_raw( $url ) );
@@ -371,15 +936,15 @@
371 936 return wp_specialchars_decode( esc_url_raw( $url ) );
372 937 }
373 938
374 939 // Allow URLs pointing to Jetpack.com.
375 - if ( 0 === strpos( $url, 'https://jetpack.com/' ) ) {
940 + if ( str_starts_with( $url, 'https://jetpack.com/' ) ) {
376 941 // Replace special characters with their correct entities e.g. &amp; to &.
377 942 return wp_specialchars_decode( esc_url_raw( $url ) );
378 943 }
379 944
380 945 // Disallow other external URLs.
381 - if ( 0 !== strpos( $url, get_site_url() ) ) {
946 + if ( ! str_starts_with( $url, get_site_url() ) ) {
382 947 return '';
383 948 }
384 949 // The URL matches that of the site, treat it as an internal URL.
385 950 }
@@ -403,9 +968,9 @@
403 968 $admin_is_parent = ! empty( $menu_hook ) || ( ( 'index.php' !== $parent_slug ) && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! file_exists( ABSPATH . "/wp-admin/$parent_file" ) );
404 969 }
405 970
406 971 if (
407 - ( false === $admin_is_parent && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! is_dir( WP_PLUGIN_DIR . "/$parent_file" ) ) ||
972 + ( ! $admin_is_parent && file_exists( WP_PLUGIN_DIR . "/$parent_file" ) && ! is_dir( WP_PLUGIN_DIR . "/$parent_file" ) ) ||
408 973 ( file_exists( ABSPATH . "/wp-admin/$parent_file" ) && ! is_dir( ABSPATH . "/wp-admin/$parent_file" ) )
409 974 ) {
410 975 $url = add_query_arg( array( 'page' => $url ), admin_url( $parent_slug ) );
411 976 } else {
@@ -427,12 +992,17 @@
427 992 * @param string $title Title to parse.
428 993 * @return array
429 994 */
430 995 private function parse_menu_item( $title ) {
996 + // Handle non-string input
997 + if ( ! is_string( $title ) ) {
998 + return array();
999 + }
1000 +
431 1001 $item = array();
432 1002
433 1003 if (
434 - false !== strpos( $title, 'count-' )
1004 + str_contains( $title, 'count-' )
435 1005 && preg_match( '/<span class=".+\s?count-(\d*).+\s?<\/span><\/span>/', $title, $matches )
436 1006 ) {
437 1007
438 1008 $count = (int) ( $matches[1] );
@@ -445,9 +1015,9 @@
445 1015 $title = trim( str_replace( $matches[0], '', $title ) );
446 1016 }
447 1017
448 1018 if (
449 - false !== strpos( $title, 'inline-text' )
1019 + str_contains( $title, 'inline-text' )
450 1020 && preg_match( '/<span class="inline-text".+\s?>(.+)<\/span>/', $title, $matches )
451 1021 ) {
452 1022
453 1023 $text = $matches[1];
@@ -460,9 +1030,24 @@
460 1030 $title = trim( str_replace( $matches[0], '', $title ) );
461 1031 }
462 1032
463 1033 if (
464 - false !== strpos( $title, 'awaiting-mod' )
1034 + str_contains( $title, 'inline-icon' )
1035 + && preg_match( '/<span class="inline-icon dashicons (dashicons-[^"]+)"[^>]*><\/span>/', $title, $matches )
1036 + ) {
1037 +
1038 + $icon = $matches[1];
1039 + if ( $icon ) {
1040 + // Keep the dashicon slug in the item array.
1041 + $item['inlineIcon'] = $icon;
1042 + }
1043 +
1044 + // Finally remove the markup.
1045 + $title = trim( str_replace( $matches[0], '', $title ) );
1046 + }
1047 +
1048 + if (
1049 + str_contains( $title, 'awaiting-mod' )
465 1050 && preg_match( '/<span class="awaiting-mod">(.+)<\/span>/', $title, $matches )
466 1051 ) {
467 1052
468 1053 $text = $matches[1];