products() as $slug => $product ) { $home = $product['home']; if ( ! $home['enabled'] || ! current_user_can( $home['capability'] ) ) { // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- the product's own capability. continue; } $render = static function () use ( $slug ): void { self::render( $slug ); }; if ( '' === $home['parent'] ) { add_menu_page( $home['title'], self::menu_wordmark( $product ), $home['capability'], $home['slug'], $render, $home['menu_icon'], $home['position'] ); add_submenu_page( $home['slug'], $home['title'], $home['menu_title'], $home['capability'], $home['slug'], $render ); } else { // A Home under another menu whose item is the product's name (Rooster Zero under Appearance) wears the // wordmark too; one that reads "Home" (the LMS's, Rooster One's) stays a plain word. $label = $home['menu_title'] === $product['name'] ? self::menu_wordmark( $product ) : $home['menu_title']; add_submenu_page( $home['parent'], $home['title'], $label, $home['capability'], $home['slug'], $render, $home['position'] ); } } } /** * The side menu's title of a Home the library registers (0.16.0): the wordmark as the LMS and Rooster One paint * their own. Since 0.18.0 "Rooster" is one word in the mark's own gradient, left to right as the mark reads (the * tail's blue and green, the body, the comb's red, the beak's yellow: the owner, "do this now for the word * Rooster"), and the product word is in the product's colour (0.17.6), as a gradient that starts from that * yellow (0.17.7). For a name of the "Rooster Xyz" shape; a plain name otherwise. * * @param array $product Product. */ public static function menu_wordmark( array $product ): string { $name = (string) $product['name']; if ( 1 !== preg_match( '/^Rooster [^\s]{2,}$/u', $name ) ) { return esc_html( $name ); } return '' // the slug: menu_css() colours the product word per product . '' . esc_html( substr( $name, 0, 7 ) ) . ' ' . '' . esc_html( substr( $name, 8 ) ) . ''; } /** * The mark's colours as a gradient across "Rooster", for the dark menu and for the light scheme (the body white * on dark, black on light; the blue, green and yellow in the lifted set the LMS chose for the dark menu). The * comb's own red (0.18.1) is held from 64 % to 72 %, over "te" and no earlier (0.18.2: three letters were too many), so it peaks as red instead of being washed to * salmon by the white before it and the yellow after it (the owner: "the red is never actually red"). */ public const ROOSTER_DARK = 'linear-gradient(90deg,#75beff,#8fe03a 28%,#f3f1f1 50%,#d90b0e 64%,#d90b0e 72%,#ffde00)'; public const ROOSTER_LIGHT = 'linear-gradient(90deg,#184fa6,#3a680c 28%,#212222 50%,#d90b0e 64%,#d90b0e 72%,#7a6a00)'; /** * The side menu's element id of a product's menu, from its Home's parent: `toplevel_page_` for a plugin * page (the Home itself when it is the top-level item), `menu-posts-` for a post type's; '' for a core * menu (Appearance, Settings), which keeps its own look, and for any parent that is not a plain slug (0.81.0, the * wordpress.org review: the id goes into menu_css()'s selectors, so only letters, digits, '-' and '_' reach them). * * @param array $product Product. */ public static function menu_id( array $product ): string { $parent = (string) $product['home']['parent']; if ( 1 === preg_match( '/^edit\.php\?post_type=([a-z0-9_-]+)$/', $parent, $m ) ) { return 'menu-posts-' . $m[1]; } $page = '' === $parent ? (string) $product['home']['slug'] : $parent; return 1 === preg_match( '/^[a-z0-9_-]+$/i', $page ) ? 'toplevel_page_' . $page : ''; } /** * Filter custom_menu_order: true when a product's Home names the item it follows (`home.after`), so that * menu_order runs. * * @param bool $custom The value so far. */ public static function custom_menu_order( $custom ): bool { $registry = Library::registry(); if ( null === $registry ) { return (bool) $custom; } foreach ( $registry->products() as $product ) { if ( '' !== $product['home']['after'] && '' === $product['home']['parent'] ) { return true; } } return (bool) $custom; } /** * Filter menu_order: each top-level Home that names the item it follows moves right after that item (when the * item is in the menu; else it keeps the place its position gave it). WordPress resolves positions by itself * (a core position such as Comments' 25 pushes a post type's menu to 26, a collision adds a hash fraction), so * a slug is the one reliable way to say "under Rooster LMS". * * @param array $order The menu's slugs in order. * @return array */ public static function menu_order( $order ): array { $order = is_array( $order ) ? array_values( $order ) : array(); $registry = Library::registry(); if ( null === $registry ) { return $order; } foreach ( $registry->products() as $product ) { $after = (string) $product['home']['after']; $slug = (string) $product['home']['slug']; if ( '' === $after || '' !== $product['home']['parent'] || ! in_array( $slug, $order, true ) || ! in_array( $after, $order, true ) ) { continue; } $order = array_values( array_diff( $order, array( $slug ) ) ); $at = array_search( $after, $order, true ); array_splice( $order, (int) $at + 1, 0, array( $slug ) ); } return $order; } /** * Hook admin_enqueue_scripts: menu_css() as an inline style of its own handle (0.68.12, the wordpress.org review: * no style element echoed; it was printed on admin_head), on the Rooster pages only (0.81.0, the wordpress.org * review: a plugin's styles only on its own screens; on WordPress's screens our menu entries wear the menu's own * colours). */ public static function enqueue_menu_css(): void { if ( ! Library::rooster_page() ) { return; } $css = self::menu_css(); if ( '' === $css ) { return; } wp_register_style( 'rooster-admin-menu', false, array(), Library::VERSION ); // an inline style only, no file wp_enqueue_style( 'rooster-admin-menu' ); wp_add_inline_style( 'rooster-admin-menu', wp_strip_all_tags( $css ) ); } /** * The side menu's CSS: the wordmark's four colours, the last group in the product's own colour, and each product's * menu icon in that colour (a Dashicon takes it; an image icon keeps its own). Every selector names our own * elements only (0.81.0, the wordpress.org review): the wordmark's spans in our menu titles, by their classes, and * our own menu items' icons, by the items' ids. Hex colours (Colour::hex()), class names (sanitize_html_class()) and * ids of letters, digits, '-' and '_' (menu_id()) only: no value the request gives. */ public static function menu_css(): string { $registry = Library::registry(); if ( null === $registry ) { return ''; } // "Rooster" in the mark's gradient, left to right as the mark reads: the tail's blue and green, the body (white // on the dark menu, black on the light scheme), the comb's red, the beak's yellow (0.18.0). $css = '.rooster-admin-menuword{white-space:nowrap}li.menu-top > a .rooster-admin-menuword{font-weight:600}' // bold as a top-level item, the submenu's own weight below one . '.rooster-admin-mw-rooster{color:#8fe03a;background-image:' . self::ROOSTER_DARK . ';-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}' . 'body.admin-color-light .rooster-admin-mw-rooster{color:#3a680c;background-image:' . self::ROOSTER_LIGHT . '}' . '.rooster-admin-mw-4{color:#ffde00}body.admin-color-light .rooster-admin-mw-4{color:#7a6a00}'; foreach ( $registry->products() as $product ) { if ( 1 !== preg_match( '/^#[0-9a-f]{6}$/i', (string) $product['colour'] ) ) { continue; } // The wordmark's last letters in the product's own colour, wherever the wordmark is (0.17.5: "MS" green, // "LRS" violet, "One" blue, "Demo" red, "ero" of Rooster Zero orange under Appearance). // …and across the word a gradient (0.17.7: the owner, "a gradient from the first to the last letter of the // second word; the first letter, although primarily the product's colour, takes into account the colour // of the Rooster before it: the symmetric approach"): it starts from the colour moved two fifths towards // the yellow "Rooster" ends in (the beak), is the colour itself by the middle and ends in its lighter tint. // The plain colour stays as the fallback. $mark = '.rooster-admin-menuword.is-' . sanitize_html_class( (string) $product['slug'] ) . ' .rooster-admin-mw-4'; $dark = Colour::lift( $product['colour'] ); $light = Colour::ink( $product['colour'] ); $css .= $mark . '{color:' . $dark . ';background-image:linear-gradient(90deg,' . Colour::mix( $dark, '#ffde00', 0.4 ) . ',' . $dark . ' 55%,' . Colour::tint( $dark, 0.4 ) . ');-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}' . 'body.admin-color-light ' . $mark . '{color:' . $light . ';background-image:linear-gradient(90deg,' . Colour::mix( $light, '#7a6a00', 0.4 ) . ',' . $light . ' 55%,' . Colour::tint( $light, 0.3 ) . ')}'; $id = self::menu_id( $product ); if ( '' === $id ) { continue; } $css .= '#adminmenu #' . $id . ' div.wp-menu-image::before,#adminmenu #' . $id . ':hover div.wp-menu-image::before,#adminmenu #' . $id . '.wp-has-current-submenu div.wp-menu-image::before{color:' . $product['colour'] . '}'; } return $css; } /** * Renders a product's Home page. * * @param string $slug Product slug. */ public static function render( string $slug ): void { $registry = Library::registry(); if ( null === $registry || null === $registry->product( $slug ) ) { return; } Markup::out( self::html( $registry, $slug ) ); } /** * The default columns: each section, in order, goes to the column holding the fewest entries so far. * * @param array $weights Section key => entries, section order. * @return list> */ public static function default_layout( array $weights ): array { $cols = array_fill( 0, self::COLUMNS, array() ); $load = array_fill( 0, self::COLUMNS, 0 ); foreach ( $weights as $key => $weight ) { $col = (int) array_search( min( $load ), $load, true ); $cols[ $col ][] = $key; $load[ $col ] += max( 1, $weight ) + 2; } return $cols; } /** * The layout of a user: the saved columns with unknown or absent sections dropped, the sections the save does not * know placed by the default balancing after them. * * @param mixed $saved Saved value (list of lists of section keys) or null. * @param array $weights Section key => entries, section order. * @return list> */ public static function layout( $saved, array $weights ): array { if ( ! is_array( $saved ) || array() === $saved ) { return self::default_layout( $weights ); } $cols = array_fill( 0, self::COLUMNS, array() ); $load = array_fill( 0, self::COLUMNS, 0 ); $seen = array(); foreach ( array_values( array_slice( $saved, 0, self::COLUMNS ) ) as $i => $column ) { foreach ( is_array( $column ) ? $column : array() as $key ) { if ( is_string( $key ) && isset( $weights[ $key ] ) && ! isset( $seen[ $key ] ) ) { $cols[ $i ][] = $key; $load[ $i ] += $weights[ $key ] + 2; $seen[ $key ] = true; } } } foreach ( $weights as $key => $weight ) { if ( ! isset( $seen[ $key ] ) ) { $col = (int) array_search( min( $load ), $load, true ); $cols[ $col ][] = $key; $load[ $col ] += $weight + 2; } } return $cols; } /** * A layout from a client: COLUMNS lists of known section keys, each key once; null when malformed. * * @param mixed $value Value. * @param string[] $known The product's section keys. * @return list>|null */ public static function clean_layout( $value, array $known ): ?array { if ( ! is_array( $value ) || count( $value ) > self::COLUMNS ) { return null; } $seen = array(); $out = array(); foreach ( array_values( $value ) as $column ) { if ( ! is_array( $column ) ) { return null; } $keys = array(); foreach ( $column as $key ) { if ( ! is_string( $key ) || ! in_array( $key, $known, true ) || isset( $seen[ $key ] ) ) { return null; } $seen[ $key ] = true; $keys[] = $key; } $out[] = $keys; } return array_pad( $out, self::COLUMNS, array() ); } /** * The page (escaped here). * * @param Registry $registry The registry. * @param string $slug Product slug. */ public static function html( Registry $registry, string $slug ): string { $product = $registry->product( $slug ); $visible = Registry::listed( $registry->visible( $slug, Registry::HOME_KINDS ) ); $selected = Personas::selected( get_current_user_id(), $product ); return '

' . self::hero( $registry, $product, $visible ) . self::slot( $registry, $slug, 'after_hero' ) . self::slot( $registry, $slug, 'before_catalogue' ) . self::catalogue( $registry, $product, $visible, $selected ) . self::slot( $registry, $slug, 'after_catalogue' ) . self::status( $product ) // last (0.18.3): the state is in the brand bar's tier popover already, and the catalogue is what a person came for (the owner) . Board::reset_dialog() . '
'; } /** * A slot's cards. * * @param Registry $registry The registry. * @param string $slug Product slug. * @param string $slot Slot. */ private static function slot( Registry $registry, string $slug, string $slot ): string { $html = ''; foreach ( $registry->home_cards( $slug, $slot ) as $card ) { $out = is_callable( $card ) ? call_user_func( $card ) : $card; $html .= is_string( $out ) ? $out : ''; } return $html; } /** * The hero: title and strapline, welcome, the introduction, and the right column. * * @param Registry $registry The registry. * @param array $product Product. * @param array> $visible Visible entries. */ private static function hero( Registry $registry, array $product, array $visible ): string { $user = wp_get_current_user(); $tags = array( 'strong' => array(), 'em' => array(), 'a' => array( 'href' => array() ), ); $html = '
' . '

' . esc_html( $product['name'] ) . ( '' !== $product['strapline'] ? '' . esc_html( $product['strapline'] ) . '' : '' ) . '

' /* translators: %s: the user's display name. */ . '

' . sprintf( esc_html__( 'Welcome back %s :)', 'ai-eshop-optimizer' ), '' . esc_html( $user->display_name ) . '' ) . '

'; $intro = $product['intro']; /* translators: %s: the product's name. */ $intro[] = sprintf( __( 'Press Ctrl+K on any %s screen to jump straight to a page, tab, card or setting. Every destination below says in one line what you can do there; pin the ones you use to the menu bar.', 'ai-eshop-optimizer' ), $product['name'] ); $html .= '
'; foreach ( $intro as $paragraph ) { $html .= '

' . wp_kses( $paragraph, $tags ) . '

'; } $html .= '
'; $ai = Chrome::ai_chat_url( $product['slug'] ); if ( '' !== $ai ) { $html .= '' . Icons::ui( 'sparkle', 16 ) . esc_html__( 'Ask AI', 'ai-eshop-optimizer' ) . ( Chrome::is_external( $ai ) ? ' ' . esc_html__( '(opens in a new tab)', 'ai-eshop-optimizer' ) . '' : '' ) . ''; } /* translators: %s: the product's name. */ $label = sprintf( __( 'Search %s', 'ai-eshop-optimizer' ), $product['name'] ); $html .= ''; return $html . '
'; // a region, not an aside: core's #wpbody is the main landmark and an aside inside it is flagged (axe, 0.26.0) } /** * The status card: the product's rows (Chrome::status_rows()), '' without any. * * @param array $product Product. */ private static function status( array $product ): string { $rows = Chrome::status_rows( $product ); if ( array() === $rows ) { return ''; } return '

' . esc_html__( 'Status', 'ai-eshop-optimizer' ) . '

' . Chrome::status_list( $rows ) . '
'; } /** * The catalogue: the summary card and the section blocks in columns. * * @param Registry $registry The registry. * @param array $product Product. * @param array> $visible Visible entries. * @param string[] $selected Audience choice. */ private static function catalogue( Registry $registry, array $product, array $visible, array $selected ): string { $slug = $product['slug']; $sections = $registry->by_section( $visible, $slug ); $weights = array_map( 'count', $sections ); $layout = self::layout( Prefs::get( get_current_user_id(), Prefs::LAYOUT, $slug ), $weights ); $count = count( $visible ); /* translators: 1: number of functions, 2: number of pages. */ $title = sprintf( _n( 'Full catalogue — %1$s function across %2$s pages', 'Full catalogue — %1$s functions across %2$s pages', $count, 'ai-eshop-optimizer' ), number_format_i18n( $count ), number_format_i18n( $registry->page_count( $visible ) ) ); /* translators: %s: the product's name. */ $lead = sprintf( __( 'Every page, card and setting of %s you may open, with what you can do there. Press Ctrl+K to search them, drag a block by its handle to move it, or pin any item to the menu bar.', 'ai-eshop-optimizer' ), $product['name'] ); $html = '
' . '
' . '

' . esc_html( $title ) . '

' . esc_html( $lead ) . '

' . '

' . esc_html__( 'Showing the functions of the roles chosen under "For:" in the menu bar.', 'ai-eshop-optimizer' ) . '

' . '
' . Board::edit_button( 'home', true ) . '
'; // The catalogue is the Home's board (0.58.0, the Metronic note's F9): one row of three stacks of cards, whose // columns are the person's own as before (Prefs::LAYOUT: one list of section keys per column) until they arrange // the board itself (0.80.0: its rows, their layouts and the blocks in them, as on every page; Prefs::BOARDS' // `home`). $columns = array(); foreach ( $layout as $column ) { $blocks = array(); foreach ( $column as $key ) { $blocks[] = self::block( $registry, $product, $key, $sections[ $key ], $selected ); } $columns[] = $blocks; } $html .= Board::render( 'home', array( array( 'key' => 'catalogue', 'cols' => self::COLUMNS, 'min' => self::MIN, 'columns' => $columns, ), ), array( 'reset' => false ) // the summary's own button resets it ) . self::rest( $product, Personas::sections( $product, $sections, $selected )['folded'], $sections ); return $html . '

' . esc_html__( 'Nothing here for the chosen roles. Choose "Everything" under "For:" in the menu bar.', 'ai-eshop-optimizer' ) . '

'; } /** * "Everything else": the sections without a function for the chosen roles, collapsed under the blocks. * * @param array $product Product. * @param string[] $folded Folded section keys. * @param array>> $sections Entries by section. */ private static function rest( array $product, array $folded, array $sections ): string { if ( array() === $folded ) { return ''; } $count = 0; $body = ''; foreach ( $folded as $key ) { $def = $product['sections'][ $key ]; $body .= '

' . Icons::svg( $def['icon'], 16 ) . esc_html( $def['label'] ) . '

'; } /* translators: %s: number of functions. */ $summary = sprintf( _n( 'Everything else — %s function for other roles', 'Everything else — %s functions for other roles', $count, 'ai-eshop-optimizer' ), number_format_i18n( $count ) ); return '
' . esc_html( $summary ) . '
' . $body . '
'; } /** * Whether any entry shows for the choice. * * @param array $product Product. * @param array> $visible Visible entries. * @param string[] $selected Audience choice. */ private static function any_match( array $product, array $visible, array $selected ): bool { foreach ( $visible as $entry ) { if ( Personas::matches( $product, (array) $entry['audiences'], $selected ) ) { return true; } } return array() === $visible; } /** * One section block: a grouped section lists its entries under the group headings, in stack order; the others as * one list. * * @param Registry $registry The registry. * @param array $product Product. * @param string $key Section key. * @param list> $entries The section's entries. * @param string[] $selected Audience choice. */ private static function block( Registry $registry, array $product, string $key, array $entries, array $selected ): string { $def = $product['sections'][ $key ]; $shown = 0; $items = ''; if ( array() !== $def['groups'] ) { foreach ( $registry->by_group( $product['slug'], $key, $entries ) as $group => $members ) { if ( array() === $members ) { continue; } $any = false; foreach ( $members as $entry ) { $match = Personas::matches( $product, (array) $entry['audiences'], $selected ); $any = $any || $match; $shown += $match ? 1 : 0; } $items .= '

' . esc_html( $def['groups'][ $group ] ?? $group ) . '

    ' . self::items( $registry, $product, $members, $selected ) . '
'; } } else { foreach ( $entries as $entry ) { $shown += Personas::matches( $product, (array) $entry['audiences'], $selected ) ? 1 : 0; } $items = '
    ' . self::items( $registry, $product, $entries, $selected ) . '
'; } $id = 'rooster-admin-home-' . $key; // a block's anchor, linked from the menu bar and the products; the page's own ids are rooster-admin-homepage-*, so a section named status or search does not collide (0.46.7) // A card of the Home's board (0.58.0): the card's head (the board adds the grip), the block's lists as its body; // its key on the board is the section's, as the saved layout names it. return '
' . '
' . Icons::svg( $def['icon'], 20, 'rooster-admin-card__icon' ) . '

' . esc_html( $def['label'] ) . '

' . esc_html( number_format_i18n( $shown ) ) . '
' . '
' . $items . '
'; } /** * The entries of a block as a tree (Registry::tree()): a parent's children nest under it, a parent shown away * from its own section heads them in a muted row. * * @param Registry $registry The registry. * @param array $product Product. * @param list> $entries Entries. * @param string[] $selected Audience choice. */ public static function items( Registry $registry, array $product, array $entries, array $selected ): string { $html = ''; foreach ( $registry->tree( $entries ) as $node ) { $html .= self::node( $product, $node, $selected ); } return $html; } /** * One node of a block's tree: the entry (item()) and, nested, its children. * * @param array $product Product. * @param array $node Node. * @param string[] $selected Audience choice. */ private static function node( array $product, array $node, array $selected ): string { $sub = ''; foreach ( $node['children'] as $child ) { $sub .= self::node( $product, $child, $selected ); } return self::item( $product, $node['entry'], $selected, '' !== $sub ? '
    ' . $sub . '
' : '', $node['foreign'] ); } /** * One entry of a block: its icon, link, badge, pin button and description, and its children nested. * * @param array $product Product. * @param array $entry Entry. * @param string[] $selected Audience choice. * @param string $sub The children's list (escaped HTML), '' for none. * @param bool $foreign Whether the entry only heads children shown away from its section. */ public static function item( array $product, array $entry, array $selected, string $sub = '', bool $foreign = false ): string { $html = '
  • ' . Icons::entry( $entry, $product, 18 ) . '' . esc_html( (string) $entry['title'] ) . '' . Chrome::badge( $entry ) . ( $foreign ? '' : Chrome::pin_button( $entry ) ) . '
    '; if ( ! $foreign && '' !== (string) $entry['description'] ) { $html .= '

    ' . esc_html( (string) $entry['description'] ) . '

    '; } return $html . $sub . '
  • '; } }