Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.30% covered (success)
91.30%
42 / 46
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Menu_Renderer
91.30% covered (success)
91.30%
42 / 46
60.00% covered (warning)
60.00%
3 / 5
28.52
0.00% covered (danger)
0.00%
0 / 1
 badge_markup
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 strip
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
10.46
 has_registered_slug
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 find_parent_index
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
9.24
1<?php
2/**
3 * Renders notification-count badges into the wp-admin $menu/$submenu globals.
4 *
5 * @package automattic/jetpack-menu-badges
6 */
7
8namespace Automattic\Jetpack\Menu_Badges;
9
10/**
11 * Reads the Notification_Counts registry and writes the top-level and submenu
12 * badges. Sole writer of Jetpack menu badges in wp-admin. Idempotent.
13 */
14class Menu_Renderer {
15
16    /**
17     * Build a single badge span.
18     *
19     * @param string $id       Owning entry id (or 'total').
20     * @param int    $count    Count to show.
21     * @param bool   $is_total Whether this is the top-level total badge.
22     * @return string
23     */
24    public static function badge_markup( $id, $count, $is_total = false ) {
25        $count = (int) $count;
26        // A zero-count badge still renders (so client live-updates have an element to
27        // target) but ships hidden. The inline style trails the data-jp-* attributes so
28        // strip()'s idempotency regex still matches; setBadgeCount() clears it to reveal.
29        $hidden = $count > 0 ? '' : ' style="display:none"';
30        // The top-level Jetpack total badge carries the core `awaiting-mod` and
31        // `update-plugins` classes so it picks up WordPress's standard menu-bubble styling;
32        // submenu badges use only our own `menu-counter` hook. `menu-counter` is always
33        // present for CSS/JS targeting.
34        $classes = $is_total ? 'awaiting-mod update-plugins menu-counter' : 'menu-counter';
35        $attrs   = sprintf(
36            'class="%1$s count-%2$d" data-jp-menu-badge="%3$s" data-jp-menu-count="%2$d"%4$s%5$s',
37            $classes,
38            $count,
39            esc_attr( $id ),
40            $is_total ? ' data-jp-menu-badge-total="1"' : '',
41            $hidden
42        );
43        return sprintf(
44            ' <span %1$s><span class="count">%2$s</span></span>',
45            $attrs,
46            number_format_i18n( $count )
47        );
48    }
49
50    /**
51     * Strip any badge this renderer previously wrote from a menu title (idempotency).
52     *
53     * @param string $title Menu title.
54     * @return string
55     */
56    private static function strip( $title ) {
57        return trim( (string) preg_replace( '/\s*<span class="[^"]*menu-counter count-\d+" data-jp-menu-badge=.*$/s', '', (string) $title ) );
58    }
59
60    /**
61     * Render badges into the current $menu/$submenu globals.
62     *
63     * @return void
64     */
65    public static function render() {
66        global $menu, $submenu;
67
68        if ( ! is_array( $menu ) ) {
69            return;
70        }
71
72        $entries = Notification_Counts::all();
73
74        // Submenu badges: one per registered menu_slug. A registered slug always gets a
75        // badge span — even at count 0 — so a later client live-update (0 -> positive)
76        // has an element to reveal. badge_markup() ships the zero case hidden.
77        if ( isset( $submenu['jetpack'] ) && is_array( $submenu['jetpack'] ) ) {
78            foreach ( $submenu['jetpack'] as $i => $item ) {
79                if ( ! isset( $item[2] ) || ! isset( $item[0] ) ) {
80                    continue;
81                }
82                $slug  = $item[2];
83                $title = self::strip( $item[0] );
84                if ( self::has_registered_slug( $entries, $slug ) ) {
85                    $title .= self::badge_markup( $slug, Notification_Counts::get_for_menu( $slug ) );
86                }
87                // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
88                $submenu['jetpack'][ $i ][0] = $title;
89            }
90        }
91
92        // Nothing registered: leave the Jetpack parent untouched (no badge to write or strip).
93        if ( empty( $entries ) ) {
94            return;
95        }
96
97        // Top-level total on the Jetpack parent. Rendered even at 0 (hidden) so a live
98        // update can reveal it without a reload.
99        $parent_index = self::find_parent_index( $menu );
100        if ( null === $parent_index ) {
101            return;
102        }
103        // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
104        $menu[ $parent_index ][0] = self::strip( $menu[ $parent_index ][0] ) . self::badge_markup( 'total', Notification_Counts::get_total(), true );
105    }
106
107    /**
108     * Whether any registered entry badges the given submenu slug.
109     *
110     * @param array<string,array> $entries Registry entries (from Notification_Counts::all()).
111     * @param string              $slug    Submenu slug.
112     * @return bool
113     */
114    private static function has_registered_slug( array $entries, $slug ) {
115        foreach ( $entries as $entry ) {
116            if ( isset( $entry['menu_slug'] ) && $entry['menu_slug'] === $slug ) {
117                return true;
118            }
119        }
120        return false;
121    }
122
123    /**
124     * Locate the Jetpack top-level menu row to carry the total badge.
125     *
126     * Prefers the row whose slug (item[2]) is 'jetpack' — portable across self-hosted,
127     * Atomic, and WP.com Simple, where jetpack-mu-wpcom builds the parent with a different
128     * capability. Falls back to the plugin's 'jetpack_admin_page' capability (item[1]).
129     *
130     * @param array $menu The wp-admin $menu global.
131     * @return int|string|null Matching key, or null when no Jetpack parent is present.
132     */
133    private static function find_parent_index( array $menu ) {
134        foreach ( $menu as $i => $item ) {
135            if ( isset( $item[0] ) && isset( $item[2] ) && 'jetpack' === $item[2] ) {
136                return $i;
137            }
138        }
139        foreach ( $menu as $i => $item ) {
140            if ( isset( $item[0] ) && isset( $item[1] ) && 'jetpack_admin_page' === $item[1] ) {
141                return $i;
142            }
143        }
144        return null;
145    }
146}