current_product(); if ( '' === $product ) { return; } Markup::out( self::html( $registry, $product ) ); } /** * The whole chrome of a product (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 ); if ( null === $product ) { return ''; } $visible = $registry->visible( $slug ); $selected = Personas::selected( get_current_user_id(), $product ); // Two siblings directly in #wpcontent: a sticky box sticks only within its parent, and #wpcontent is the page. return self::topbar( $registry, $product ) . '
' . self::menubar( $registry, $product, $visible, $selected ) . self::pins( $registry, $product, $visible ) . '
'; } /** * A pill's label in its own box (trimmed to the capitals and the baseline, chrome.css). * * @param string $escaped The label, escaped. */ private static function text( string $escaped ): string { return '' . $escaped . ''; } /** * The brand of the bar: the product's own lockup when it registered one (`wordmark`: light and dark files, the * name kept for screen readers; the dark image lazy, so only the shown one loads), else the logo and the words, * the product's word in the product's colour. * * @param array $product Product. */ public static function brandmark( array $product ): string { $light = (string) ( $product['wordmark']['light'] ?? '' ); if ( '' !== $light ) { $dark = (string) ( $product['wordmark']['dark'] ?? '' ); return '' . ( '' !== $dark ? '' : '' ) . '' . esc_html( (string) $product['name'] ) . ''; } $style = (array) ( $product['word'] ?? array() ); $extra = ( 'outline' === (string) ( $style['style'] ?? '' ) ? ' is-outline' : '' ) . ( array() !== (array) ( $style['gradient'] ?? array() ) ? ' is-gradient' : '' ); $html = ''; foreach ( (array) $product['words'] as $i => $word ) { $html .= ( $i > 0 ? ' ' : '' ) . '' . esc_html( (string) $word ) . ''; } return $html . ''; } /** * The theme toggle of the brand bar (0.24.0): one button that cycles light, dark and the device's scheme. The * choice is the user's preference (Prefs::THEME): the body class prints it (Library::body_class()), the script * applies the next one at once and saves it over the preferences route. */ public static function theme_button(): string { $theme = Prefs::theme( get_current_user_id() ); $labels = self::theme_labels(); return '
  • '; } /** * The toggle's labels, by the current theme: what it is, and what a click makes it. * * @return array */ public static function theme_labels(): array { return array( 'light' => __( 'Theme: light. Switch to dark', 'ai-eshop-optimizer' ), 'dark' => __( 'Theme: dark. Switch to the device’s', 'ai-eshop-optimizer' ), 'auto' => __( 'Theme: the device’s. Switch to light', 'ai-eshop-optimizer' ), ); } /** * A product's own switches in the menu bar's tools area (0.10.0), between the "For:" picker and Ask AI: the LMS's * "Designer" and language switches. The product answers with escaped markup within the chrome's allowed list * (Markup::allowed()) and handles it with a script of its own. * * @param string $product Product slug. */ private static function tools( string $product ): string { /** * Filters the product's own controls of the menu bar's tools area (escaped HTML; '' for none). * * @since 0.10.0 * * @param string $html The markup so far. * @param string $product The product. */ $html = apply_filters( 'rooster/admin/menubar_tools', '', $product ); return is_string( $html ) ? $html : ''; } /** * The CSS class of a section (its colour custom properties, Colour::css()). * * @param string $product Product slug. * @param string $section Section key. */ public static function sec_class( string $product, string $section ): string { return 'rooster-admin-sec-' . sanitize_html_class( $product ) . '--' . sanitize_html_class( $section ); } /** * Whether a URL leaves wp-admin (opened in a new tab). * * @param string $url URL. */ public static function is_external( string $url ): bool { return ! str_starts_with( $url, admin_url() ); } /** * The Ask AI URL for a product and section ('' hides the button): what the `rooster/admin/ai_chat_url` filter * answers (Rooster One: its Insights Chat). No provider, no button. * * @param string $product Product slug. * @param string $section Section key ('' on Home). */ public static function ai_chat_url( string $product, string $section = '' ): string { /** * Filters the URL of the "Ask AI" button of the chrome and the Home page of a product ('' = no button). A * provider (Rooster One) answers with its chat's URL; it may carry the context (product and section). * * @since 0.1.0 * * @param string $url '' (no provider). * @param string $product The product whose screen it is. * @param string $section The section of the current screen ('' on Home). */ $url = apply_filters( 'rooster/admin/ai_chat_url', '', $product, $section ); return is_string( $url ) ? esc_url_raw( $url ) : ''; } /** * The status rows of a product's tier popover: the product's own version row, then what the * `rooster/admin/status_rows` filter adds (the companion plugins, the account, the licence). Each row: id, name, * state (on | hold | off | error), detail, url (a "Fix" link) and fix (its label). * * @param array $product Product. * @return list */ public static function status_rows( array $product ): array { $rows = array(); if ( '' !== $product['version'] ) { $rows['core'] = array( 'name' => $product['name'], 'state' => 'on', /* translators: 1: version number, 2: the tier label ("Free", "Pro", "Hub edition"). */ 'detail' => '' !== $product['tier']['label'] ? sprintf( __( 'v%1$s · %2$s', 'ai-eshop-optimizer' ), $product['version'], $product['tier']['label'] ) : sprintf( /* translators: %s: version number. */ __( 'v%s', 'ai-eshop-optimizer' ), $product['version'] ), ); } /** * Filters the status rows of a product's tier popover (the brand bar's pill) and Home page. Each row: name, * state (on | hold | off | error), detail, and optionally url (a "Fix" link) and fix (its label). * * @since 0.1.0 * * @param array> $rows id => row. * @param string $product Product slug. */ $filtered = apply_filters( 'rooster/admin/status_rows', $rows, $product['slug'] ); $out = array(); foreach ( is_array( $filtered ) ? $filtered : $rows as $id => $row ) { if ( ! is_string( $id ) || ! is_array( $row ) || ! is_string( $row['name'] ?? null ) || '' === $row['name'] ) { continue; } $out[] = array( 'id' => sanitize_key( $id ), 'name' => $row['name'], 'state' => in_array( $row['state'] ?? '', array( 'on', 'hold', 'off', 'error' ), true ) ? (string) $row['state'] : 'off', 'detail' => is_string( $row['detail'] ?? null ) ? $row['detail'] : '', 'url' => is_string( $row['url'] ?? null ) ? esc_url_raw( $row['url'] ) : '', 'fix' => is_string( $row['fix'] ?? null ) && '' !== $row['fix'] ? $row['fix'] : __( 'Fix', 'ai-eshop-optimizer' ), ); } return $out; } /* ── Brand bar ────────────────────────────────────────────────────── */ /** * The brand bar. * * @param Registry $registry The registry. * @param array $product Product. */ private static function topbar( Registry $registry, array $product ): string { $links = $product['links']; $home = $registry->home_url( $product['slug'] ); $html = '
    '; $html .= '' . self::brandmark( $product ) . ''; $html .= '
      '; $html .= self::site_pill( $product ); // The standard set (0.20.0), in the one order on every product: survey (a provider's), feedback (the // library's dialog when a sender takes the product's feedback, else the product's link), chat, manual, // welcome (a provider's, else the library's), help; then the library's own guidance switch (0.61.0, FR19: the // person's, beside light and dark) and theme toggle (0.24.0). A provider's service replaces the library's item of // its key. $services = self::services( $product['slug'] ); $link = static function ( string $key, string $icon, string $label ) use ( $links ): string { /* translators: %s: the link's name (Manual, Help…). */ return '' === $links[ $key ] ? '' : '
    • ' . Icons::ui( $icon ) . '
    • '; }; $items = array( 'survey' => $services['survey'] ?? '', 'feedback' => $services['feedback'] ?? ( Feedback::applies( $product ) ? Feedback::button() : $link( 'feedback', 'bulb', __( 'Send feedback or an idea', 'ai-eshop-optimizer' ) ) ), 'chat' => $services['chat'] ?? $link( 'chat', 'chat', self::chat_label( $links['chat'] ) ), 'manual' => $link( 'manual', 'book', __( 'Read the manual', 'ai-eshop-optimizer' ) ), 'welcome' => $services['welcome'] ?? ( Welcome::applies( $product ) ? Welcome::button() : '' ), 'help' => $link( 'help', 'help', __( 'Help and support', 'ai-eshop-optimizer' ) ), 'guidance' => Guidance::button(), 'theme' => self::theme_button(), ); $items = array_filter( $items, static fn( string $item ): bool => '' !== $item ); /** * Filters the brand bar's items after the Site pill (0.10.0): key => `
    • ` * markup, the standard set as the product's links, the senders and the providers gave it (0.20.0: survey, * feedback, chat, manual, welcome, help). A product replaces one with its own control or adds one; escaped * markup within the chrome's allowed list. Icon controls only (0.13.2): an item whose control is not a `.rooster-admin-iconlink` is * dropped with a _doing_it_wrong() notice. The bar is the product's handful of icon actions; a product's * state (what kind of site this is, a mode, a connection) is a status row (`rooster/admin/status_rows`), * shown in the tier popover and the Home status card, where it has a name, a state and a line of detail. * * @since 0.10.0 * * @param array $items The items. * @param string $product The product. */ $items = apply_filters( 'rooster/admin/topbar_items', $items, $product['slug'] ); foreach ( is_array( $items ) ? $items : array() as $key => $item ) { if ( ! is_string( $item ) || '' === $item ) { continue; } if ( ! str_contains( $item, 'rooster-admin-iconlink' ) ) { _doing_it_wrong( 'rooster/admin/topbar_items', sprintf( /* translators: 1: the item's key, 2: the product slug. */ esc_html__( 'The brand bar takes icon controls only (an li.rooster-admin-topbar__item around a .rooster-admin-iconlink); the item "%1$s" of %2$s is not one and was dropped. A product\'s state belongs in a status row (rooster/admin/status_rows).', 'ai-eshop-optimizer' ), esc_html( (string) $key ), esc_html( $product['slug'] ) ), '0.13.2' ); continue; } $html .= $item; } if ( '' !== $product['tier']['label'] ) { $html .= '
    • ' . '' . self::tier_panel( $product ) . '
    • '; } // The version is the product's name's tip (0.56.2, the owner: it took the bar's room); the tier pill's panel // carries it too. A product without a link keeps the chip: there is no name to hover. /* translators: %s: version number. */ $version = '' !== $product['version'] ? sprintf( _x( 'v%s', 'product version in the brand bar', 'ai-eshop-optimizer' ), $product['version'] ) : ''; if ( '' !== $links['product'] ) { $html .= '
    • ' . esc_html( $product['name'] ) . Icons::ui( 'external', 15 ) . ' ' . ( '' !== $version ? esc_html( $version ) . ' ' : '' ) . esc_html__( '(opens in a new tab)', 'ai-eshop-optimizer' ) . '
    • '; } elseif ( '' !== $version ) { $html .= '
    • ' . esc_html( $version ) . '
    • '; } $html .= self::ecosystem( $registry, $product['slug'] ); return $html . '
    '; } /** * The label of the chat item, by the host of the community chat's link (Viber, Telegram, Discord, Slack, * WhatsApp), else the plain one. * * @param string $url The chat link. */ public static function chat_label( string $url ): string { $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); foreach ( array( 'viber' => 'Viber', 'telegram' => 'Telegram', 't.me' => 'Telegram', 'discord' => 'Discord', 'slack' => 'Slack', 'whatsapp' => 'WhatsApp' ) as $needle => $name ) { if ( str_contains( $host, $needle ) ) { /* translators: %s: the chat service (Viber, Telegram…). */ return sprintf( __( 'Talk to us on %s', 'ai-eshop-optimizer' ), $name ); } } return __( 'Talk to us', 'ai-eshop-optimizer' ); } /** * A provider's services for a product's brand bar (0.20.0): `survey`, `feedback`, `welcome` or `chat`, each a * `
  • ` around a `.rooster-admin-iconlink`, validated like the bar's items * (Rooster One: its survey and welcome forms on its own screens). * * @param string $product Product slug. * @return array */ public static function services( string $product ): array { /** * Filters the services a provider adds to a product's brand bar (0.20.0): key (`survey`, `feedback`, * `welcome`, `chat`) => `
  • ` markup around a `.rooster-admin-iconlink` * (an `` or a `' . $panel . '
  • '; } /** * Status rows as a definition list (the popover and the Home page). * * @param list> $rows Rows (status_rows()). */ public static function status_list( array $rows ): string { if ( array() === $rows ) { return ''; } $html = '
    '; foreach ( $rows as $row ) { $html .= '
    ' . esc_html( $row['name'] ) . '
    ' . esc_html( $row['detail'] ) . ''; if ( 'on' !== $row['state'] && '' !== $row['url'] ) { /* translators: 1: the action, e.g. "Fix" or "Activate", 2: the row's name. */ $label = sprintf( __( '%1$s: %2$s', 'ai-eshop-optimizer' ), $row['fix'], $row['name'] ); $html .= ' ' . esc_html( $row['fix'] ) . ' '; } $html .= '
    '; } return $html . '
    '; } /* ── Menu bar ─────────────────────────────────────────────────────── */ /** * The menu bar: Home, the sections with their dropdowns, the audience picker, Ask AI and search. * * @param Registry $registry The registry. * @param array $product Product. * @param array> $visible The product's visible entries. * @param string[] $selected Audience choice. */ private static function menubar( Registry $registry, array $product, array $visible, array $selected ): string { $slug = $product['slug']; $here = $registry->current_id( $visible ); $section = $registry->current_section( $slug, $visible ); $on_home = Registry::current_page() === $product['home']['slug']; $html = ''; } /** * One link of a dropdown: the entry's icon, its title and its badge. * * @param array $product Product. * @param array $entry Entry. * @param bool $is_current Whether it is the current screen. * @param bool $foreign Whether it heads children shown away from its own section (muted). */ private static function item( array $product, array $entry, bool $is_current, bool $foreign = false ): string { return '' . Icons::entry( $entry, $product, 16 ) . '' . esc_html( (string) $entry['title'] ) . '' . self::badge( $entry ) . ''; } /** * A dropdown's entries as a tree: a parent's children nest under it in a sub-list, folded behind a count toggle * (chrome.js opens it; open from the start when the current screen is the parent or one of its children), so a * page with twenty cards is one row until asked. A parent shown away from its own section (Registry::tree()'s * `foreign`) heads its children in a muted row. * * @param Registry $registry The registry. * @param array $product Product. * @param list> $entries Entries. * @param string[] $choice The "For:" choice the entries are shown for. * @param string $here The current entry's id. * @param string $prefix The DOM id prefix of the sub-lists. */ private static function list( Registry $registry, array $product, array $entries, array $choice, string $here, string $prefix ): string { $html = ''; foreach ( $registry->tree( $entries ) as $i => $node ) { $html .= self::node( $product, $node, $choice, $here, $prefix . '-' . $i ); } return $html; } /** * One node of a dropdown's tree (item()). * * @param array $product Product. * @param array $node Node (Registry::tree()). * @param string[] $choice The "For:" choice. * @param string $here The current entry's id. * @param string $id The DOM id of this node's sub-list. */ private static function node( array $product, array $node, array $choice, string $here, string $id ): string { $entry = $node['entry']; $attrs = $node['foreign'] ? '' : self::audience_attrs( $product, $entry, $choice ); if ( array() === $node['children'] ) { return '' . self::item( $product, $entry, $entry['id'] === $here ) . ''; } $sub = ''; $open = $entry['id'] === $here; $n = 0; foreach ( $node['children'] as $j => $child ) { $sub .= self::node( $product, $child, $choice, $here, $id . '-' . $j ); $open = $open || self::holds( $child, $here ); $n += self::size( $child ); } /* translators: %s: how many destinations a menu item holds. */ $label = sprintf( _n( '%s item', '%s items', $n, 'ai-eshop-optimizer' ), number_format_i18n( $n ) ); return '
  • ' . self::item( $product, $entry, $entry['id'] === $here, $node['foreign'] ) . '
    ' . '
      ' . $sub . '
  • '; } /** * Whether a node or one of its descendants is the entry. * * @param array $node Node. * @param string $id Entry id. */ private static function holds( array $node, string $id ): bool { if ( $node['entry']['id'] === $id ) { return true; } foreach ( $node['children'] as $child ) { if ( self::holds( $child, $id ) ) { return true; } } return false; } /** * How many entries a node holds, itself included. * * @param array $node Node. */ private static function size( array $node ): int { $n = 1; foreach ( $node['children'] as $child ) { $n += self::size( $child ); } return $n; } /** * A grouped dropdown: the section's entries in one stack per column of the section's `stacks`, each stack holding * its groups with the usual items; a group without an entry for the "For:" choice and a stack without a group * shown are hidden. Wide (`is-wide is-cols-N`, N = the stacks shown, at most MAX_COLS) when more than LIST_MAX entries * show in at least two stacks; one stacked list with the group headings otherwise. * * @param Registry $registry The registry. * @param array $product Product. * @param string $key Section key. * @param list> $entries The section's visible entries. * @param string[] $choice The "For:" choice the entries are shown for. * @param string $here The current entry's id ('' when none is). * @param string $foot The foot link's HTML ('' for none). */ private static function grouped_panel( Registry $registry, array $product, string $key, array $entries, array $choice, string $here, string $foot ): string { $names = $product['sections'][ $key ]['groups']; $shown = count( array_filter( $entries, static fn( array $e ): bool => Personas::matches( $product, (array) $e['audiences'], $choice ) ) ); $cols = ''; $used = 0; foreach ( $registry->stacks( $product['slug'], $key, $entries ) as $stack ) { $groups = ''; $filled = false; foreach ( $stack as $group => $members ) { if ( array() === $members ) { continue; } $any = false; foreach ( $members as $entry ) { $any = $any || Personas::matches( $product, (array) $entry['audiences'], $choice ); } $head = 'rooster-admin-menu-' . $key . '-' . $group; $items = self::list( $registry, $product, $members, $choice, $here, $head ); $filled = $filled || $any; $groups .= '

    ' . esc_html( $names[ $group ] ?? $group ) . '

      ' . $items . '
    '; } if ( '' === $groups ) { continue; } $used += $filled ? 1 : 0; $cols .= '
    ' . $groups . '
    '; } $wide = $shown > self::LIST_MAX && $used > 1; $panel = $wide ? ' is-wide is-cols-' . min( self::MAX_COLS, $used ) : ''; return '
    ' . '
    ' . $cols . '
    ' . $foot . '
    '; } /** * The "Everything else (N)" pill: the sections without a function for the chosen roles, folded into one dropdown. * * @param array $product Product. * @param string[] $folded Folded section keys. * @param array>> $by Entries by section. * @param string $here The current entry's id. */ private static function folded( array $product, array $folded, array $by, string $here ): string { if ( array() === $folded ) { return ''; } $count = 0; $lists = ''; foreach ( $folded as $key ) { $def = $product['sections'][ $key ]; $count += count( $by[ $key ] ); $lists .= '

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

      ' . self::list( Library::registry(), $product, $by[ $key ], array(), $here, 'rooster-admin-menu-rest-' . $key ) . '
    '; } /* translators: %s: number of functions. */ $label = sprintf( __( 'Everything else (%s)', 'ai-eshop-optimizer' ), number_format_i18n( $count ) ); return '
    ' . '' . '
    ' . $lists . '
    '; } /** * The audience picker ("For: everyone"): a checkbox per persona with its dot, "Everything (n)" and Apply. '' for * a product without audiences. * * @param array $product Product. * @param array> $visible Visible entries. * @param string[] $selected Audience choice. */ private static function lens( array $product, array $visible, array $selected ): string { $personas = $product['audiences']; if ( array() === $personas ) { return ''; } $names = array(); $dots = ''; foreach ( $selected as $key ) { $names[] = $personas[ $key ]['label']; $dots .= ''; } /* translators: %s: comma-separated persona names. */ $text = array() === $names ? __( 'For: everyone', 'ai-eshop-optimizer' ) : sprintf( __( 'For: %s', 'ai-eshop-optimizer' ), implode( _x( ', ', 'between persona names', 'ai-eshop-optimizer' ), $names ) ); $html = '
    ' . '' . '
    ' . esc_html__( 'Showing for', 'ai-eshop-optimizer' ) . ''; foreach ( $personas as $key => $persona ) { $desc = 'rooster-admin-lens-desc-' . sanitize_key( (string) $key ); $html .= ''; } $html .= '
    ' /* translators: %s: number of functions. */ . '' . '' . '
    '; return $html; } /** * The CSS class of a persona's dot. * * @param string $product Product slug. * @param string $key Persona key. */ public static function persona_class( string $product, string $key ): string { return 'rooster-admin-persona-' . sanitize_html_class( $product ) . '--' . sanitize_html_class( $key ); } /* ── Pins ─────────────────────────────────────────────────────────── */ /** * The pinned destinations under the menu bar (rendered empty too: the pin buttons fill it in place). * * @param Registry $registry The registry. * @param array $product Product. * @param array> $visible Visible entries. */ private static function pins( Registry $registry, array $product, array $visible ): string { $html = ''; foreach ( self::pinned( $product['slug'], $visible ) as $entry ) { $html .= self::pin_chip( $product, $entry ); } return '
    ' . $html . '
    '; } /** * The current user's pinned entries of a product that are still visible, pin order. * * @param string $product Product slug. * @param array> $visible Visible entries. * @return list> */ public static function pinned( string $product, array $visible ): array { $out = array(); foreach ( (array) Prefs::get( get_current_user_id(), Prefs::PINS, $product, array() ) as $id ) { if ( is_string( $id ) && isset( $visible[ $id ] ) ) { $out[] = $visible[ $id ]; } } return $out; } /** * Whether an entry is pinned by the current user. * * @param array $entry Entry. */ public static function is_pinned( array $entry ): bool { return in_array( $entry['id'], (array) Prefs::get( get_current_user_id(), Prefs::PINS, (string) $entry['product'], array() ), true ); } /** * One pinned chip (chrome.js draws the same markup): a page's title, a card's, tab's or setting's ": ". * * @param array<string, mixed> $product Product. * @param array<string, mixed> $entry Entry. */ public static function pin_chip( array $product, array $entry ): string { $text = self::chip_label( $entry ); /* translators: %s: the pinned item's title. */ $unpin = sprintf( __( 'Unpin "%s"', 'ai-eshop-optimizer' ), $text ); return '<span data-kind="' . esc_attr( self::kind_code( $entry ) ) . '" class="rooster-admin-pin-chip ' . esc_attr( self::sec_class( $product['slug'], (string) $entry['section'] ) ) . '" role="listitem" data-id="' . esc_attr( (string) $entry['id'] ) . '"><a href="' . esc_url( (string) $entry['url'] ) . '">' . Icons::entry( $entry, $product, 14 ) . '<span class="rooster-admin-pin-chip__text">' . esc_html( $text ) . '</span></a><button type="button" class="rooster-admin-pin-chip__x" data-ra-unpin="' . esc_attr( (string) $entry['id'] ) . '" aria-label="' . esc_attr( $unpin ) . '">' . Icons::ui( 'close', 12 ) . '</button></span>'; } /** * The label of an entry's pinned chip: the title of a page, "<Page>: <Title>" of a tab, card or setting. * * @param array<string, mixed> $entry Entry. */ public static function chip_label( array $entry ): string { $title = (string) $entry['title']; $page = (string) ( $entry['page'] ?? '' ); if ( Registry::PAGE === $entry['kind'] || '' === $page ) { return $title; } /* translators: A pinned card, tab or setting of the menu bar. 1: the page's title, 2: the item's title. */ return sprintf( _x( '%1$s: %2$s', 'pinned card, tab or setting', 'ai-eshop-optimizer' ), $page, $title ); } /** * The short kind of an entry for the scripts and the chips' data-kind: p, c, t or s. * * @param array<string, mixed> $entry Entry. */ public static function kind_code( array $entry ): string { return match ( (string) $entry['kind'] ) { Registry::CARD => 'c', Registry::TAB => 't', Registry::SETTING => 's', default => 'p', }; } /** * THE pin button of an entry (the one pattern of the ecosystem): on Home, on a settings card, on a product's own * page, card or tab. '' for an entry that is not pinnable or not available. chrome.js toggles it, draws the chip * and saves the pins; a product that prints the button itself calls rooster_admin_pin_button( $id ). * * @param array<string, mixed> $entry Entry. */ public static function pin_button( array $entry ): string { if ( ! $entry['available'] || ! $entry['pin'] ) { return ''; } $pinned = self::is_pinned( $entry ); $title = (string) $entry['title']; /* translators: %s: item title. */ $pin = sprintf( __( 'Pin "%s" to the menu bar', 'ai-eshop-optimizer' ), $title ); /* translators: %s: item title. */ $unpin = sprintf( __( 'Unpin "%s" from the menu bar', 'ai-eshop-optimizer' ), $title ); return '<button type="button" class="rooster-admin-pinbtn is-tip-end' . ( $pinned ? ' is-on' : '' ) . '" data-ra-pin="' . esc_attr( (string) $entry['id'] ) . '" aria-pressed="' . ( $pinned ? 'true' : 'false' ) . '" aria-label="' . esc_attr( $pinned ? $unpin : $pin ) . '" data-label-pin="' . esc_attr( $pin ) . '" data-label-unpin="' . esc_attr( $unpin ) . '" data-ra-tip="' . esc_attr( $pinned ? $unpin : $pin ) . '">' . Icons::ui( 'pin', 15 ) . '</button>'; } /** * The words of the board's moves (assets/board.js, 0.58.0): where a moved card or row went, the Move menu. In the * chrome's script data; 0.65.1: a table whose rows move on a screen without the chrome carries them itself. * * @return array<string, string> */ public static function board_words(): array { return Board::words(); // 0.75.0: the board's own, printed on the storefront too } /** * A dismissible card's key (0.64.3): `card/<dom id>` in the person's `hints` (one list, not per product), or '' * when the dom id cannot be one (lowercase letters, digits, dashes and underscores, up to 55). * * @param string $dom The card's dom id. */ public static function card_key( string $dom ): string { return 1 === preg_match( '/^[a-z0-9][a-z0-9_-]{0,54}$/', $dom ) ? 'card/' . $dom : ''; } /** * Whether the current user dismissed a card (its key, card_key()): the card prints hidden, so the first paint * never shows it. * * @param string $key The card's key. */ public static function card_dismissed( string $key ): bool { return '' !== $key && in_array( $key, (array) Prefs::get( get_current_user_id(), Prefs::HINTS, null, array() ), true ); } /** * THE dismiss button of a card (0.64.3; rooster_admin_card_open_html()'s `dismissible`): at the end of its head, * before the pin (the pin stays last, H17). * chrome.js hides the card, puts a line with Undo in its place and saves the key in the person's `hints`; Reset * layout brings the page's hidden cards back. * * @param string $title The card's title (raw). */ public static function dismiss_button( string $title ): string { /* translators: %s: the card's title. */ $label = sprintf( __( 'Hide the card "%s"', 'ai-eshop-optimizer' ), $title ); return '<button type="button" class="rooster-admin-card__dismiss is-tip-end" data-ra-card-dismiss="" aria-label="' . esc_attr( $label ) . '" data-ra-tip="' . esc_attr__( 'Hide this card', 'ai-eshop-optimizer' ) . '">' . Icons::ui( 'close', 16 ) . '</button>'; } /** * The audience attributes of an entry's list item: data-audiences, and hidden when the choice hides it. * * @param array<string, mixed> $product Product. * @param array<string, mixed> $entry Entry. * @param string[] $selected Audience choice. */ public static function audience_attrs( array $product, array $entry, array $selected ): string { return ' data-audiences="' . esc_attr( implode( ' ', (array) $entry['audiences'] ) ) . '"' . ( Personas::matches( $product, (array) $entry['audiences'], $selected ) ? '' : ' hidden' ); } /** * The badge of an entry whose product or edition is not active on this site ('' for an available entry). * * @param array<string, mixed> $entry Entry. */ public static function badge( array $entry ): string { if ( $entry['available'] || '' === $entry['badge'] ) { return ''; } return '<span class="rooster-admin-tier is-tip-start" data-ra-tip="' . esc_attr__( 'Not active on this site', 'ai-eshop-optimizer' ) . '">' . esc_html( $entry['badge'] ) . '</span>'; } /* ── The palette and the script data ──────────────────────────────── */ /** * Hook admin_footer: the Ctrl+K palette on the chrome's screens (a modal dialog filled by chrome.js). */ public static function palette(): void { $registry = Library::registry(); if ( null === $registry || '' === $registry->current_product() ) { return; } Markup::out( self::palette_html( (string) $registry->product( $registry->current_product() )['name'] ) ); } /** * Hook admin_footer: on the chrome's screens the browser prefetches a link to another screen of the current * product while the pointer rests on it (Speculation Rules, "moderate" eagerness: one request per hovered link, * no prerender), so a pill's destination, a pinned chip or a palette result opens from the cache. The product's * own admin pages only (the paths and page slugs of its catalogue), never a link with an action or a nonce (a * write), never a row action. Browsers without the API ignore the script. */ public static function speculation_rules(): void { $registry = Library::registry(); if ( null === $registry ) { return; } $product = $registry->current_product(); if ( '' === $product ) { return; } $rules = self::speculation_rules_of( $registry, $product ); if ( null !== $rules ) { wp_print_inline_script_tag( (string) wp_json_encode( $rules ), array( 'type' => 'speculationrules' ) ); } } /** * The links the hover prefetch leaves alone: WordPress's row actions, links inside forms, anything marked * `data-ra-no-prefetch`, and what the product adds (its own row actions, a link that acts on GET). * * @param string $product Product slug. */ public static function prefetch_exclude( string $product ): string { $selector = '.row-actions a, .rooster-admin-table__actions a, form a, [data-ra-no-prefetch], [data-ra-no-prefetch] a'; /** * Filters the CSS selector of the links the hover prefetch of the chrome's screens leaves alone. * * @since 0.3.1 * * @param string $selector The selector. * @param string $product The product whose screens these are. */ $filtered = apply_filters( 'rooster/admin/prefetch_exclude', $selector, $product ); return is_string( $filtered ) && '' !== trim( $filtered ) ? $filtered : $selector; } /** * The speculation rules of a product: one `href_matches` pattern per admin page of its catalogue (null without any). * * @param Registry $registry The registry. * @param string $product Product slug. * @return array<string, mixed>|null */ public static function speculation_rules_of( Registry $registry, string $product ): ?array { $admin = (string) wp_parse_url( admin_url(), PHP_URL_PATH ); $patterns = array(); foreach ( $registry->visible( $product, array( Registry::PAGE, Registry::TAB ) ) as $entry ) { if ( ! $entry['available'] || '' === $entry['path'] || self::is_external( (string) $entry['url'] ) ) { continue; } $key = (string) ( $entry['args']['page'] ?? ( $entry['args']['post_type'] ?? '' ) ); $arg = isset( $entry['args']['page'] ) ? 'page' : ( isset( $entry['args']['post_type'] ) ? 'post_type' : '' ); $id = $entry['path'] . '|' . $arg . '|' . $key; if ( isset( $patterns[ $id ] ) || 1 !== preg_match( '/^[a-z0-9_-]*$/', $key ) ) { continue; } $pattern = array( 'pathname' => $admin . $entry['path'] ); if ( '' !== $arg ) { $pattern['search'] = '*' . $arg . '=' . $key . '*'; } $patterns[ $id ] = $pattern; } if ( array() === $patterns ) { return null; } return array( 'prefetch' => array( array( 'source' => 'document', 'where' => array( 'and' => array( array( 'href_matches' => array_values( $patterns ) ), array( 'not' => array( 'href_matches' => array( array( 'search' => '*action=*' ), array( 'search' => '*_wpnonce=*' ), ), ), ), array( 'not' => array( 'selector_matches' => self::prefetch_exclude( $product ) ) ), ), ), 'eagerness' => 'moderate', ), ), ); } /** * The palette's dialog (escaped here; filled by chrome.js). * * @param string $name The product's name. */ public static function palette_html( string $name ): string { /* translators: %s: the product's name. */ $label = sprintf( __( 'Search %s', 'ai-eshop-optimizer' ), $name ); return '<dialog class="rooster-admin-palette rooster-admin" id="rooster-admin-palette" aria-labelledby="rooster-admin-palette-label"><div class="rooster-admin-palette__box">' . '<div class="rooster-admin-palette__head">' . Icons::ui( 'search', 18 ) . '<label class="screen-reader-text" id="rooster-admin-palette-label" for="rooster-admin-palette-input">' . esc_html( $label ) . '</label>' . '<input type="text" id="rooster-admin-palette-input" class="rooster-admin-palette__input" role="combobox" aria-expanded="true" aria-controls="rooster-admin-palette-list" aria-autocomplete="list" autocomplete="off" spellcheck="false" placeholder="' . esc_attr__( 'Search pages, tabs, cards and settings…', 'ai-eshop-optimizer' ) . '" />' . '<kbd>' . esc_html_x( 'Esc', 'keyboard key', 'ai-eshop-optimizer' ) . '</kbd></div>' . '<ul class="rooster-admin-palette__list" id="rooster-admin-palette-list" role="listbox" aria-label="' . esc_attr__( 'Results', 'ai-eshop-optimizer' ) . '"></ul>' . '<p class="rooster-admin-palette__empty" hidden>' . esc_html__( 'No match. Try another word.', 'ai-eshop-optimizer' ) . '</p>' . '<p class="rooster-admin-palette__foot"><span><kbd>↑</kbd><kbd>↓</kbd> ' . esc_html__( 'to move', 'ai-eshop-optimizer' ) . '</span><span><kbd>' . esc_html_x( 'Enter', 'keyboard key', 'ai-eshop-optimizer' ) . '</kbd> ' . esc_html__( 'to open', 'ai-eshop-optimizer' ) . '</span><span><kbd>' . esc_html_x( 'Esc', 'keyboard key', 'ai-eshop-optimizer' ) . '</kbd> ' . esc_html__( 'to close', 'ai-eshop-optimizer' ) . '</span></p>' . '<p class="screen-reader-text" aria-live="polite" id="rooster-admin-palette-status"></p></div></dialog>'; } /** * The script's data: the admin-ajax URL and nonce, the current product, every visible entry of the current * product (all kinds) and the visible pages of the other products for the palette, the section, group and product * labels, the personas, the pins and the strings. Every `u` is relative to wp-admin/ when it can be. * * @param Registry $registry The registry. * @return array<string, mixed> */ public static function script_data( Registry $registry ): array { $current = $registry->current_product(); $product = $registry->product( $current ) ?? array( 'audiences' => array(), 'slug' => '' ); $selected = Personas::selected( get_current_user_id(), $product ); $admin = admin_url(); $entries = array(); $sections = array(); $groups = array(); $names = array(); foreach ( $registry->products() as $slug => $p ) { $names[ $slug ] = $p['name']; foreach ( $p['sections'] as $key => $section ) { $sections[ $slug . '--' . $key ] = $section['label']; foreach ( $section['groups'] as $g => $label ) { $groups[ $slug . '--' . $key . '--' . $g ] = $label; } } } foreach ( $registry->visible( null ) as $entry ) { $own = $entry['product'] === $current; if ( ! $own && Registry::PAGE !== $entry['kind'] ) { continue; } $url = remove_query_arg( Request::VIEW_ARG, (string) $entry['url'] ); // assets/view.js sets the nonce on the link $row = array( 'id' => (string) $entry['id'], 't' => (string) $entry['title'], 'd' => (string) $entry['description'], 's' => (string) $entry['section'], 'pr' => (string) $entry['product'], 'u' => str_starts_with( $url, $admin ) ? substr( $url, strlen( $admin ) ) : $url, 'v' => $entry['available'] ? 1 : 0, 'k' => implode( ' ', (array) $entry['keywords'] ), 'i' => Icons::of_entry( $entry, $registry->product( $entry['product'] ) ), 'kd' => self::kind_code( $entry ), ); if ( ! $entry['available'] ) { $row['b'] = (string) $entry['badge']; } if ( ! $entry['pin'] ) { $row['np'] = 1; } if ( '' !== $entry['group'] ) { $row['g'] = (string) $entry['group']; } if ( $own && array() !== $selected && Personas::matches( $product, (array) $entry['audiences'], $selected ) ) { $row['m'] = 1; } if ( '' !== $entry['page'] ) { $row['c'] = (string) $entry['page']; } $entries[] = $row; } $personas = array(); $all = array(); foreach ( $product['audiences'] as $key => $persona ) { $personas[ $key ] = $persona['label']; if ( $persona['all'] ) { $all[] = $key; } } return array( 'rest' => esc_url_raw( rest_url( Rest::NAMESPACE . Rest::ROUTE ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'user' => get_current_user_id(), // 0.68.15: a save not yet taken is kept per person in the tab (prefs.pending()) 'feedback' => array( // the feedback dialog (0.20.0): its route and the screen the user is on 'rest' => esc_url_raw( rest_url( Rest::NAMESPACE . Feedback::ROUTE ) ), 'screen' => Feedback::screen(), ), 'product' => $current, 'products' => $names, 'entries' => $entries, 'sections' => $sections, 'groups' => $groups, 'personas' => $personas, 'all' => $all, 'pins' => array_values( array_filter( (array) Prefs::get( get_current_user_id(), Prefs::PINS, $current, array() ), 'is_string' ) ), 'panels' => array_values( array_filter( (array) Prefs::get( get_current_user_id(), Prefs::PANELS, $current, array() ), 'is_string' ) ), // the docked side panels (0.46.0) 'hints' => array_values( array_filter( (array) Prefs::get( get_current_user_id(), Prefs::HINTS, null, array() ), 'is_string' ) ), // the cards the person hid (0.64.3) 'maxPins' => self::MAX_PINS, 'l10n' => array( 'everyone' => __( 'For: everyone', 'ai-eshop-optimizer' ), 'theme' => self::theme_labels(), 'tooShort' => __( 'Please add a short message first.', 'ai-eshop-optimizer' ), 'sendFailed' => __( 'Could not send. Please try again.', 'ai-eshop-optimizer' ), /* translators: %s: comma-separated persona names. */ 'for' => __( 'For: %s', 'ai-eshop-optimizer' ), 'sep' => _x( ', ', 'between persona names', 'ai-eshop-optimizer' ), /* translators: %s: the pinned item's title. */ 'unpin' => __( 'Unpin "%s"', 'ai-eshop-optimizer' ), 'failed' => __( 'That was not saved. Try again.', 'ai-eshop-optimizer' ), 'ui' => array( // the words the script twins draw (roosterAdmin.ui, 0.54.0, 0.55.0) 'dismiss' => __( 'Dismiss', 'ai-eshop-optimizer' ), 'close' => __( 'Close', 'ai-eshop-optimizer' ), 'cancel' => __( 'Cancel', 'ai-eshop-optimizer' ), 'confirm' => __( 'Confirm', 'ai-eshop-optimizer' ), /* translators: %s: the word to type. */ 'typeToConfirm' => __( 'Type %s to confirm', 'ai-eshop-optimizer' ), 'newTab' => __( '(opens in a new tab)', 'ai-eshop-optimizer' ), // 0.64.3: a button to a new tab says so // The table's twin (0.74.0, assets/table-twin.js): a boolean cell, the empty row, a nameless region, a bar. 'yes' => __( 'Yes', 'ai-eshop-optimizer' ), 'no' => __( 'No', 'ai-eshop-optimizer' ), 'nothing' => __( 'Nothing here yet.', 'ai-eshop-optimizer' ), 'table' => __( 'Table', 'ai-eshop-optimizer' ), 'progress' => __( 'Progress', 'ai-eshop-optimizer' ), 'sortBy' => __( 'Sort by', 'ai-eshop-optimizer' ), // 0.74.1: a list as cards, the heads' row of sort links 'actions' => __( 'Actions', 'ai-eshop-optimizer' ), // 0.80.2: a nameless actions column's head ), 'tooMany' => __( 'The menu bar holds 32 pins. Unpin one first.', 'ai-eshop-optimizer' ), 'card' => array( // a dismissible card (0.64.3): the line left in its place /* translators: %s: the card's title. */ 'hidden' => __( '"%s" is hidden.', 'ai-eshop-optimizer' ), /* translators: %s: the card's title. */ 'back' => __( '"%s" is hidden. Reset layout brings it back.', 'ai-eshop-optimizer' ), 'undo' => __( 'Undo', 'ai-eshop-optimizer' ), /* translators: %s: the card's title. */ 'shown' => __( '"%s" is shown again.', 'ai-eshop-optimizer' ), ), 'select' => array( // the searchable select (assets/select.js, 0.36.0) 'search' => __( 'Search…', 'ai-eshop-optimizer' ), 'choose' => __( 'Choose…', 'ai-eshop-optimizer' ), 'noResults' => __( 'No results', 'ai-eshop-optimizer' ), 'searching' => __( 'Searching…', 'ai-eshop-optimizer' ), /* translators: %d: a number of characters. */ 'typeMore' => __( 'Type at least %d characters to search', 'ai-eshop-optimizer' ), /* translators: %d: a number of options. */ 'more' => __( '%d more: type to narrow the list', 'ai-eshop-optimizer' ), /* translators: %s: the chosen option's label. */ 'remove' => __( 'Remove %s', 'ai-eshop-optimizer' ), 'clear' => __( 'Clear', 'ai-eshop-optimizer' ), /* translators: %d: a number of options. */ 'selectAll' => __( 'Select all shown (%d)', 'ai-eshop-optimizer' ), /* translators: %d: a number of options. */ 'selected' => __( '%d selected', 'ai-eshop-optimizer' ), 'separator' => _x( ', ', 'between chosen labels', 'ai-eshop-optimizer' ), 'error' => __( 'The search failed. Try again.', 'ai-eshop-optimizer' ), ), /* translators: %d: number of results. */ 'results' => __( '%d results', 'ai-eshop-optimizer' ), 'noResult' => __( 'No match. Try another word.', 'ai-eshop-optimizer' ), 'board' => self::board_words(), // the page board's moves (assets/board.js, 0.58.0): where a moved card went, the Move menu /* translators: A pinned card, tab or setting of the menu bar. 1: the page's title, 2: the item's title. */ 'chip' => _x( '%1$s: %2$s', 'pinned card, tab or setting', 'ai-eshop-optimizer' ), /* translators: A card, tab or setting in the search results. 1: the page's title, 2: the item's title. */ 'crumb' => _x( '%1$s › %2$s', 'search result of a card, tab or setting', 'ai-eshop-optimizer' ), /* translators: 1: a product's name, 2: a section's name (a palette result of another product). */ 'inOther' => _x( '%1$s · %2$s', 'palette result of another product', 'ai-eshop-optimizer' ), ), ); } }