normalised product. * * @var array> */ private array $products = array(); /** * Raw entries in registration order, validated at seal(). * * @var list> */ private array $raw = array(); /** * Validated entries: id => entry. * * @var array> */ private array $entries = array(); /** * Settings screens: slug => normalised screen. * * @var array> */ private array $screens = array(); /** * Home cards: product => slot => list of [order, html or callable]. * * @var array>> */ private array $cards = array(); /** * Entity icons: '/' => Lucide name (the product that owns an entity owns its icon; anyone who * refers to the entity asks Icons::of_entity()). * * @var array */ private array $entity_icons = array(); /** * Rejected rows: id or slug => reason. * * @var array */ private array $problems = array(); /** * Whether seal() ran. * * @var bool */ private bool $sealed = false; /** * The current product of this request, or null before it is known. * * @var string|null */ private ?string $current = null; /** * The visible entries per user, product and kinds of this request. * * @var array>> */ private array $visible = array(); /** * The entries a page's element is found by, per user and product (0.87.0, anchored()): the fragment each link ends * with => the entry; built once per request, the first time a card asks. * * @var array>> */ private array $anchored = array(); /** * The capability checks of this request: capability => whether the current user has it (a catalogue of a * thousand rows names a handful of capabilities; current_user_can() costs about 20 µs each). * * @var array */ private array $caps = array(); /** * The user the capability memo belongs to. * * @var int */ private int $caps_user = -1; /* ── Registration ──────────────────────────────────────────────────── */ /** * Registers a product. Keys (docs/CONTRACT.md §2): slug (required), name (required), words, colour, version, logo, * hero_image, wordmark {light, dark}, strapline, intro, links {manual, help, feedback, product}, tier {label, on, detail, action {label, * url}}, home {parent, slug, capability, title, menu_title, menu_icon, position}, sections {key => {label, short, * colour, icon, groups {key => label}, stacks [[group keys]]}}, audiences {key => {label, sentence, all, colour}}, * screens {pages [], ids [], hooks []}. False (with a problem) when the slug or name is missing or the slug is taken. * * @param array $product Product. */ public function register_product( array $product ): bool { $slug = (string) ( $product['slug'] ?? '' ); if ( $this->sealed ) { $this->problems[ 'product:' . $slug ] = 'registered after seal'; return false; } if ( 1 !== preg_match( self::KEY, $slug ) || ! is_string( $product['name'] ?? null ) || '' === $product['name'] ) { $this->problems[ 'product:' . $slug ] = 'a product needs a slug and a name'; return false; } if ( isset( $this->products[ $slug ] ) ) { $this->problems[ 'product:' . $slug ] = 'duplicate product'; return false; } $name = (string) $product['name']; $words = is_array( $product['words'] ?? null ) ? array_values( array_filter( array_map( 'strval', $product['words'] ) ) ) : explode( ' ', $name, 2 ); $colour = Colour::hex( (string) ( $product['colour'] ?? '' ), '#1e63ce' ); $home = is_array( $product['home'] ?? null ) ? $product['home'] : array(); $links = is_array( $product['links'] ?? null ) ? $product['links'] : array(); $tier = is_array( $product['tier'] ?? null ) ? $product['tier'] : array(); $action = is_array( $tier['action'] ?? null ) ? $tier['action'] : array(); $screens = is_array( $product['screens'] ?? null ) ? $product['screens'] : array(); $sections = array(); $order = 0; foreach ( is_array( $product['sections'] ?? null ) ? $product['sections'] : array() as $key => $section ) { $key = (string) $key; if ( 1 !== preg_match( self::KEY, $key ) || ! is_array( $section ) ) { $this->problems[ 'section:' . $slug . '/' . $key ] = 'bad section key'; continue; } $groups = array(); foreach ( is_array( $section['groups'] ?? null ) ? $section['groups'] : array() as $g => $label ) { if ( 1 === preg_match( self::KEY, (string) $g ) ) { $groups[ (string) $g ] = (string) $label; } } $stacks = array(); foreach ( is_array( $section['stacks'] ?? null ) ? $section['stacks'] : array() as $stack ) { $stack = array_values( array_filter( array_map( 'strval', is_array( $stack ) ? $stack : array() ), static fn( string $g ): bool => isset( $groups[ $g ] ) ) ); if ( array() !== $stack ) { $stacks[] = $stack; } } if ( array() !== $groups && array() === $stacks ) { $stacks = array( array_keys( $groups ) ); } $sections[ $key ] = array( 'key' => $key, 'label' => (string) ( $section['label'] ?? ucfirst( $key ) ), 'short' => (string) ( $section['short'] ?? ( $section['label'] ?? ucfirst( $key ) ) ), 'colour' => Colour::hex( (string) ( $section['colour'] ?? '' ), Colour::palette( $order ) ), 'icon' => (string) ( $section['icon'] ?? 'layout-grid' ), 'groups' => $groups, 'stacks' => $stacks, 'order' => $order++, ); } $audiences = array(); $i = 0; foreach ( is_array( $product['audiences'] ?? null ) ? $product['audiences'] : array() as $key => $persona ) { if ( 1 === preg_match( self::KEY, (string) $key ) && is_array( $persona ) ) { $audiences[ (string) $key ] = array( 'label' => (string) ( $persona['label'] ?? $key ), 'sentence' => (string) ( $persona['sentence'] ?? '' ), 'all' => ! empty( $persona['all'] ), 'colour' => Colour::hex( (string) ( $persona['colour'] ?? '' ), Colour::palette( $i++ ) ), ); } } $intro = array(); foreach ( is_array( $product['intro'] ?? null ) ? $product['intro'] : ( is_string( $product['intro'] ?? null ) ? array( $product['intro'] ) : array() ) as $paragraph ) { $intro[] = (string) $paragraph; } $this->products[ $slug ] = array( 'slug' => $slug, 'name' => $name, 'words' => $words, 'colour' => $colour, 'version' => (string) ( $product['version'] ?? '' ), 'logo' => isset( $product['logo'] ) && is_string( $product['logo'] ) && '' !== $product['logo'] ? esc_url_raw( $product['logo'] ) : Library::asset( 'assets/images/rooster-logo.svg' ), 'hero_image' => isset( $product['hero_image'] ) && is_string( $product['hero_image'] ) ? esc_url_raw( $product['hero_image'] ) : Library::asset( 'assets/images/home-hero.webp' ), 'wordmark' => array( // the product's own lockup (mark and name in one file) for the brand bar; '' = the logo and the words 'light' => is_string( $product['wordmark']['light'] ?? null ) ? esc_url_raw( $product['wordmark']['light'] ) : '', 'dark' => is_string( $product['wordmark']['dark'] ?? null ) ? esc_url_raw( $product['wordmark']['dark'] ) : '', ), 'word' => self::word_style( is_array( $product['word'] ?? null ) ? $product['word'] : array() ), 'strapline' => (string) ( $product['strapline'] ?? '' ), 'intro' => $intro, 'welcome' => self::welcome( is_array( $product['welcome'] ?? null ) ? $product['welcome'] : array() ), 'links' => self::links( $links, (string) ( $product['slug'] ?? '' ) ), 'tier' => array( 'label' => (string) ( $tier['label'] ?? '' ), 'on' => ! empty( $tier['on'] ), 'detail' => (string) ( $tier['detail'] ?? '' ), 'action' => array( 'label' => (string) ( $action['label'] ?? '' ), 'url' => esc_url_raw( (string) ( $action['url'] ?? '' ) ), ), ), 'home' => array( 'parent' => (string) ( $home['parent'] ?? '' ), 'slug' => 1 === preg_match( self::KEY, (string) ( $home['slug'] ?? '' ) ) ? (string) $home['slug'] : $slug . '-home', 'capability' => (string) ( $home['capability'] ?? 'read' ), 'title' => (string) ( $home['title'] ?? $name ), 'menu_title' => (string) ( $home['menu_title'] ?? __( 'Home', 'ai-eshop-optimizer' ) ), 'menu_icon' => (string) ( $home['menu_icon'] ?? 'dashicons-admin-generic' ), 'position' => isset( $home['position'] ) && is_numeric( $home['position'] ) ? $home['position'] + 0 : null, // int or float 'after' => (string) ( $home['after'] ?? '' ), // the side menu item this Home follows, by its slug (the LRS: the LMS's course menu); the library reorders the menu 'enabled' => ! isset( $home['enabled'] ) || ! empty( $home['enabled'] ), ), 'sections' => $sections, 'audiences' => $audiences, 'screens' => array( 'pages' => array_values( array_map( 'strval', is_array( $screens['pages'] ?? null ) ? $screens['pages'] : array() ) ), 'ids' => array_values( array_map( 'strval', is_array( $screens['ids'] ?? null ) ? $screens['ids'] : array() ) ), 'hooks' => array_values( array_map( 'strval', is_array( $screens['hooks'] ?? null ) ? $screens['hooks'] : array() ) ), ), 'order' => count( $this->products ), ); return true; } /** * Registers catalogue entries (validated at seal(); problems() says what was dropped). Keys (CONTRACT §3): id, * kind, product, section, group, title, description, url, icon, capabilities, audiences, keywords, pin, available, * badge, page, current, listed. * * @param list> $entries Entries. * @return int How many rows were taken for validation. */ public function register_entries( array $entries ): int { if ( $this->sealed ) { $this->problems['entries'] = 'registered after seal'; return 0; } $n = 0; foreach ( $entries as $entry ) { if ( is_array( $entry ) ) { $this->raw[] = $entry; ++$n; } } return $n; } /** * Registers a settings screen the library renders (CONTRACT §4): product, slug, parent, title, menu_title, * capability, option, option_group, value (callable group, key => value), section, icon, groups {key => {title, * description, section, icon, post (the option's sub-array the card posts into; default the key), fields {key => * {type, label, help, options, default}}, audiences}}, extra {key => {title, card_title (0.68.11: the title of the * card that frames it, other than the tab's, which the page head says), description, section, group, icon, * audiences, render}} (tabs the product renders itself, reached by `tab=`, each a page entry, framed in a card * with its anchor), intro, register, * group (0.57.0: the screen page's menu group; an extra tab's default) and audiences (0.57.0: the personas of the * screen, the default of its groups and extra tabs; a field takes its group's). * * @param array $screen Screen. */ public function register_settings_screen( array $screen ): bool { $slug = (string) ( $screen['slug'] ?? '' ); $product = (string) ( $screen['product'] ?? '' ); if ( $this->sealed ) { $this->problems[ 'screen:' . $slug ] = 'registered after seal'; return false; } if ( 1 !== preg_match( self::KEY, $slug ) || ! isset( $this->products[ $product ] ) || ! is_array( $screen['groups'] ?? null ) || ! is_string( $screen['option'] ?? null ) ) { $this->problems[ 'screen:' . $slug ] = 'a settings screen needs a slug, a registered product, an option and groups'; return false; } if ( isset( $this->screens[ $slug ] ) ) { $this->problems[ 'screen:' . $slug ] = 'duplicate screen'; return false; } $groups = array(); foreach ( $screen['groups'] as $key => $group ) { $key = (string) $key; if ( 1 !== preg_match( self::KEY, $key ) || ! is_array( $group ) || ! is_array( $group['fields'] ?? null ) ) { $this->problems[ 'group:' . $slug . '/' . $key ] = 'a group needs a key and fields'; continue; } $fields = array(); foreach ( $group['fields'] as $name => $field ) { $name = (string) $name; if ( 1 !== preg_match( self::KEY, $name ) || ! is_array( $field ) || ! is_string( $field['label'] ?? null ) ) { $this->problems[ 'field:' . $slug . '/' . $key . '/' . $name ] = 'a field needs a key and a label'; continue; } $fields[ $name ] = array( 'type' => in_array( $field['type'] ?? '', SettingsScreen::TYPES, true ) ? (string) $field['type'] : 'text', 'label' => $field['label'], 'help' => (string) ( $field['help'] ?? '' ), 'options' => is_array( $field['options'] ?? null ) ? $field['options'] : array(), 'default' => $field['default'] ?? null, 'icon' => (string) ( $field['icon'] ?? '' ), 'keywords' => $this->list( $field['keywords'] ?? array() ), 'render' => is_callable( $field['render'] ?? null ) ? $field['render'] : null, 'sanitize' => is_callable( $field['sanitize'] ?? null ) ? $field['sanitize'] : null, // 0.81.0: an html field's own (SettingsScreen::sanitize_fields()) 'rows' => isset( $field['rows'] ) ? max( 2, (int) $field['rows'] ) : 4, ); } $groups[ $key ] = array( 'key' => $key, 'post' => 1 === preg_match( self::KEY, (string) ( $group['post'] ?? '' ) ) ? (string) $group['post'] : $key, 'title' => (string) ( $group['title'] ?? ucfirst( $key ) ), 'description' => (string) ( $group['description'] ?? '' ), 'section' => (string) ( $group['section'] ?? ( $screen['section'] ?? '' ) ), 'group' => (string) ( $group['group'] ?? '' ), // the section's menu group of the card and its fields 'icon' => (string) ( $group['icon'] ?? ( $screen['icon'] ?? 'settings' ) ), 'keywords' => $this->list( $group['keywords'] ?? array() ), 'audiences' => $this->list( $group['audiences'] ?? ( $screen['audiences'] ?? array() ) ), // 0.57.0: the card's and its fields' personas (default: the screen's) 'fields' => $fields, ); } $extra = array(); foreach ( is_array( $screen['extra'] ?? null ) ? $screen['extra'] : array() as $key => $tab ) { $key = (string) $key; if ( 1 !== preg_match( self::KEY, $key ) || ! is_array( $tab ) || ! is_callable( $tab['render'] ?? null ) || isset( $groups[ $key ] ) ) { $this->problems[ 'extra:' . $slug . '/' . $key ] = 'an extra tab needs a key of its own and a render callable'; continue; } $extra[ $key ] = array( 'key' => $key, 'title' => (string) ( $tab['title'] ?? ucfirst( $key ) ), 'card_title' => (string) ( $tab['card_title'] ?? '' ), // 0.68.11: the frame card's title (the page head has the tab's: H12) 'description' => (string) ( $tab['description'] ?? '' ), 'section' => (string) ( $tab['section'] ?? ( $screen['section'] ?? '' ) ), 'icon' => (string) ( $tab['icon'] ?? ( $screen['icon'] ?? 'settings' ) ), 'keywords' => $this->list( $tab['keywords'] ?? array() ), 'capability' => (string) ( $tab['capability'] ?? ( $screen['capability'] ?? 'manage_options' ) ), 'group' => (string) ( $tab['group'] ?? ( $screen['group'] ?? '' ) ), // 0.57.0: the tab page's menu group (default: the screen's) 'audiences' => $this->list( $tab['audiences'] ?? ( $screen['audiences'] ?? array() ) ), 'render' => $tab['render'], ); } $this->screens[ $slug ] = array( 'slug' => $slug, 'product' => $product, 'parent' => (string) ( $screen['parent'] ?? '' ), 'title' => (string) ( $screen['title'] ?? $this->products[ $product ]['name'] ), 'menu_title' => (string) ( $screen['menu_title'] ?? ( $screen['title'] ?? $this->products[ $product ]['name'] ) ), 'capability' => (string) ( $screen['capability'] ?? 'manage_options' ), 'option' => (string) $screen['option'], 'option_group' => (string) ( $screen['option_group'] ?? $screen['option'] ), 'value' => is_callable( $screen['value'] ?? null ) ? $screen['value'] : null, 'section' => (string) ( $screen['section'] ?? '' ), 'icon' => (string) ( $screen['icon'] ?? 'settings' ), 'group' => (string) ( $screen['group'] ?? '' ), // 0.57.0: the screen page's menu group in its section 'audiences' => $this->list( $screen['audiences'] ?? array() ), // 0.57.0: the screen's personas, its groups' and tabs' default 'intro' => (string) ( $screen['intro'] ?? '' ), 'register' => ! empty( $screen['register'] ), 'sanitize' => is_callable( $screen['sanitize'] ?? null ) ? $screen['sanitize'] : null, 'groups' => $groups, 'extra' => $extra, 'menu' => ! isset( $screen['menu'] ) || ! empty( $screen['menu'] ), ); return true; } /** * Registers a product's own card on its Home page (CONTRACT §5): a slot of SLOTS, escaped HTML or a callable * returning it (called at render), and an order. * * @param string $product Product slug. * @param string $slot A slot of SLOTS. * @param string|callable $html Escaped HTML, or a callable returning it. * @param int $order Order within the slot. */ public function register_home_card( string $product, string $slot, $html, int $order = 10 ): bool { if ( $this->sealed || ! isset( $this->products[ $product ] ) || ! in_array( $slot, self::SLOTS, true ) || ( ! is_string( $html ) && ! is_callable( $html ) ) ) { $this->problems[ 'card:' . $product . '/' . $slot ] = 'a Home card needs a registered product, a slot and HTML or a callable'; return false; } $this->cards[ $product ][ $slot ][] = array( $order, $html ); return true; } /** * The welcome of a product (0.21.0), in Rooster One's anatomy, every key optional (Welcome::spec() fills the * defaults from the product's name, strapline and introduction): `hero` (the big first line), `title`, `intro` * (a list of paragraphs, or one), `punch` (the one sentence), `caution` (`{title, body}`, or the body), `blocks` * (a list of `{title, lede, body: [paragraphs]}`: the folds that show their point and hide their evidence). * * @param array $welcome The product's `welcome`. * @return array */ private static function welcome( array $welcome ): array { $list = static function ( $value ): array { $items = is_array( $value ) ? $value : ( is_string( $value ) && '' !== $value ? array( $value ) : array() ); return array_values( array_filter( array_map( static fn( $item ): string => is_scalar( $item ) ? (string) $item : '', $items ), static fn( string $item ): bool => '' !== $item ) ); }; $caution = is_array( $welcome['caution'] ?? null ) ? $welcome['caution'] : array( 'body' => $welcome['caution'] ?? '' ); $blocks = array(); foreach ( is_array( $welcome['blocks'] ?? null ) ? $welcome['blocks'] : array() as $block ) { if ( ! is_array( $block ) || '' === (string) ( $block['title'] ?? '' ) ) { continue; } $blocks[] = array( 'title' => (string) $block['title'], 'lede' => (string) ( $block['lede'] ?? '' ), 'body' => $list( $block['body'] ?? array() ), ); } return array( 'hero' => (string) ( $welcome['hero'] ?? '' ), 'title' => (string) ( $welcome['title'] ?? '' ), 'intro' => $list( $welcome['intro'] ?? array() ), 'punch' => (string) ( $welcome['punch'] ?? '' ), 'caution' => array( 'title' => (string) ( $caution['title'] ?? '' ), 'body' => is_scalar( $caution['body'] ?? null ) ? (string) $caution['body'] : '', ), 'blocks' => $blocks, ); } /** * The links profile of a product (0.20.1), the same keys on every product, each '' when the product has none: * `manual` (its documentation), `help` (help and support), `feedback` (the feedback page, the bar's item when no * sender takes the dialog), `chat` (the community chat, e.g. the Viber group), `review` (the directory's review * page), `product` (the product's page on rooster.systems), `directory` (the free edition's page on * wordpress.org), `pro` (the Pro or Hub edition's page). Filtered per product (`rooster/admin/links`), so a * site or a partner changes an address without a release. * * @param array $links The product's links as registered. * @param string $slug Product slug. * @return array */ private static function links( array $links, string $slug ): array { $out = array(); foreach ( self::LINK_KEYS as $key ) { $out[ $key ] = esc_url_raw( (string) ( $links[ $key ] ?? '' ) ); } /** * Filters a product's links profile (0.20.1): manual, help, feedback, chat, review, product, directory, pro * ('' hides the item that uses one). * * @since 0.20.1 * * @param array $links key => URL. * @param string $product The product. */ $filtered = apply_filters( 'rooster/admin/links', $out, $slug ); foreach ( self::LINK_KEYS as $key ) { if ( is_array( $filtered ) && is_string( $filtered[ $key ] ?? null ) ) { $out[ $key ] = esc_url_raw( $filtered[ $key ] ); } } return $out; } /** * The style of the wordmark's product word (0.17.0): `style` '' (the product's colour, as every product) or * `outline` (hollow letters stroked in the gradient, as Rooster Zero's logo kit draws ZERO), `gradient` two hex * colours (from, to; the stroke, or the fill of a solid word); anything else is the plain word. * * @param array $word The product's `word`. * @return array{style: string, gradient: list} */ private static function word_style( array $word ): array { $gradient = array(); foreach ( (array) ( $word['gradient'] ?? array() ) as $hex ) { if ( is_string( $hex ) && 1 === preg_match( '/^#[0-9a-fA-F]{6}$/', $hex ) ) { $gradient[] = strtolower( $hex ); } } return array( 'style' => 'outline' === (string) ( $word['style'] ?? '' ) ? 'outline' : '', 'gradient' => 2 === count( $gradient ) ? $gradient : array(), ); } /** * Registers the icons of a product's entities (CONTRACT §6): `entity => Lucide name`, each stored as * `/` (underscores read as dashes), so a slot, a card or another product's row that refers to * the entity gets the owner's icon through Icons::of_entity(). A name the bundle lacks is kept: the product * adds its shapes through `rooster/admin/icons`. * * @param string $product Product slug (registered or not: Rooster One's entities may be named * before Rooster One consumes the library). * @param array $icons entity => Lucide name. * @return int How many were taken. */ public function register_entity_icons( string $product, array $icons ): int { if ( 1 !== preg_match( self::KEY, $product ) ) { return 0; } $n = 0; foreach ( $icons as $entity => $icon ) { $entity = str_replace( '_', '-', strtolower( (string) $entity ) ); if ( 1 !== preg_match( '/^[a-z0-9][a-z0-9-]{0,59}$/', $entity ) || ! is_string( $icon ) || 1 !== preg_match( '/^[a-z0-9-]{1,60}$/', $icon ) ) { continue; } $this->entity_icons[ $product . '/' . $entity ] = $icon; ++$n; } return $n; } /** * The icon of an entity ('' when none is registered), by `/`; underscores read as dashes. * * @param string $key Entity key. */ public function entity_icon( string $key ): string { return $this->entity_icons[ str_replace( '_', '-', strtolower( $key ) ) ] ?? ''; } /** * The rows rejected so far: id or key => reason. * * @return array */ public function problems(): array { return $this->problems; } /* ── Sealing and validation ───────────────────────────────────────── */ /** * Seals the registry on the first read of its entries (lazy: an admin screen without the chrome never pays for * the validation of the catalogue; the products' registration callbacks alone run there). */ private function ready(): void { if ( ! $this->sealed ) { $this->seal(); } } /** * Completes and validates the registry once: the settings screens' entries, the `rooster/admin/catalogue` filter, * then every row validated in order. Called by the first read (ready()), or explicitly. */ public function seal(): void { if ( $this->sealed ) { return; } $this->sealed = true; foreach ( $this->screens as $screen ) { foreach ( SettingsScreen::entries_of( $screen, $this ) as $entry ) { $this->raw[] = $entry; } } /** * Filters the catalogue rows before validation: a product adds or changes the rows of its own destinations * (CONTRACT §3). Rows of an unregistered product are dropped. * * @since 0.1.0 * * @param list> $rows Rows. * @param Registry $registry The registry. */ $rows = apply_filters( 'rooster/admin/catalogue', $this->raw, $this ); $this->raw = array(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { if ( ! is_array( $row ) ) { continue; } $entry = $this->validate( $row ); if ( null === $entry ) { continue; } if ( isset( $this->entries[ $entry['id'] ] ) ) { $this->problems[ $entry['id'] ] = 'duplicate id'; continue; } if ( count( $this->entries ) >= self::MAX ) { $this->problems[ $entry['id'] ] = 'over MAX'; continue; } $this->entries[ $entry['id'] ] = $entry; } // The hierarchy: a parent must exist (else the row stands alone, with a problem); a child without a `page` // takes its parent's title (the chip reads "Parent: Title", the palette "Parent › Title"). foreach ( $this->entries as $id => &$entry ) { if ( '' === $entry['parent'] ) { continue; } if ( $entry['parent'] === $id || ! isset( $this->entries[ $entry['parent'] ] ) ) { $this->problems[ $id ] = 'unknown parent'; $entry['parent'] = ''; continue; } if ( '' === $entry['page'] ) { $entry['page'] = (string) $this->entries[ $entry['parent'] ]['title']; } } unset( $entry ); // The group (0.52.1): a row without one, in a section with groups, takes its page's (the first group up its // chain in the same section), so a page's tabs and cards stand in the page's column; a page without one, or a // child whose chain leaves the section, lands in the section's last group. Parents come before children (the // registration order), so a parent's group is settled first. foreach ( $this->entries as &$entry ) { $groups = $this->products[ $entry['product'] ]['sections'][ $entry['section'] ]['groups'] ?? array(); if ( '' !== $entry['group'] || array() === $groups ) { continue; } $up = (string) $entry['parent']; for ( $depth = 0; '' !== $up && isset( $this->entries[ $up ] ) && $depth < 4; $depth++ ) { $above = $this->entries[ $up ]; if ( $above['section'] !== $entry['section'] ) { break; } if ( '' !== $above['group'] ) { $entry['group'] = (string) $above['group']; break; } $up = (string) $above['parent']; } if ( '' === $entry['group'] ) { $entry['group'] = (string) array_key_last( $groups ); } } unset( $entry ); // Icons (0.12.0): a page without one gets one from its words; a child without one, or with only an icon of // the chain above it (its tab's, its page's), gets one from its own words (not the page's when another fits), // else its parent's (then Icons::of_entry() falls back to the section's). Parents before children: the // entries are in registration order, a page before its tabs and cards, so a parent's icon is settled first. // Cached (0.13.0): the pass costs about 20 ms for two products' 1,800 rows, so its result is kept in one // transient keyed by everything it reads; a changed title, icon or parent, or a new library, rebuilds it. $key = Icons::cache_key( $this->entries ); $cached = Icons::cached( $key ); if ( null !== $cached ) { foreach ( $cached as $id => $icon ) { if ( isset( $this->entries[ $id ] ) ) { $this->entries[ $id ]['icon'] = $icon; } } } foreach ( $this->entries as &$entry ) { if ( null !== $cached ) { break; } if ( '' === $entry['parent'] ) { if ( '' === $entry['icon'] ) { $entry['icon'] = Icons::guess( $entry['title'], $entry['description'] ); } continue; } $above = array(); $up = $entry['parent']; for ( $depth = 0; '' !== $up && isset( $this->entries[ $up ] ) && $depth < 4; $depth++ ) { $above[] = (string) $this->entries[ $up ]['icon']; $up = (string) $this->entries[ $up ]['parent']; } if ( '' === $entry['icon'] || in_array( $entry['icon'], $above, true ) ) { $own = Icons::guess( $entry['title'], $entry['description'], (string) ( $above[ count( $above ) - 1 ] ?? '' ) ); $entry['icon'] = '' !== $own ? $own : (string) $above[0]; } } unset( $entry ); if ( null === $cached ) { Icons::remember( $key, array_map( static fn( array $e ): string => (string) $e['icon'], $this->entries ) ); } // Registry order: product, then section order, then registration order (the menu bar and Home follow it). $order = 0; foreach ( $this->entries as &$entry ) { $entry['order'] = $order++; } unset( $entry ); uasort( $this->entries, function ( array $a, array $b ): int { return ( $this->products[ $a['product'] ]['order'] <=> $this->products[ $b['product'] ]['order'] ) ?: ( $this->section_order( $a ) <=> $this->section_order( $b ) ) ?: ( $a['order'] <=> $b['order'] ); } ); } /** * A section's order within its product (999 for an unknown section). * * @param array $entry Entry. */ private function section_order( array $entry ): int { return $this->products[ $entry['product'] ]['sections'][ $entry['section'] ]['order'] ?? 999; } /** * One validated entry, or null (a problem recorded). * * @param array $row Row. * @return array|null */ private function validate( array $row ): ?array { $id = (string) ( $row['id'] ?? '' ); $product = (string) ( $row['product'] ?? '' ); if ( 1 !== preg_match( self::ID, $id ) ) { $this->problems[ 'row:' . substr( $id, 0, 60 ) ] = 'bad id'; return null; } if ( ! isset( $this->products[ $product ] ) ) { $this->problems[ $id ] = 'unknown product'; return null; } $kind = (string) ( $row['kind'] ?? self::PAGE ); if ( ! in_array( $kind, self::KINDS, true ) ) { $this->problems[ $id ] = 'bad kind'; return null; } $title = (string) ( $row['title'] ?? '' ); $url = esc_url_raw( (string) ( $row['url'] ?? '' ) ); if ( '' === $title || '' === $url ) { $this->problems[ $id ] = 'a title and a URL are required'; return null; } $sections = $this->products[ $product ]['sections']; $section = (string) ( $row['section'] ?? '' ); if ( ! isset( $sections[ $section ] ) ) { $this->problems[ $id ] = 'unknown section'; return null; } $group = (string) ( $row['group'] ?? '' ); if ( '' !== $group && ! isset( $sections[ $section ]['groups'][ $group ] ) ) { $group = ''; // settled with the hierarchy: its page's group, else the section's last (seal()) } $parts = wp_parse_url( $url ); $args = array(); if ( is_array( $parts ) && isset( $parts['query'] ) ) { parse_str( (string) $parts['query'], $args ); } $audiences = array_values( array_intersect( array_keys( $this->products[ $product ]['audiences'] ), $this->list( $row['audiences'] ?? array() ) ) ); $path = is_array( $parts ) && isset( $parts['path'] ) ? basename( (string) $parts['path'] ) : ''; $args = array_map( 'strval', array_filter( $args, 'is_scalar' ) ); $available = ! isset( $row['available'] ) || ! empty( $row['available'] ); // A core screen of a taxonomy or a post type this site has not registered answers "Invalid taxonomy" (a 500): // the row is unavailable here, whatever the product says (0.29.1: Rooster One's Identity tab on a shop // without the Need attribute). if ( $available && in_array( $path, array( 'edit-tags.php', 'term.php' ), true ) && '' !== (string) ( $args['taxonomy'] ?? '' ) && ! taxonomy_exists( (string) $args['taxonomy'] ) ) { $available = false; } if ( $available && in_array( $path, array( 'edit.php', 'post-new.php' ), true ) && '' !== (string) ( $args['post_type'] ?? '' ) && ! post_type_exists( (string) $args['post_type'] ) ) { $available = false; } return array( 'id' => $id, 'kind' => $kind, 'product' => $product, 'section' => $section, 'group' => $group, 'title' => $title, 'description' => (string) ( $row['description'] ?? '' ), 'url' => Request::view_url( $url ), // a screen's view arguments (a tab) carry the view nonce (0.69.0) 'icon' => (string) ( $row['icon'] ?? '' ), 'capabilities' => $this->list( $row['capabilities'] ?? array() ), 'audiences' => $audiences, 'keywords' => $this->list( $row['keywords'] ?? array() ), 'pin' => ! isset( $row['pin'] ) || ! empty( $row['pin'] ), 'available' => $available, 'badge' => (string) ( $row['badge'] ?? '' ), 'page' => (string) ( $row['page'] ?? '' ), 'parent' => (string) ( $row['parent'] ?? '' ), 'current' => ! isset( $row['current'] ) || ! empty( $row['current'] ), 'listed' => ! isset( $row['listed'] ) || ! empty( $row['listed'] ), 'path' => $path, 'args' => $args, 'anchor' => is_array( $parts ) ? (string) ( $parts['fragment'] ?? '' ) : '', ); } /** * A list of strings from a list, a comma-separated string, or nothing. * * @param mixed $value Value. * @return list */ private function list( $value ): array { if ( is_string( $value ) ) { $value = preg_split( '/[,\s]+/', $value ) ?: array(); } if ( ! is_array( $value ) ) { return array(); } return array_values( array_unique( array_filter( array_map( static fn( $v ): string => is_scalar( $v ) ? trim( (string) $v ) : '', $value ), 'strlen' ) ) ); } /* ── Reading ──────────────────────────────────────────────────────── */ /** * Whether the registry is sealed. */ public function sealed(): bool { return $this->sealed; } /** * The products, registration order: slug => product. * * @return array> */ public function products(): array { return $this->products; } /** * One product, or null. * * @param string $slug Slug. * @return array|null */ public function product( string $slug ): ?array { return $this->products[ $slug ] ?? null; } /** * The settings screens: slug => screen. * * @return array> */ public function screens(): array { return $this->screens; } /** * The Home cards of a product's slot, in order: each escaped HTML or a callable. * * @param string $product Product slug. * @param string $slot Slot. * @return list */ public function home_cards( string $product, string $slot ): array { $cards = $this->cards[ $product ][ $slot ] ?? array(); usort( $cards, static fn( array $a, array $b ): int => $a[0] <=> $b[0] ); return array_map( static fn( array $card ) => $card[1], $cards ); } /** * The validated entries of a product (every product with null), of the given kinds, registry order: id => entry. * * @param string|null $product Product slug, or null for all. * @param string[] $kinds Kinds. * @return array> */ public function entries( ?string $product = null, array $kinds = self::KINDS ): array { $this->ready(); $out = array(); foreach ( $this->entries as $id => $entry ) { if ( ( null === $product || $entry['product'] === $product ) && in_array( $entry['kind'], $kinds, true ) ) { $out[ $id ] = $entry; } } return $out; } /** * One entry, or null. * * @param string $id Id. * @return array|null */ public function get( string $id ): ?array { $this->ready(); return $this->entries[ $id ] ?? null; } /** * The entry an element of the page stands for by its id (0.87.0, the card builder's pin; it replaced chrome.js's * pins.byAnchor()): the first of the entries the chrome's script lists for this screen (every visible entry of the * current product, a page of another) whose link ends with `#`; null for none. The index is built once per * request and user, the first time it is asked, never per card. * * @param string $id The element's id. * @return array|null */ public function anchored( string $id ): ?array { if ( '' === $id ) { return null; } $current = $this->current_product(); $memo = get_current_user_id() . '|' . $current; if ( ! isset( $this->anchored[ $memo ] ) ) { $index = array(); foreach ( $this->visible( null ) as $entry ) { if ( $entry['product'] !== $current && self::PAGE !== $entry['kind'] ) { continue; // as Chrome::script_data(): another product's pages only } $url = (string) $entry['url']; $at = strrpos( $url, '#' ); if ( false !== $at && $at + 1 < strlen( $url ) ) { $index[ substr( $url, $at + 1 ) ] ??= $entry; // the first one, as the script's find() took } } $this->anchored[ $memo ] = $index; } return $this->anchored[ $memo ][ $id ] ?? null; } /** * The entries the current user may open (any of the entry's capabilities, or none required), of a product (or all) * and the given kinds: id => entry. An unlisted entry is among them (0.57.0, CONTRACT §3 `listed`: the palette, the * pins and the current screen keep it); the menu bar and Home draw listed() of them. Memoised per request: the whole * catalogue is filtered once per user, the product and kind subsets are taken from it. * * @param string|null $product Product slug, or null for all. * @param string[] $kinds Kinds. * @return array> */ public function visible( ?string $product = null, array $kinds = self::KINDS ): array { $user = get_current_user_id(); $memo = $user . '|' . ( $product ?? '*' ) . '|' . implode( ',', $kinds ); if ( isset( $this->visible[ $memo ] ) ) { return $this->visible[ $memo ]; } $all = $user . '|*|' . implode( ',', self::KINDS ); if ( ! isset( $this->visible[ $all ] ) ) { $out = array(); foreach ( $this->entries() as $id => $entry ) { if ( ! $entry['available'] ) { if ( ! $entry['listed'] ) { continue; // a teaser is an invitation: an unlisted one invites nobody } // An entry of an inactive add-on (the LMS's Pro and LTI rows, Rooster One's Pro rows): a badged // teaser, pages only (their cards and tabs stay out), for everyone when it names no capability, // else for whoever may open the product's Home: its own capability belongs to the add-on and nobody // holds it while the add-on is off. if ( self::PAGE === $entry['kind'] && ( array() === $entry['capabilities'] || $this->can_open_home( (string) $entry['product'] ) ) ) { $out[ $id ] = $entry; } continue; } if ( $this->allowed( $entry ) ) { $out[ $id ] = $entry; } } $this->visible[ $all ] = $out; } $out = array(); foreach ( $this->visible[ $all ] as $id => $entry ) { if ( ( null === $product || $entry['product'] === $product ) && in_array( $entry['kind'], $kinds, true ) ) { $out[ $id ] = $entry; } } $this->visible[ $memo ] = $out; return $out; } /** * The listed entries of a list (0.57.0): what the menu bar's dropdowns and Home draw. An unlisted entry stays in the * palette and the pins (CONTRACT §3 `listed`). * * @param array> $entries Entries. * @return array> */ public static function listed( array $entries ): array { return array_filter( $entries, static fn( array $entry ): bool => ! empty( $entry['listed'] ) ); } /** * Whether the current user may open a product's Home page (its `home.capability`, memoised with the capabilities). * * @param string $product Product slug. */ private function can_open_home( string $product ): bool { $cap = (string) ( $this->products[ $product ]['home']['capability'] ?? 'read' ); return $this->allowed( array( 'capabilities' => array( $cap ) ) ); } /** * Whether the current user may open an entry (each capability checked once per request). * * @param array $entry Entry. */ public function allowed( array $entry ): bool { if ( array() === $entry['capabilities'] ) { return true; } $user = get_current_user_id(); if ( $user !== $this->caps_user ) { $this->caps = array(); $this->caps_user = $user; } foreach ( $entry['capabilities'] as $cap ) { if ( ! isset( $this->caps[ $cap ] ) ) { $this->caps[ $cap ] = self::can( $cap ); } if ( $this->caps[ $cap ] ) { return true; } } return false; } /** * Whether the current user holds a capability spec (CONTRACT §3): a capability name; `post_type::` * (the post type's mapped capability, resolved now, after the post types registered on `init`); * `taxonomy::`; specs joined with `|` (any one of them); or a product's own word, which the * product answers through `rooster/admin/capability` (the LMS's `grading`). The adapters pass the plugins' * existing specs through unchanged. * * @param string $spec The spec. */ public static function can( string $spec ): bool { if ( str_contains( $spec, '|' ) ) { foreach ( explode( '|', $spec ) as $one ) { if ( '' !== $one && self::can( $one ) ) { return true; } } return false; } $parts = explode( ':', $spec, 3 ); if ( 3 === count( $parts ) && 'post_type' === $parts[0] ) { $object = get_post_type_object( $parts[1] ); $cap = null !== $object ? (string) ( $object->cap->{$parts[2]} ?? '' ) : ''; return '' !== $cap && current_user_can( $cap ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- the post type's own capability. } if ( 3 === count( $parts ) && 'taxonomy' === $parts[0] ) { $object = get_taxonomy( $parts[1] ); $cap = false !== $object ? (string) ( $object->cap->{$parts[2]} ?? '' ) : ''; return '' !== $cap && current_user_can( $cap ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- the taxonomy's own capability. } /** * Filters whether the current user holds a product's own capability word (null = not a word of yours; a plain * capability is then checked with current_user_can()). * * @since 0.9.0 * * @param bool|null $can The answer, or null. * @param string $spec The spec. */ $own = apply_filters( 'rooster/admin/capability', null, $spec ); if ( is_bool( $own ) ) { return $own; } return current_user_can( $spec ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- the product's own capability. } /** * Whether a product has any entry the current user may open and that is available. * * @param string $product Product slug. */ public function any_visible( string $product ): bool { foreach ( $this->visible( $product ) as $entry ) { if ( $entry['available'] ) { return true; } } return false; } /** * Entries by section, the product's section order, sections with entries only. * * @param array> $entries Entries (one product's). * @param string $product Product slug. * @return array>> */ public function by_section( array $entries, string $product ): array { $out = array(); foreach ( $this->products[ $product ]['sections'] ?? array() as $key => $section ) { $out[ $key ] = array(); } foreach ( $entries as $entry ) { if ( $entry['product'] === $product ) { $out[ $entry['section'] ][] = $entry; } } return array_filter( $out ); } /** * A section's entries by group, stack order (a section without groups: one group ''). * * @param string $product Product slug. * @param string $section Section key. * @param list> $entries The section's entries. * @return array>> */ public function by_group( string $product, string $section, array $entries ): array { $def = $this->products[ $product ]['sections'][ $section ] ?? null; if ( null === $def || array() === $def['groups'] ) { return array( '' => array_values( $entries ) ); } $out = array(); foreach ( array_merge( ...$def['stacks'] ) as $group ) { $out[ $group ] = array(); } foreach ( $entries as $entry ) { $out[ $entry['group'] ][] = $entry; } return $out; } /** * A section's entries per stack: stack => group => entries. * * @param string $product Product slug. * @param string $section Section key. * @param list> $entries The section's entries. * @return list>>> */ public function stacks( string $product, string $section, array $entries ): array { $def = $this->products[ $product ]['sections'][ $section ] ?? null; if ( null === $def || array() === $def['groups'] ) { return array(); } $by = $this->by_group( $product, $section, $entries ); $out = array(); foreach ( $def['stacks'] as $stack ) { $groups = array(); foreach ( $stack as $group ) { $groups[ $group ] = $by[ $group ] ?? array(); } $out[] = $groups; } return $out; } /** * The entries of a list as a tree: each node `{entry, children, foreign}`. An entry whose parent is in the list * nests under it; an entry whose parent is outside the list (a slot of the Templates page shown under Store, a * settings card shown under Design) stands alone there, under the list's heading, as does an entry without a parent * (0.57.0, MENU-AUDIT round 2: the muted head of the parent it used to nest under drew a page a second time, once * per group, marked current and blind to the "For:" choice; the child's chip and palette label still name the * parent). `foreign` stays in the node's shape, always false. * * @param list> $entries Entries (registry order). * @return list, children: list, foreign: bool}> */ public function tree( array $entries ): array { $pool = array(); foreach ( $entries as $entry ) { $pool[ $entry['id'] ] = $entry; } $nodes = array(); foreach ( $pool as $id => $entry ) { $nodes[ $id ] = array( 'entry' => $entry, 'children' => array(), 'foreign' => false, ); } $top = array(); foreach ( $pool as $id => $entry ) { $parent = (string) $entry['parent']; if ( '' === $parent || ! isset( $nodes[ $parent ] ) ) { $top[] = $id; continue; } $nodes[ $parent ]['children'][] = $id; } $build = function ( string $id ) use ( &$build, &$nodes ): array { $node = $nodes[ $id ]; $node['children'] = array_map( $build, $node['children'] ); return $node; }; return array_map( $build, $top ); } /** * How many distinct pages the entries cover (the Home summary). * * @param array> $entries Entries. */ public function page_count( array $entries ): int { $pages = array(); foreach ( $entries as $entry ) { $pages[ $entry['path'] . '?' . http_build_query( $entry['args'] ) ] = true; } return count( $pages ); } /* ── The current screen ───────────────────────────────────────────── */ /** * The Home page's URL of a product. * * @param string $product Product slug. */ public function home_url( string $product ): string { $home = $this->products[ $product ]['home'] ?? null; if ( null === $home ) { return admin_url(); } return self::page_url( $home['parent'], $home['slug'] ); } /** * The URL of an admin page under a parent ('' = a top-level page under admin.php). * * @param string $parent Parent menu slug or file. * @param string $slug Page slug. */ public static function page_url( string $parent, string $slug ): string { $file = str_ends_with( $parent, '.php' ) ? $parent : 'admin.php'; return admin_url( add_query_arg( 'page', $slug, $file ) ); } /** * The current request's plugin page ('' when none): WordPress's own $plugin_page (wp-admin/admin.php sets it before * `admin_menu`; 0.69.0: never read from the request). */ public static function current_page(): string { return (string) ( $GLOBALS['plugin_page'] ?? '' ); } /** * A query argument of the request as a menu entry names it: WordPress's own routing arguments from WordPress's * globals (the plugin page, a list's post type and taxonomy), every other one through the view-nonce-checked * Request::text() (null when absent). * * @param string $name The argument's name. */ private static function request_arg( string $name ): ?string { $core = array( 'page' => 'plugin_page', 'post_type' => 'typenow', 'taxonomy' => 'taxnow', ); if ( isset( $core[ $name ] ) ) { $value = (string) ( $GLOBALS[ $core[ $name ] ] ?? '' ); return '' === $value ? null : $value; } return Request::text( $name ); } /** * The product whose screen this request is ('' when none): the product's Home page, a settings screen of its, a * page of its `screens.pages`, a screen id of its `screens.ids`, a hook suffix of its `screens.hooks`, else the * `rooster/admin/current_product` filter's answer. Never in admin-ajax, never in the block editor. */ public function current_product(): string { if ( null !== $this->current ) { return $this->current; } if ( ! is_admin() || wp_doing_ajax() ) { return ''; } $page = self::current_page(); $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( null !== $screen && $screen->is_block_editor() ) { $this->current = ''; return ''; } $found = ''; if ( '' !== $page ) { foreach ( $this->products as $slug => $product ) { if ( $product['home']['slug'] === $page || in_array( $page, $product['screens']['pages'], true ) ) { $found = $slug; break; } } if ( '' === $found ) { foreach ( $this->screens as $slug => $settings ) { if ( $slug === $page ) { $found = $settings['product']; break; } } } } if ( '' === $found && null !== $screen ) { $hook = (string) ( $GLOBALS['hook_suffix'] ?? '' ); foreach ( $this->products as $slug => $product ) { if ( in_array( $screen->id, $product['screens']['ids'], true ) || ( '' !== $hook && in_array( $hook, $product['screens']['hooks'], true ) ) ) { $found = $slug; break; } } } if ( '' === $found ) { /** * Filters the product whose screen the current admin request is, when the registry cannot tell from the * page slug, screen id or hook suffix (a product's post-type list, for instance). Return a registered slug. * * @since 0.1.0 * * @param string $product '' (none). * @param \WP_Screen|null $screen The current screen, or null before it is set. */ $answer = apply_filters( 'rooster/admin/current_product', '', $screen ); if ( is_string( $answer ) && isset( $this->products[ $answer ] ) ) { $found = $answer; } } // Before the screen is set (admin_menu) a miss is not final. if ( '' !== $found || null !== $screen ) { $this->current = $found; } return $found; } /** * Forgets the request memo (tests). * * @internal */ public function forget(): void { $this->current = null; $this->visible = array(); $this->anchored = array(); $this->caps = array(); $this->caps_user = -1; } /** * The id of the one visible entry that is the current screen ('' when none): an available entry whose path is the * current admin file and whose every query argument the request carries; of several, the one with the most * arguments, then registry order; never an entry with an anchor or with `current` false. * * @param array> $visible Visible entries. */ public function current_id( array $visible ): string { $page = self::current_page(); if ( '' !== $page ) { foreach ( $this->products as $product ) { if ( $page === $product['home']['slug'] ) { return ''; // a product's Home is no entry: nothing is current there (the LMS's list under the same file would match) } } } $now = (string) ( $GLOBALS['pagenow'] ?? '' ); $best = ''; $most = -1; foreach ( $visible as $id => $entry ) { if ( ! $entry['available'] || ! $entry['current'] || '' !== $entry['anchor'] || $entry['path'] !== $now ) { continue; } foreach ( $entry['args'] as $name => $value ) { $got = self::request_arg( (string) $name ); if ( $got !== $value ) { continue 2; } } if ( count( $entry['args'] ) > $most ) { $best = (string) $id; $most = count( $entry['args'] ); } } /** * Filters the id of the current entry (0.10.0): '' when no entry's URL matches the request. A product answers * for a screen whose entry links elsewhere (the LMS's Courses page, reached by a redirect from the core list * its entry names; its quiz editor, which belongs to the Quizzes page). An answer the user does not see is * no answer. * * @since 0.10.0 * * @param string $best The id ('' = none). * @param array> $visible The visible entries. */ $best = (string) apply_filters( 'rooster/admin/current_id', $best, $visible ); return isset( $visible[ $best ] ) ? $best : ''; } /** * The section of the current screen within a product ('' on Home or an unknown screen). * * @param string $product Product slug. * @param array> $visible The product's visible entries. */ public function current_section( string $product, array $visible ): string { $page = self::current_page(); if ( '' !== $page && $page === ( $this->products[ $product ]['home']['slug'] ?? '' ) ) { return ''; } $id = $this->current_id( $visible ); if ( '' !== $id ) { return (string) $visible[ $id ]['section']; } // A screen without an entry of its own: the section of any entry on the same page. $now = (string) ( $GLOBALS['pagenow'] ?? '' ); foreach ( $visible as $entry ) { if ( $entry['path'] === $now && ( $entry['args']['page'] ?? '' ) === $page && '' !== $page ) { return (string) $entry['section']; } } return ''; } }