> */ protected static $captured_tree = array(); /** * Register hooks. * * @since 0.1.0 * * @return void */ public function register() { add_action( 'admin_menu', array( $this, 'capture_tree' ), 90 ); add_action( 'admin_menu', array( $this, 'apply_whitelist' ), PHP_INT_MAX - 10 ); add_action( 'admin_bar_menu', array( $this, 'trim_admin_bar' ), PHP_INT_MAX - 10 ); add_filter( 'admin_body_class', array( $this, 'body_class' ) ); } /** * Saves the complete menu tree before it is reduced. * * @since 0.1.0 * * @global array $menu Top-level menu. * @global array $submenu Submenus. * * @return void */ public function capture_tree() { global $menu, $submenu; if ( ! empty( self::$captured_tree ) ) { return; } $tree = array(); foreach ( (array) $menu as $position => $item ) { if ( ! isset( $item[2], $item[0] ) ) { continue; } $slug = $item[2]; $label = trim( preg_replace( '/\s*\d+\s*$/', '', wp_strip_all_tags( $item[0] ) ) ); $tree[ $slug ] = array( 'label' => $label, 'position' => $position, 'subs' => array(), ); if ( isset( $submenu[ $slug ] ) && is_array( $submenu[ $slug ] ) ) { foreach ( $submenu[ $slug ] as $sub ) { if ( ! isset( $sub[0], $sub[2] ) ) { continue; } $tree[ $slug ]['subs'][ $sub[2] ] = trim( preg_replace( '/\s*\d+\s*$/', '', wp_strip_all_tags( $sub[0] ) ) ); } } } self::$captured_tree = $tree; } /** * Returns the saved (unfiltered) menu tree. * * @since 0.1.0 * * @return array> */ public static function get_full_tree() { if ( empty( self::$captured_tree ) ) { // Fallback in case capture_tree() has not run yet (e.g. direct call). $filter = new self(); $filter->capture_tree(); } /** * Filter the menu tree for the whitelist settings. * * @since 0.1.0 * * @param array $tree Slug => [ label, position, subs ]. */ return apply_filters( 'quietadmin_menu_tree', self::$captured_tree ); } /** * Applies the whitelist. * * @since 0.1.0 * * @global array $menu Top-level menu. * @global array $submenu Submenus. * * @return void */ public function apply_whitelist() { global $menu, $submenu; if ( ! ClientMode::is_active() ) { return; } $whitelist = $this->resolve_whitelist(); $strict_subs = (bool) Options::get( 'strict_submenus' ); $supp_bubble = (bool) Options::get( 'suppress_comment_bubble' ); foreach ( (array) $menu as $position => $item ) { if ( ! isset( $item[2] ) ) { continue; } $slug = $item[2]; // Keep separators, otherwise the menu layout breaks. if ( $this->is_separator( $slug ) ) { continue; } if ( ! in_array( $slug, $whitelist['top'], true ) ) { unset( $menu[ $position ] ); continue; } // Remove update/comment counters from the label. // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited -- The admin menu tree is deliberately rewritten here. $menu[ $position ][0] = $this->strip_counts( $item[0], $supp_bubble ); $menu[ $position ][6] = isset( $item[6] ) ? $this->clean_css_class( $item[6], $supp_bubble ) : ''; // phpcs:enable if ( isset( $submenu[ $slug ] ) && is_array( $submenu[ $slug ] ) ) { $allowed_subs = isset( $whitelist['subs'][ $slug ] ) ? $whitelist['subs'][ $slug ] : null; if ( null === $allowed_subs && $strict_subs ) { // No submenu configured + strict mode => only the first entry stays. $allowed_subs = $this->first_sub_slugs( $submenu[ $slug ] ); } foreach ( $submenu[ $slug ] as $sub_pos => $sub ) { if ( ! isset( $sub[2] ) ) { continue; } if ( is_array( $allowed_subs ) && ! in_array( $sub[2], $allowed_subs, true ) ) { unset( $submenu[ $slug ][ $sub_pos ] ); continue; } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Remove counters from submenu labels. $submenu[ $slug ][ $sub_pos ][0] = $this->strip_counts( $sub[0], $supp_bubble ); } if ( empty( $submenu[ $slug ] ) ) { unset( $submenu[ $slug ] ); } } } /** * After the menu has been reduced. * * @since 0.1.0 * * @param array $whitelist Resolved whitelist. */ do_action( 'quietadmin_menu_filtered', $whitelist ); } /** * Cleans up the admin bar. * * @since 0.1.0 * * @param \WP_Admin_Bar $wp_admin_bar Admin bar instance. * @return void */ public function trim_admin_bar( $wp_admin_bar ) { if ( ! ClientMode::is_active() || ! is_admin_bar_showing() ) { return; } if ( Options::get( 'hide_wp_logo' ) ) { $wp_admin_bar->remove_node( 'wp-logo' ); } if ( Options::get( 'suppress_update_nags' ) ) { $wp_admin_bar->remove_node( 'updates' ); } if ( Options::get( 'suppress_comment_bubble' ) ) { $wp_admin_bar->remove_node( 'comments' ); } $wp_admin_bar->remove_node( 'about' ); $wp_admin_bar->remove_node( 'contribute' ); $wp_admin_bar->remove_node( 'wordpress-org' ); $wp_admin_bar->remove_node( 'documentation' ); $wp_admin_bar->remove_node( 'support-forums' ); $wp_admin_bar->remove_node( 'feedback' ); // Reduce the "New" menu to the allowed content types. $whitelist = $this->resolve_whitelist(); $new_nodes = array( 'new-post' => 'edit.php', 'new-media' => 'upload.php', 'new-page' => 'edit.php?post_type=page', 'new-user' => 'users.php', ); foreach ( $new_nodes as $node => $required_slug ) { if ( ! in_array( $required_slug, $whitelist['top'], true ) ) { $wp_admin_bar->remove_node( $node ); } } if ( ! current_user_can( 'edit_posts' ) ) { $wp_admin_bar->remove_node( 'new-content' ); } } /** * Body classes for targeted CSS hiding. * * @since 0.1.0 * * @param string $classes Previous classes. * @return string */ public function body_class( $classes ) { if ( ! ClientMode::is_active() ) { return $classes; } $extra = array( 'qa-client-mode' ); if ( Options::get( 'suppress_notices' ) ) { $extra[] = 'qa-hide-notices'; } if ( Options::get( 'suppress_update_nags' ) ) { $extra[] = 'qa-hide-updates'; } if ( Options::get( 'suppress_comment_bubble' ) ) { $extra[] = 'qa-hide-comment-bubble'; } if ( Options::get( 'suppress_site_health' ) ) { $extra[] = 'qa-hide-site-health'; } if ( Options::get( 'remove_default_widgets' ) ) { $extra[] = 'qa-minimal-dashboard'; } if ( ClientMode::is_ghost_mode() && ! Roles::is_client() ) { $extra[] = 'qa-ghost-mode'; } return trim( $classes . ' ' . implode( ' ', $extra ) ); } /** * Builds the effective whitelist from the settings. * * @since 0.1.0 * * @return array{top:string[],subs:array} */ protected function resolve_whitelist() { $top = (array) Options::get( 'menu_whitelist' ); $subs = array(); foreach ( (array) Options::get( 'submenu_whitelist' ) as $token ) { if ( false === strpos( (string) $token, '||' ) ) { continue; } list( $parent, $sub ) = explode( '||', (string) $token, 2 ); if ( '' === $parent || '' === $sub ) { continue; } $subs[ $parent ][] = $sub; } $whitelist = array( 'top' => array_values( array_filter( array_map( 'strval', $top ) ) ), 'subs' => $subs, ); /** * Filter the effective menu whitelist. * * Example: `add_filter( 'quietadmin_menu_whitelist', fn( $w ) => array_merge( $w['top'], [ 'woocommerce' ] ) );` * * @since 0.1.0 * * @param array $whitelist Whitelist with the keys 'top' and 'subs'. */ $whitelist = apply_filters( 'quietadmin_menu_whitelist', $whitelist ); $whitelist['top'] = array_values( array_unique( (array) $whitelist['top'] ) ); $whitelist['subs'] = (array) $whitelist['subs']; // Always allow the dashboard, otherwise the client ends up nowhere. if ( ! in_array( 'index.php', $whitelist['top'], true ) ) { array_unshift( $whitelist['top'], 'index.php' ); } return $whitelist; } /** * Removes counter suffixes such as " 3". * * The core bubbles contain nested spans; a simple regex * would leave remnants, so balanced parsing is used here. * * @since 0.1.0 * * @param string $label Raw label. * @param bool $strip_comments Also remove comment counters. * @return string */ protected function strip_counts( $label, $strip_comments = true ) { $label = (string) $label; $patterns = array( '/class="[^"]*\bupdate-plugins\b/i', '/id="ab-awaiting-mod"/i', ); if ( $strip_comments ) { $patterns[] = '/class="[^"]*\b(?:awaiting-mod|pending-mod)\b/i'; $patterns[] = '/class="[^"]*\bcount-\d+/i'; } $label = $this->remove_matching_spans( $label, $patterns ); // Simple counters without markup, e.g. "Plugins 3". $label = preg_replace( '/\s+\d+\s*$/u', '', $label ); return trim( (string) $label ); } /** * Removes elements including nested children. * * @since 0.1.0 * * @param string $html Markup. * @param string[] $patterns Regexes that must match the opening tag. * @return string */ protected function remove_matching_spans( $html, $patterns ) { $offset = 0; $length = strlen( $html ); while ( $offset < $length && '' !== $html ) { $start = stripos( $html, '', $start ); if ( false === $tag_end ) { break; } $tag = substr( $html, $start, $tag_end - $start + 1 ); $match = false; foreach ( $patterns as $pattern ) { if ( preg_match( $pattern, $tag ) ) { $match = true; break; } } if ( ! $match ) { $offset = $tag_end + 1; continue; } // Find the balanced end (count nested spans). $depth = 1; $pos = $tag_end + 1; $found = false; $html_length = strlen( $html ); while ( $pos < $html_length ) { $next_open = stripos( $html, '', $pos ); if ( false === $next_close ) { break; } if ( false !== $next_open && $next_open < $next_close ) { ++$depth; $pos = $next_open + 5; continue; } --$depth; $pos = $next_close + 7; if ( 0 === $depth ) { $found = true; break; } } if ( ! $found ) { $offset = $tag_end + 1; continue; } $html = substr( $html, 0, $start ) . substr( $html, $pos ); $length = strlen( $html ); $offset = $start; } return $html; } /** * Removes CSS classes that draw counter bubbles. * * @since 0.1.0 * * @param string $css_class Class string. * @param bool $strip_comments Remove comment classes. * @return string */ protected function clean_css_class( $css_class, $strip_comments = true ) { $parts = preg_split( '/\s+/', (string) $css_class ); $keep = array(); foreach ( (array) $parts as $part ) { if ( '' === $part ) { continue; } if ( preg_match( '/^(update-plugins|count-\d+)$/', $part ) ) { continue; } if ( $strip_comments && preg_match( '/^(awaiting-mod|pending-mod|count-\d+)$/', $part ) ) { continue; } $keep[] = $part; } return implode( ' ', $keep ); } /** * First submenu slugs of a parent menu (typically the overview page). * * @since 0.1.0 * * @param array $subs Submenu entries. * @return string[] */ protected function first_sub_slugs( $subs ) { $slugs = array(); foreach ( (array) $subs as $sub ) { if ( isset( $sub[2] ) ) { $slugs[] = $sub[2]; } if ( count( $slugs ) >= 1 ) { break; } } return $slugs; } /** * Whether the slug is a menu separator. * * @since 0.1.0 * * @param string $slug Menu slug. * @return bool */ protected function is_separator( $slug ) { return (bool) preg_match( '/^separator\d*$/', (string) $slug ); } }