*/ private array $definitions = array(); /** * Callable that forwards a single top-level admin-page * registration to WordPress. * * Signature: `function (AdminPageDefinition $definition): void`. * * @var callable */ private $registerMenuFn; /** * Callable that forwards a single submenu admin-page * registration to WordPress. * * Signature: `function (AdminPageDefinition $definition): void`. * * @var callable */ private $registerSubmenuFn; /** * Construct a registrar with optional overrides for the two * registration callables. * * The default callables forward to the global * `add_menu_page()` / `add_submenu_page()` functions when * available (the normal runtime path) and raise a * {@see LogicException} otherwise so a misconfigured production * runtime fails loudly rather than silently dropping admin * pages. Tests pass in-memory recording callables to drive the * registrar without booting WordPress. * * @param callable|null $registerMenuFn Override for the top-level registration callable. * @param callable|null $registerSubmenuFn Override for the submenu registration callable. * * @phpstan-param (callable(AdminPageDefinition): void)|null $registerMenuFn * @phpstan-param (callable(AdminPageDefinition): void)|null $registerSubmenuFn */ public function __construct( ?callable $registerMenuFn = null, ?callable $registerSubmenuFn = null ) { $this->registerMenuFn = $registerMenuFn ?? self::defaultRegisterMenuFn(); $this->registerSubmenuFn = $registerSubmenuFn ?? self::defaultRegisterSubmenuFn(); } /** * Append a single {@see AdminPageDefinition} to the registrar. * * Returns the registrar for fluent chaining. * * @param AdminPageDefinition $definition Definition to append. */ public function add( AdminPageDefinition $definition ): self { $this->definitions[] = $definition; return $this; } /** * Append every definition in `$definitions` in order. * * @param list $definitions Definitions to append. */ public function addAll( array $definitions ): self { foreach ( $definitions as $definition ) { $this->add( $definition ); } return $this; } /** * Forward every accumulated definition to the appropriate * registration callable: top-level definitions go to * `$registerMenuFn`, submenus go to `$registerSubmenuFn`. * * The registrar does not clear its internal list after * registration so tests can re-assert against the configured * definitions; production callers MUST NOT invoke this method * more than once per request because WordPress's * `add_menu_page()` / `add_submenu_page()` are not idempotent * on repeated calls with identical args. */ public function register(): void { foreach ( $this->definitions as $definition ) { if ( $definition->isTopLevel() ) { ( $this->registerMenuFn )( $definition ); continue; } ( $this->registerSubmenuFn )( $definition ); } } /** * Return every definition that has been added, in insertion * order. * * @return list */ public function definitions(): array { return $this->definitions; } /** * Build the default top-level registration callable. * * Forwards to the global `add_menu_page()` when available and * wires a `load-{$hookSuffix}` action when the definition's * `loadCallback` is non-null and `add_action()` is available. * * Phase 2 KI-010 closeout: the function-existence probe is * injectable so the LogicException branch can be exercised * without process isolation. Default resolves to the global * `function_exists()` so production behavior is unchanged. * * @param callable(string): bool|null $functionExistsFn Optional probe; * defaults to the * global * `function_exists()`. * * @phpstan-return callable(AdminPageDefinition): void */ public static function defaultRegisterMenuFn( ?callable $functionExistsFn = null ): callable { $probe = $functionExistsFn ?? static fn ( string $name ): bool => function_exists( $name ); return static function ( AdminPageDefinition $definition ) use ( $probe ): void { if ( ! $probe( 'add_menu_page' ) ) { throw new LogicException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. 'AdminMenuRegistrar default forwarder requires the global add_menu_page() function.' ); } $hookSuffix = add_menu_page( $definition->pageTitle(), $definition->menuTitle(), $definition->capability(), $definition->menuSlug(), $definition->renderCallback(), $definition->iconUrl(), $definition->position() === 0 ? null : $definition->position() ); self::wireLoadCallback( $definition, $hookSuffix ); }; } /** * Build the default submenu registration callable. * * Forwards to the global `add_submenu_page()` when available * and wires a `load-{$hookSuffix}` action when the definition's * `loadCallback` is non-null and `add_action()` is available. * * Phase 2 KI-010 closeout: the function-existence probe is * injectable so the LogicException branch can be exercised * without process isolation. Default resolves to the global * `function_exists()` so production behavior is unchanged. * * @param callable(string): bool|null $functionExistsFn Optional probe; * defaults to the * global * `function_exists()`. * * @phpstan-return callable(AdminPageDefinition): void */ public static function defaultRegisterSubmenuFn( ?callable $functionExistsFn = null ): callable { $probe = $functionExistsFn ?? static fn ( string $name ): bool => function_exists( $name ); return static function ( AdminPageDefinition $definition ) use ( $probe ): void { if ( ! $probe( 'add_submenu_page' ) ) { throw new LogicException( // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. 'AdminMenuRegistrar default forwarder requires the global add_submenu_page() function.' ); } $hiddenPage = $definition->parentSlug() === AdminPageDefinition::HIDDEN_PARENT_SLUG; $parentSlug = $hiddenPage ? 'affilun-products' : $definition->parentSlug(); $hookSuffix = add_submenu_page( $parentSlug, $definition->pageTitle(), $definition->menuTitle(), $definition->capability(), $definition->menuSlug(), $definition->renderCallback(), $definition->position() === 0 ? null : $definition->position() ); if ( $hiddenPage && function_exists( 'remove_submenu_page' ) ) { remove_submenu_page( $parentSlug, $definition->menuSlug() ); } self::wireLoadCallback( $definition, $hookSuffix ); }; } /** * Register the definition's `loadCallback` against the * `load-{$hookSuffix}` action when both are available. * * No-op when the definition carries no load callback, when * WordPress returned a non-string hook suffix (which happens * when the user lacks the required capability), or when the * `add_action()` global is missing. * * @param AdminPageDefinition $definition Source definition. * @param mixed $hookSuffix Hook suffix returned by `add_menu_page()` / `add_submenu_page()`. */ private static function wireLoadCallback( AdminPageDefinition $definition, $hookSuffix ): void { $loadCallback = $definition->loadCallback(); if ( $loadCallback === null ) { return; } if ( ! is_string( $hookSuffix ) || $hookSuffix === '' ) { return; } if ( ! function_exists( 'add_action' ) ) { return; } add_action( 'load-' . $hookSuffix, $loadCallback ); } }