pages = array_values( $pages ); } /** * Register hooks. * * @return void */ public function boot(): void { add_action( 'admin_menu', array( $this, 'register' ) ); } /** * Build the menu. * * @return void */ public function register(): void { if ( array() === $this->pages ) { return; } $first = $this->pages[0]; // The top-level entry is the first page. WordPress would otherwise invent a submenu item // duplicating the parent, labelled with the menu title — "AdPix > AdPix" — so the first // submenu below is registered against the parent's own slug to take that duplicate's place. $hook = add_menu_page( $first->page_title(), $this->brand(), $first->capability(), $first->slug(), array( $first, 'render' ), $this->icon(), self::POSITION ); $first->set_hook( is_string( $hook ) ? $hook : '' ); foreach ( $this->pages as $page ) { $hook = add_submenu_page( $first->slug(), $page->page_title(), $page->menu_title(), $page->capability(), $page->slug(), array( $page, 'render' ) ); // A page the current user cannot open is not registered at all, and add_submenu_page() // returns false for it. Leaving the hook empty in that case is correct: the screen is // unreachable, so nothing should ever match it. $page->set_hook( is_string( $hook ) ? $hook : '' ); } } /** * The name shown in the sidebar. * * Separate from the settings page's own title so the sidebar can carry the brand as each language * writes it — "AdPix" in English, "ادپیکس" in Persian — while the screen itself keeps its full name. * * @return string */ private function brand(): string { return __( 'AdPix', 'adpix-analytics' ); } /** * The sidebar icon, as a data URI. * * A data URI rather than a URL because WordPress inlines these into the menu markup, so the icon * is there on first paint instead of arriving a request later and shifting the sidebar. Note that * WordPress recolours dashicons to match the admin scheme but leaves an SVG exactly as given, so * this is a copy of the mark with its mid-grey lightened — the brand grey is all but invisible * against the dark sidebar. * * @return string The data URI, or a dashicon name if the file cannot be read. */ private function icon(): string { $path = ADPIX_ANALYTICS_PATH . 'assets/img/menu-icon.svg'; // A bundled 800-byte file, read once per admin request while the menu is built. The fallback is // not defensive theatre: a hardened host with open_basedir set awkwardly can make this fail, and // a menu with no icon at all is harder to find than one wearing a generic chart. $svg = is_readable( $path ) ? file_get_contents( $path ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents if ( ! is_string( $svg ) || '' === trim( $svg ) ) { return 'dashicons-chart-area'; } return 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode } }