*/ private const ORDER = [ self::SLUG => 10, self::SLUG . '-settings' => 20, self::SLUG . '-events' => 30, self::SLUG . '-gateways' => 60, self::SLUG . '-hub' => 65, self::SLUG . '-skins' => 70, self::SLUG . '-cities' => 80, self::SLUG . '-health' => 90, ]; /** * Weight given to a screen this list has never heard of. * * Below the licence and above nothing else: an add-on that says nothing about * where it belongs lands after the plugin's own screens rather than in the * middle of them, and the menu stays readable instead of breaking. */ private const UNPLACED = 150; /** * Register menu hooks. */ public function register(): void { // Registered on every admin request rather than only on our own screens. // The shared loader picks the highest design-system version among all the // Rahkar plugins that have registered one, and a plugin that only enters // the competition on its own pages would let an older sibling's copy load // everywhere else. Registering costs one small file include and enqueues // nothing; the front end never reaches this at all. Ui::register(); add_action( 'admin_menu', [ $this, 'addPages' ] ); // Priority 100: after every module has registered, whatever priority it // chose. Sorting earlier would order a list that is still being built. add_action( 'admin_menu', [ $this, 'sortPages' ], 100 ); // A page with no parent highlights nothing, so the plugin's menu would // collapse the moment one of its own tabs was opened. add_filter( 'parent_file', [ $this, 'keepMenuOpen' ] ); add_filter( 'submenu_file', [ $this, 'highlightDiagnostics' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue' ] ); add_filter( 'plugin_action_links_' . RAHKAR_WP_BASENAME, [ $this, 'actionLinks' ] ); } /** * Build the menu. * * @internal Hooked callback. */ public function addPages(): void { add_menu_page( __( 'راهکار وردپرس', 'rahkar-toolkit' ), __( 'راهکار وردپرس', 'rahkar-toolkit' ), self::CAPABILITY, self::SLUG, static fn() => Plugin::make( ModulesPage::class )->render(), 'dashicons-screenoptions', 58 ); // No callback here: this call only renames the auto-generated first // submenu item (which otherwise repeats the top-level menu's own title). // It resolves to the exact same hookname as add_menu_page() above // ('toplevel_page_' . self::SLUG, since $admin_page_hooks already has an // entry for self::SLUG) — passing a second callback would register a // second closure on that hook and render the page twice. add_submenu_page( self::SLUG, __( 'ماژول‌ها', 'rahkar-toolkit' ), __( 'ماژول‌ها', 'rahkar-toolkit' ), self::CAPABILITY, self::SLUG ); add_submenu_page( self::SLUG, __( 'تنظیمات', 'rahkar-toolkit' ), __( 'تنظیمات', 'rahkar-toolkit' ), self::CAPABILITY, self::SLUG . '-settings', static fn() => Plugin::make( SettingsPage::class )->render() ); // One entry for the three diagnostics screens; the other two are taken out // of the menu below and reached through the tab strip. See Diagnostics. add_submenu_page( self::SLUG, __( 'وضعیت و گزارش‌ها', 'rahkar-toolkit' ), __( 'وضعیت و گزارش‌ها', 'rahkar-toolkit' ), self::CAPABILITY, self::SLUG . '-health', static fn() => Plugin::make( HealthPage::class )->render() ); // Lazy render callback, like every entry above: the logs screen is 500 lines // of list table and filter handling, and it was being opened on every admin // request purely to add this one menu entry. // No parent: a tab of the diagnostics screen rather than a menu entry of // its own. `remove_submenu_page()` was the obvious way to do that and is // the wrong one — `get_admin_page_parent()` finds a page's parent by // searching `$submenu`, so a page removed from it gets a different hook // name than the one it registered, fails `user_can_access_admin_page()` // and answers "you are not allowed to access this page". Registering with // no parent is the arrangement WordPress actually supports, and the page's // own capability check is what guards it. // // An empty string rather than `null`: both land in the same `$submenu` // bucket, but `add_submenu_page()` puts its argument through // `plugin_basename()`, and passing null into a string parameter is // deprecated from PHP 8.1. add_submenu_page( '', __( 'گزارش‌ها', 'rahkar-toolkit' ), __( 'گزارش‌ها', 'rahkar-toolkit' ), self::CAPABILITY, self::SLUG . '-logs', static fn() => Plugin::make( LogsPage::class )->render() ); // Settings registration is only needed on our own screens and on the // options.php POST target, so it is deferred rather than run on admin_init. add_action( 'admin_init', static fn() => Plugin::make( SettingsPage::class )->registerSettings() ); } /** * Keep this plugin's menu open on a parentless screen of ours. * * @param mixed $parent Parent file WordPress worked out. * * @internal Hooked callback. */ public function keepMenuOpen( $parent ): string { return self::isPluginScreen() ? self::SLUG : (string) $parent; } /** * Mark the diagnostics entry as current while one of its tabs is open. * * @param mixed $submenuFile Submenu file WordPress worked out. * * @internal Hooked callback. */ public function highlightDiagnostics( $submenuFile ): ?string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check of which admin screen is showing. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; return in_array( $page, Diagnostics::hidden(), true ) ? Diagnostics::PRIMARY : ( null === $submenuFile ? null : (string) $submenuFile ); } /** * Put the submenu in the order {@see self::ORDER} describes. * * WordPress keeps the submenu as a plain list and renders it in array order, * so reordering is sorting that list. Done here rather than by asking every * module to pick a clever priority, because that is the arrangement this * replaced. * * @internal Hooked callback. */ public function sortPages(): void { global $submenu; if ( ! isset( $submenu[ self::SLUG ] ) || ! is_array( $submenu[ self::SLUG ] ) ) { return; } /** * Filters where each screen sits in the plugin's menu. * * An add-on's screens are its own business, so it places them here rather * than by guessing a hook priority that does not collide with anyone * else's. Anything left out lands after this plugin's own screens. * * @param array $order Weight per menu slug, lowest first. */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort -- Inline type assertion for phpstan: a filter may return anything. /** @var mixed $filtered */ $filtered = apply_filters( 'rahkar_wp_menu_order', self::ORDER ); $order = is_array( $filtered ) ? $filtered : self::ORDER; // PHP's sort has been stable since 8.0, which the plugin requires, so two // screens of equal weight keep the order they registered in rather than // swapping places between requests. usort( $submenu[ self::SLUG ], static function ( $left, $right ) use ( $order ): int { $a = (int) ( $order[ (string) ( $left[2] ?? '' ) ] ?? self::UNPLACED ); $b = (int) ( $order[ (string) ( $right[2] ?? '' ) ] ?? self::UNPLACED ); return $a <=> $b; } ); } /** * Are we on one of this plugin's screens? * * Matched on the page slug rather than against a list of registered hook * suffixes. Several screens add their own submenu — the logs, notification * events, and cities pages each register from the class that owns them — so a * list held here silently misses them, which is exactly what happened: three * screens rendered with no stylesheet at all until someone looked at one. * * Every screen this plugin adds lives under the same slug prefix, so deriving * the answer cannot drift out of step with what actually exists. */ public static function isPluginScreen(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only check of which admin screen is showing. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; return '' !== $page && str_starts_with( $page, self::SLUG ); } /** * Load admin assets, on this plugin's screens only. * * @internal Hooked callback. */ public function enqueue(): void { if ( ! self::isPluginScreen() ) { return; } Ui::enqueueStyles(); // Page layout only — everything else comes from the design system. Declared // after it so the cascade order is right whichever way the browser happens // to receive the two files. wp_enqueue_style( 'rahkar-wp-admin', RAHKAR_WP_URL . 'assets/dist/admin.css', [ 'rahkar-ui' ], RAHKAR_WP_VERSION ); } /** * Add a settings shortcut to the plugins list. * * @param array $links Existing action links. * * @return array * * @internal Hooked callback. */ public function actionLinks( array $links ): array { $settings = sprintf( '%s', esc_url( admin_url( 'admin.php?page=' . self::SLUG ) ), esc_html__( 'ماژول‌ها', 'rahkar-toolkit' ) ); array_unshift( $links, $settings ); return $links; } }