shapes, the shape of data/icons.php), which the build tool can also bundle. * An entry that names no icon, or would only repeat its page's, gets one from its own words (guess(), RULES; the * registry applies it when it seals), so the tabs and cards of a page do not all wear the page's icon. * * @package RoosterAdmin */ namespace Rooster\Admin; defined( 'ABSPATH' ) || exit; /** * The map of the chrome's own symbols and the markup (escaped here). */ final class Icons { /** * The chrome's own symbols: name => Lucide name. */ // @icons-begin (tools/build-icons.py bundles every Lucide name quoted from here to the end marker) public const UI = array( 'book' => 'book-marked', 'help' => 'circle-question-mark', 'bulb' => 'lightbulb', 'external' => 'external-link', 'caret' => 'chevron-down', 'search' => 'search', 'sparkle' => 'sparkles', 'pin' => 'pin', 'close' => 'x', 'panel' => 'panel-right-open', // the side panel's toggle (0.46.0) // The side panel's layouts (0.67.0): docked at the start, at the end, floating; folding a floating one to its head. 'dock-start' => 'panel-left', 'dock-end' => 'panel-right', 'float' => 'picture-in-picture-2', 'collapse' => 'chevron-up', 'text-size' => 'a-large-small', // a table's text size (0.68.0) 'grip' => 'grip-vertical', 'reset' => 'rotate-ccw', 'grid' => 'layout-grid', 'chat' => 'message-square', 'survey' => 'clipboard-check', 'welcome' => 'rocket', 'star' => 'star', // The theme toggle (0.24.0): light, dark, the device's. 'theme-light' => 'sun', 'theme-dark' => 'moon', 'theme-auto' => 'sun-moon', // The kit (0.22.0): notices, empty states, stats, tables, the showcase. 'info' => 'info', 'success' => 'circle-check', 'warning' => 'triangle-alert', 'error' => 'circle-alert', 'inbox' => 'inbox', 'up' => 'trending-up', 'down' => 'trending-down', 'trash' => 'trash-2', 'edit' => 'pencil', 'filter' => 'list-filter', 'columns' => 'columns-3', 'more' => 'ellipsis', 'row-menu' => 'ellipsis-vertical', // 0.79.0: a row's menu (Ui::row_menu()) 'calendar' => 'calendar', 'user' => 'user', 'upload' => 'upload', 'play' => 'play', 'file' => 'file-text', 'award' => 'award', 'activity' => 'activity', 'ban' => 'ban', 'zap' => 'zap', 'shield' => 'shield-check', 'cart' => 'shopping-cart', 'package' => 'package', 'coins' => 'coins', 'percent' => 'percent', 'undo' => 'undo-2', 'table' => 'table', 'chart' => 'chart-column', 'graduate' => 'graduation-cap', 'rows' => 'rows-3', 'minus' => 'minus', 'arrowup' => 'arrow-up', 'arrowdown' => 'arrow-down', 'chevron' => 'chevron-right', 'check' => 'check', 'plus' => 'plus', 'home' => 'house', 'settings' => 'settings', 'rooster' => 'layout-dashboard', ); /** * The words that name an icon (0.12.0): rules in priority order, each a Lucide name and its terms, comma-separated. * A term is a word, a phrase (its words in order) or a stem (`categor*`: the start of a word of six letters or * more). guess() reads an entry's title first, then its description, and answers the icon of the earliest rule a * term of which occurs; a plural or an -ing/-ed form of a word counts as the word. Every name here is bundled * (tools/build-icons.py reads this block). * * The order matters: the specific before the general ("quiet hours" before "hours", "admin menu" before "menu", * "barcode" before "product"), the thing before the action done to it, and the catch-alls (settings, tools, list) * last. A product that names an icon on a row is never second-guessed; the rules serve the rows that name none. * * @var list */ public const RULES = array( // Questions, help, documentation. array( 'circle-question-mark', 'what is it for, faq, faqs, help, why, how to, how it works, explained, about' ), array( 'book-open-check', 'documentation, docs, manual, handbook, knowledge base, coverage, readme, user guide' ), // Codes and identifiers. array( 'qr-code', 'qr, qr code, qr codes' ), array( 'barcode', 'barcode, barcodes, ean, gtin, upc, isbn' ), array( 'scan-barcode', 'scanner, scan, scanning' ), array( 'hash', 'sku, skus, identifier, identifiers, serial, reference number, id lookup, ids, parent id' ), // Live sessions and media. array( 'webcam', 'zoom, webcam, meeting app, teams, google meet, webex' ), array( 'repeat', 'repeat, repeats, recurrence, recurring, series, weekly, monthly, daily, every week, every day' ), array( 'moon', 'quiet hours, night, dark mode, after hours, do not disturb' ), array( 'calendar-clock', 'date and time, dates and times, time zone, time zones, timezone, starts at, start time, end time' ), array( 'calendar-days', 'calendar, calendars, agenda' ), array( 'calendar-plus', 'new session, add session, booking, bookings, book a' ), array( 'video', 'video, videos, live lesson, live lessons, stream, streaming, recording, recordings, webinar, webinars, meeting, meetings' ), array( 'mic', 'audio, microphone, podcast, voice, sound' ), array( 'film', 'film, films, movie, movies, clip, clips' ), array( 'image', 'image, images, photo, photos, picture, pictures, thumbnail, thumbnails, gallery, galleries, media, logo, logos, icon, icons, aspect ratio' ), array( 'presentation', 'slide, slides, presentation, presentations, material, materials, handout, handouts' ), array( 'clock', 'time, times, hour, hours, duration, minute, minutes, clock, deadline, deadlines, due, late, early' ), // Mail, invitations, notifications. array( 'bell-off', 'mute, muted, silence, silenced, unsubscribe, unsubscribed, snooze' ), array( 'alarm-clock', 'reminder, reminders, remind, nudge, nudges' ), array( 'bell', 'notification, notifications, alert, alerts, notify, notifies, push' ), array( 'mails', 'all invitations, invitations, newsletter, newsletters, mailing, mailing list, bulk email, bulk emails, digest' ), array( 'send', 'invitation, invite, invites, invited, send, sending, dispatch, dispatched, outgoing' ), array( 'mail-check', 'confirmation, confirmations, confirm email, double opt in, verified email' ), array( 'mail', 'email, emails, e mail, e mails, message template, message templates, opt in, contact form, contact forms, letter, letters, sender' ), array( 'inbox', 'admission, admissions, application, applications, request, requests, inbox, incoming, pending' ), array( 'message-square', 'chat, chats, comment, comments, discussion, discussions, conversation, conversations, reply, replies, messages, answer, answers' ), // Places and links. array( 'link', 'join and host, join link, meeting link, link, links, url, urls, permalink, permalinks, slug, slugs, connected, connection, connections' ), array( 'map-pin', 'venue, venues, location, locations, address, addresses, in person, on site, pickup, pickups, map, maps, store pages, store page, branch, branches' ), array( 'globe', 'domain, domains, portal, website, websites, public site, front end, frontend, storefront, online, web' ), // People and participation. array( 'circle-check', 'completion, completions, complete, completed, pass mark, passed, done, finished, approve, approved, approval, accept, accepted' ), array( 'user-round-check', 'attendance, attend, attended, roster, rosters, present, check in, checked in' ), array( 'user-plus', 'enrol, enrolment, enrolments, enroll, enrollment, registration, registrations, register, sign up, add people, add users, bulk add, admit, admitted, onboard' ), array( 'user-x', 'unenrol, unenroll, withdraw, withdrawal, withdrawals, remove people, ban, banned, block, blocked, suspend, suspended' ), array( 'user-round-cog', 'unit role, unit roles, user role, user roles, member role, member roles, staff role, staff roles, permissions of' ), array( 'users', 'cohort, cohorts, member, members, participant, participants, people, attendee, attendees, learner, learners, student, students, customer, customers, user, users, audience, audiences, contact, contacts, persona, personas, team, teams, staff, who' ), array( 'id-card', 'person, persons, profile, profiles, identity, account, accounts, my account, me' ), array( 'building', 'organisation, organisations, organization, organizations, company, companies, supplier, suppliers, vendor, vendors, school, schools, institution, institutions, academy, client, clients, partner, partners, department, departments' ), array( 'briefcase', 'job role, job roles, position, positions, employer, employers, employee, employees, career, careers, hr, occupation, occupations' ), // Learning. array( 'graduation-cap', 'course, courses, curriculum, curricula, programme, programmes, program, programs' ), array( 'book-open', 'lesson, lessons, chapter, chapters, reading, readings, syllabus, textbook, textbooks, content, module, modules, topic, topics' ), array( 'list-checks', 'quiz, quizzes, checklist, checklists, wizard, wizards, getting set up, onboarding, to do, todo, steps, fundamentals' ), array( 'file-question-mark', 'question, questions, question bank, question banks' ), array( 'pencil-line', 'grading, grade, grades, graded, mark, marks, marking, edit, edits, editing, editor, rename, renames, rewrite, write, writing, compose' ), array( 'text-cursor-input', 'title and placement, title, titles, name, names, naming, label, labels, wording, heading, headings, caption, captions, placeholder, placeholders, text field, text fields' ), array( 'award', 'certificate, certificates, award, awards, diploma, diplomas, badge, badges, reward, rewards, loyalty, loyalty points, medal, medals, achievement, achievements, competition, competitions' ), array( 'star', 'rating, ratings, review, reviews, favourite, favourites, favorite, favorites, wishlist, wishlists, star, stars, featured, top rated, best' ), array( 'clipboard-list', 'attempt, attempts, survey, surveys, questionnaire, questionnaires, poll, polls, form, forms, feedback, results, responses, submission, submissions' ), array( 'scroll-text', 'template, templates, certificate template, terms, policy, policies, agreement, agreements, contract, contracts, transcript, transcripts' ), // Goods and their movement (before the reports about them: "shipping rates" is shipping). array( 'boxes', 'stock, stocks, inventory, warehouse, warehouses, reservation, reservations, reorder, reorders, supply, supplies, quantity, quantities, out of stock, stockout, stockouts' ), array( 'truck', 'shipping, courier, couriers, delivery, deliveries, shipment, shipments, parcel, parcels, fulfilment, fulfillment, carrier, carriers, postal, shipping rates, free shipping' ), // Numbers and reports. array( 'table', 'table, tables, per page, list table, list tables, wpdatatables, datatables, row, rows, column, columns, grid, grids, spreadsheet, spreadsheets, csv' ), array( 'chart-line', 'history, histories, over time, timeline, timelines, trajectory, progression, price history' ), array( 'trending-up', 'trend, trends, growth, progress, demand, need, needs, forecast, forecasts, popular, popularity, best seller, best sellers, momentum' ), array( 'chart-column', 'report, reports, lookup, lookups, analytics, statistic, statistics, insight, insights, metric, metrics, kpi, kpis, summary, summaries, rate, rates, ratio, ratios, pattern, patterns, dashboard, dashboards, count, counts, total, totals, average, averages, vitals, signal, signals, funnel, funnels, breakdown, breakdowns' ), array( 'target', 'goal, goals, target, targets, objective, objectives, conversion, conversions, attribution' ), // Shop. array( 'package', 'product, products, catalog, catalogue, item, items, bundle, bundles, package, packages, brand, brands, variation, variations, listing, listings' ), array( 'shield', 'guard, guards, security, 2fa, two factor, login, logins, log in, password, passwords, captcha, protect, protection, protected, privacy, gdpr, consent, consents, spam, firewall, safe, safety, verification, verify, verified, fraud, tighten, tightening' ), array( 'credit-card', 'payment, payments, checkout, card, cards, fee, fees, plan, plans, subscription, subscriptions, billing, gateway, gateways, paypal, stripe, cod, cash on delivery, instalment, instalments' ), array( 'receipt', 'invoice, invoices, receipt, receipts, vat, tax, taxes, ledger, ledgers, accounting, credit note, credit notes, statement of account' ), array( 'banknote', 'revenue, revenues, cost, costs, margin, margins, profit, profits, roi, price, prices, pricing, money, income, turnover, spend, spending, budget, budgets, earning, earnings, sales, omnibus, landed cost' ), array( 'tags', 'discount, discounts, offer, offers, coupon, coupons, promo, promos, promotion, promotions, voucher, vouchers, deal, deals, tag, tags, gift, gifts, sale, sales promotions' ), array( 'shopping-cart', 'order, orders, cart, carts, basket, baskets, abandoned, abandonment, purchase, purchases, checkout timing' ), array( 'wallet', 'credit, credits, balance, balances, wallet, wallets, seat, seats, allowance, allowances, top up, top ups, licence, licences, license, licenses, pro plan' ), // Data and integrations. array( 'import', 'import, imports, imported, upload, uploads, uploaded, ingest' ), array( 'download', 'export, exports, exported, download, downloads, feed, feeds, backup, backups' ), array( 'database', 'database, databases, db, sql, lrs, statement, statements, storage, record, records, data table, data tables' ), array( 'plug', 'integration, integrations, api, apis, webhook, webhooks, connector, connectors, erp, lti, plugin, plugins, add on, add ons, addon, addons, extension, extensions, third party, service, services, external, sync, syncs, synchronisation, synchronization' ), array( 'key', 'key, keys, credential, credentials, secret, secrets, token, tokens, oauth, api key, api keys, app id' ), array( 'search', 'seo, search, searches, sitemap, sitemaps, redirect, redirects, redirection, meta, schema, keyword, keywords, index, indexing, lookup table, query, queries, navigator, backlink, backlinks, opportunity, opportunities' ), // Look and layout. array( 'panel-left', 'admin menu, side menu, sidebar, side bar, menu, menus, navigation, nav' ), array( 'palette', 'appearance, colour, colours, color, colors, theme, themes, branding, swatch, swatches, style, styles, styling, look, design, designs, css, font, fonts, typography' ), array( 'layout-template', 'layout, layouts, display, header, headers, footer, footers, widget, widgets, popup, popups, banner, banners, placement, placements, slot, slots, position of, sticky, announcement, announcements, button, buttons, controls, tab names' ), array( 'languages', 'language, languages, translation, translations, translate, translated, wpml, i18n, locale, locales, multilingual, greek, english' ), array( 'file-pen-line', 'document, documents, pdf, pdfs, print, printing, printable, page, pages, note, notes, text, texts, description, descriptions' ), // Time, status, maintenance. array( 'timer', 'timing, timings, schedule, schedules, scheduled, scheduler, cron, job, jobs, queue, queues, batch, batches, timer, timers, countdown, interval, intervals, action scheduler, pipeline, pipelines' ), array( 'gauge', 'performance, speed, cache, caches, caching, optimisation, optimization, optimise, optimize, optimiser, optimizer, usage, quota, quotas, load, benchmark, benchmarks, throughput' ), array( 'activity', 'status, statuses, health, diagnostic, diagnostics, diagnose, monitor, monitoring, log, logs, logging, overlap, overlaps, engagement, activity, activities, audit, audits, trace, traces, debug, debugging, tracker, trackers, compatibility, heartbeat' ), array( 'refresh-cw', 'regrade, regrades, re evaluate, reevaluate, recalculate, recalculation, refresh, rebuild, rebuilds, migrate, migration, migrations, repair, repairs, backfill, reindex, regenerate, update, updates, dedupe, duplicates, merge, merges, cleanup, clean up, maintenance, prune' ), array( 'trash', 'delete, deletes, deleted, deletion, trash, trashed, remove, removal, purge, purges, erase, wipe, cancel, cancelled, canceled, cancellation' ), array( 'lock', 'lock, locks, locked, restrict, restricted, restriction, restrictions, permission, permissions, access, role, roles, capability, capabilities, private, visibility, who can, gated, gate' ), array( 'lock-open', 'release, released, unlock, unlocked, open access, public access' ), array( 'eye', 'preview, previews, view, views, visible, show, shows, hide, hidden, watch, reveal' ), array( 'funnel', 'filter, filters, filtering, sort, sorting, narrow, refine' ), array( 'copy', 'duplicate, duplicated, clone, cloned, copy, copies' ), // Growth, AI, automation. array( 'rocket', 'launch, launches, campaign, campaigns, promote, promoted, promoting, marketing, publish, published, publishing, ad, ads, advertising, boost, go live, utm, landing page, landing pages, social' ), array( 'sparkles', 'ai, artificial intelligence, assistant, assistants, recommend, recommended, recommendation, recommendations, automate, automated, automation, smart, suggest, suggestion, suggestions, magic, generate, generated, complementary, related, upsell, upsells, cross sell' ), array( 'bot', 'bot, bots, agent, agents, chatbot, chatbots' ), array( 'workflow', 'flow, flows, workflow, workflows, process, processes, stage, stages, admission flow' ), array( 'zap', 'instant, quick, fast, trigger, triggers, action, actions, shortcut, shortcuts' ), // Structure. array( 'layers', 'level, levels, tier, tiers, variant, variants, layer, layers, structure, structures, hierarchy, nested, axis, axes' ), array( 'folder', 'category, categories, folder, folders, taxonomy, taxonomies, collection, collections, group, groups, grouping, section, sections' ), array( 'puzzle', 'component, components, block, blocks, feature, features, part, parts' ), array( 'contact', 'phone, phones, viber, whatsapp, call, calls, sms, telephone' ), array( 'megaphone', 'announce, announced, broadcast, broadcasts, shout, campaign message' ), array( 'calendar-x', 'cancel or delete, not found, missing, expired, expiry, expiration' ), // Catch-alls. array( 'sliders-horizontal', 'default, defaults, limit, limits, cap, caps, threshold, thresholds, tuning, parameter, parameters, preference, preferences' ), array( 'wrench', 'tool, tools, utility, utilities, fix, fixes, troubleshoot, troubleshooting' ), array( 'settings', 'setting, settings, option, options, configuration, configure, config, general, advanced, misc, miscellaneous, setup, set up, installation, install' ), array( 'list', 'all, list, lists, browse, overview, every, index of, everything, session, sessions' ), ); // @icons-end /** * The index of the rules' terms (built once per request): a word or phrase => its rule's position, and the * stems by length. Lower is earlier (higher priority). * * @var array{0: array, 1: array, 2: int, 3: list}|null */ private static ?array $rules = null; /** * A Lucide icon for an entry from its own words (0.12.0): the title first, then the description; the earliest * rule of RULES one of whose terms occurs in the text wins, a phrase before a single word ("admin menu" beats * "branding"), a word as written before an inflected form or a stem ("barcode" beats "products"). '' when no * rule fits (the caller falls back to the page's icon, then the section's). Underscores and dashes read as * spaces, so an id serves as a title too. With $avoid (the page's icon), a rule naming that icon does not count, * so a child comes back with another when one fits ("Courses by category" under Courses: the folder). * * @param string $title The entry's title (or its id). * @param string $description Its description (read only when the title names nothing). * @param string $avoid An icon not to answer with ('' for none). */ public static function guess( string $title, string $description = '', string $avoid = '' ): string { if ( null === self::$rules ) { $terms = array(); $starts = array(); // the first word of every phrase => the longest phrase from it $stems = array(); $lengths = array(); foreach ( self::RULES as $index => $rule ) { foreach ( explode( ',', $rule[1] ) as $term ) { $term = trim( $term ); if ( '' === $term ) { continue; } if ( str_ends_with( $term, '*' ) ) { $stem = substr( $term, 0, -1 ); if ( ! isset( $stems[ $stem ] ) ) { $stems[ $stem ] = $index; $lengths[ strlen( $stem ) ] = true; } continue; } if ( isset( $terms[ $term ] ) ) { continue; } $terms[ $term ] = $index; $words = substr_count( $term, ' ' ) + 1; if ( $words > 1 ) { $first = strtok( $term, ' ' ); $starts[ $first ] = max( $starts[ $first ] ?? 0, $words ); } } } $lengths = array_keys( $lengths ); sort( $lengths ); self::$rules = array( $terms, $starts, $stems, $lengths ); } list( $terms, $starts, $stems, $lengths ) = self::$rules; foreach ( array( $title, $description ) as $text ) { if ( '' === $text ) { continue; } $text = strtolower( str_contains( $text, '&' ) ? wp_specialchars_decode( $text, ENT_QUOTES ) : $text ); $text = str_replace( array( '_', '-', '’', "'" ), array( ' ', ' ', '', '' ), $text ); $words = preg_split( '/[^a-z0-9]+/', $text, -1, PREG_SPLIT_NO_EMPTY ); if ( ! is_array( $words ) || array() === $words ) { continue; } $count = count( $words ); $phrase = PHP_INT_MAX; // the earliest rule per tier: a phrase, a word as written, an inflected form or a stem $word = PHP_INT_MAX; for ( $i = 0; $i < $count; $i++ ) { $w = $words[ $i ]; if ( isset( $starts[ $w ] ) ) { for ( $n = min( $starts[ $w ], $count - $i ); $n >= 2; $n-- ) { $gram = implode( ' ', array_slice( $words, $i, $n ) ); if ( isset( $terms[ $gram ] ) && $terms[ $gram ] < $phrase && $avoid !== self::RULES[ $terms[ $gram ] ][0] ) { $phrase = $terms[ $gram ]; } } } if ( isset( $terms[ $w ] ) && $terms[ $w ] < $word && $avoid !== self::RULES[ $terms[ $w ] ][0] ) { $word = $terms[ $w ]; } } if ( PHP_INT_MAX !== $phrase ) { return self::RULES[ $phrase ][0]; } if ( PHP_INT_MAX !== $word ) { return self::RULES[ $word ][0]; } $form = PHP_INT_MAX; foreach ( $words as $w ) { foreach ( self::forms( $w ) as $f ) { if ( isset( $terms[ $f ] ) && $terms[ $f ] < $form && $avoid !== self::RULES[ $terms[ $f ] ][0] ) { $form = $terms[ $f ]; } } if ( array() !== $lengths && strlen( $w ) >= 6 ) { foreach ( $lengths as $length ) { if ( $length <= strlen( $w ) ) { $stem = substr( $w, 0, $length ); if ( isset( $stems[ $stem ] ) && $stems[ $stem ] < $form && $avoid !== self::RULES[ $stems[ $stem ] ][0] ) { $form = $stems[ $stem ]; } } } } } if ( PHP_INT_MAX !== $form ) { return self::RULES[ $form ][0]; } } return ''; } /** * The one transient of the library (0.13.0): the icons the registry's pass settled, keyed by what it read. */ public const CACHE = 'rooster_admin_icons'; /** * The key of the registry's icon pass: everything the pass reads (each entry's id, title, description, the icon * it registered with and its parent, in order) and the library's version (the rules move with it). * * @param array> $entries The registry's entries before the pass. */ public static function cache_key( array $entries ): string { $ctx = hash_init( 'md5' ); hash_update( $ctx, Library::VERSION . '|' . get_locale() ); foreach ( $entries as $id => $entry ) { hash_update( $ctx, "\n" . $id . "\t" . (string) $entry['title'] . "\t" . (string) $entry['description'] . "\t" . (string) $entry['icon'] . "\t" . (string) $entry['parent'] ); } return hash_final( $ctx ); } /** * The icons the pass settled for this key, or null when the transient is missing or belongs to another key. * * @param string $key cache_key(). * @return array|null id => icon. */ public static function cached( string $key ): ?array { $row = get_transient( self::CACHE ); return is_array( $row ) && $key === (string) ( $row['key'] ?? '' ) && is_array( $row['icons'] ?? null ) ? $row['icons'] : null; } /** * Keeps the pass's result for a week (an abandoned key expires on its own; a live one is refreshed by the next * rebuild after it lapses). * * @param string $key cache_key(). * @param array $icons id => icon. */ public static function remember( string $key, array $icons ): void { set_transient( self::CACHE, array( 'key' => $key, 'icons' => $icons ), WEEK_IN_SECONDS ); } /** * Drops the cached pass (a test's, or a product that changed its rows and wants the next request to settle them * afresh; the key changes with the rows anyway). */ public static function forget_cache(): void { delete_transient( self::CACHE ); } /** * The base forms a word may be a plural or an inflection of ("courses" → "course", "categories" → "category", * "grading" → "grade"/"grad", "invited" → "invite"/"invit"). * * @param string $word A lower-case word. * @return list */ private static function forms( string $word ): array { $forms = array(); $len = strlen( $word ); if ( $len > 4 && str_ends_with( $word, 'ies' ) ) { $forms[] = substr( $word, 0, -3 ) . 'y'; } if ( $len > 3 && str_ends_with( $word, 'es' ) ) { $forms[] = substr( $word, 0, -2 ); } if ( $len > 2 && str_ends_with( $word, 's' ) ) { $forms[] = substr( $word, 0, -1 ); } if ( $len > 5 && str_ends_with( $word, 'ing' ) ) { $forms[] = substr( $word, 0, -3 ); $forms[] = substr( $word, 0, -3 ) . 'e'; } if ( $len > 4 && str_ends_with( $word, 'ed' ) ) { $forms[] = substr( $word, 0, -2 ); $forms[] = substr( $word, 0, -1 ); } return $forms; } /** * The bundled icons of this request. * * @var array>>|null */ private static ?array $data = null; /** * The bundled icons plus the products': Lucide name => shapes, each [tag, attribute, value, ...]. * * @return array>> */ public static function data(): array { if ( null !== self::$data ) { return self::$data; } $data = require Library::dir() . '/data/icons.php'; $data = is_array( $data ) ? $data : array(); /** * Filters the icon set: a product adds Lucide icons as name => shapes (each shape [tag, attribute, value, ...] * of a 24 x 24 outline icon: path, circle, ellipse, rect, line, polyline, polygon). * * @since 0.1.0 * * @param array>> $data Icons. */ $extra = apply_filters( 'rooster/admin/icons', array() ); foreach ( is_array( $extra ) ? $extra : array() as $name => $shapes ) { if ( is_string( $name ) && 1 === preg_match( '/^[a-z0-9-]{1,60}$/', $name ) && is_array( $shapes ) && ! isset( $data[ $name ] ) ) { $data[ $name ] = $shapes; } } self::$data = $data; return $data; } /** * Whether a Lucide name is bundled. * * @param string $name Lucide name. */ public static function exists( string $name ): bool { return isset( self::data()[ $name ] ); } /** * The icon of an entity of the ecosystem (`/`, e.g. `rooster-lms/course`): what its owner * registered (Registry::register_entity_icons()) or the `rooster/admin/entity_icons` filter names, when the * bundle has it; else $fallback. * * @param string $key Entity key. * @param string $fallback The name when none is registered or bundled. */ public static function of_entity( string $key, string $fallback = '' ): string { $registry = Library::registry(); $name = null !== $registry ? $registry->entity_icon( $key ) : ''; if ( '' === $name ) { /** * Filters the icons of the ecosystem's entities, `/` => Lucide name, for a product that * names them without registering (the adapters before a product consumes the library). * * @since 0.5.0 * * @param array $icons Entity => Lucide name. */ $map = apply_filters( 'rooster/admin/entity_icons', array() ); $slug = str_replace( '_', '-', strtolower( $key ) ); $name = is_array( $map ) && is_string( $map[ $slug ] ?? null ) ? $map[ $slug ] : ''; } return '' !== $name && self::exists( $name ) ? $name : $fallback; } /** * The icon name of an entry: its own when bundled, else its section's. * * @param array $entry Entry. * @param array $product The entry's product. */ public static function of_entry( array $entry, array $product ): string { $name = (string) ( $entry['icon'] ?? '' ); if ( '' !== $name && self::exists( $name ) ) { return $name; } $section = (string) ( $product['sections'][ $entry['section'] ]['icon'] ?? '' ); return self::exists( $section ) ? $section : 'layout-grid'; } /** * An entry's icon (decorative: the title beside it says what it is). * * @param array $entry Entry. * @param array $product The entry's product. * @param int $size Size in px. */ public static function entry( array $entry, array $product, int $size = 16 ): string { return self::svg( self::of_entry( $entry, $product ), $size ); } /** * A chrome symbol (UI). * * @param string $name A key of UI. * @param int $size Size in px. */ public static function ui( string $name, int $size = 18 ): string { return self::svg( self::UI[ $name ] ?? $name, $size, 'rooster-admin-icon rooster-admin-icon--' . sanitize_html_class( $name ) ); } /** * The icons asked for in this request (the sprite prints each once). * * @var array */ private static array $used = array(); /** * An inline, decorative Lucide icon ('' for an unknown name): a reference (``) into the page's sprite, which * print_sprite() prints once in the footer with the shapes of every icon used, so a page with a hundred icons * carries each icon's paths once (the full inline form was more than half the bytes of a large Home page). Sized * to $size, with data-ra-icon (chrome.js copies it into the palette and the pins). * * @param string $name Lucide name. * @param int $size Size in px. * @param string $classes Extra classes (space-separated). */ public static function svg( string $name, int $size = 16, string $classes = '' ): string { $size = max( 8, min( 64, $size ) ); // On the storefront (0.75.0, CONTRACT §6): the theme's own drawing of the slug, a mask painted in the text colour // (rooster_icon(), one span where an SVG is several nodes; its size the nearest of the theme's mask sizes), when // the theme has it (rooster_has_icon(), the theme brief of 31 Oct 2026); the library's sprite otherwise. if ( self::storefront() && function_exists( 'rooster_has_icon' ) && function_exists( 'rooster_icon' ) && rooster_has_icon( $name ) ) { $mask = (string) rooster_icon( $name, array( 'width' => $size, 'height' => $size, 'class' => 'rooster-admin-ico' . ( '' !== $classes ? ' ' . $classes : '' ), 'data-ra-icon' => $name, ) ); if ( '' !== $mask ) { return $mask; } } if ( ! isset( self::data()[ $name ] ) ) { return ''; } self::$used[ $name ] = true; // " />", as wp_kses() prints a void tag: a consumer that compares its output before and after kses sees no change. return ''; } /** * Whether this request draws a storefront page (0.75.0): not the admin, not an admin-ajax or a REST answer (a remote * table's rows travel with their symbols, whichever page asked for them). On one, an icon the theme has prints as * the theme's mask (svg()); the icons it lacks go into the footer's sprite (Library::builders()). */ public static function storefront(): bool { return ! is_admin() && ! wp_doing_ajax() && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ); } /** * The DOM id of an icon's symbol in the sprite. * * @param string $name Lucide name. */ public static function symbol_id( string $name ): string { return 'rooster-admin-i-' . $name; } /** * The shapes of an icon as markup (the sprite's symbol body). * * @param string $name Lucide name. */ public static function shapes( string $name ): string { $inner = ''; foreach ( self::data()[ $name ] ?? array() as $shape ) { if ( ! is_array( $shape ) || array() === $shape ) { continue; } $inner .= '<' . tag_escape( (string) $shape[0] ); for ( $i = 1, $n = count( $shape ) - 1; $i < $n; $i += 2 ) { $inner .= ' ' . tag_escape( (string) $shape[ $i ] ) . '="' . esc_attr( (string) $shape[ $i + 1 ] ) . '"'; } $inner .= ' />'; } return $inner; } /** * The sprite: one `` per icon used so far, in a hidden `` ('' when none was). The `` references * resolve wherever the sprite sits in the document. */ public static function sprite(): string { if ( array() === self::$used ) { return ''; } $html = ''; } /** * The icons asked for so far in this request (0.63.0: a server's page of rows carries the symbols its rows use). * * @return array */ public static function used(): array { return array_keys( self::$used ); } /** * The ``s of named icons, without the sprite around them (0.63.0: a remote table's answer; the glue adds * those the page lacks to its sprite). * * @param array $names Lucide names. */ public static function symbols( array $names ): string { $html = ''; foreach ( array_unique( $names ) as $name ) { if ( isset( self::data()[ $name ] ) ) { $html .= '' . self::shapes( $name ) . ''; } } return $html; } /** * Icons a script will draw (0.74.0, the table twin: Library::table_assets()): marked as used, so the page's sprite * carries their symbols (print_sprite()) although no builder printed them. An unknown name is left out. * * @param array $names Lucide names. */ public static function need( array $names ): void { foreach ( $names as $name ) { if ( isset( self::data()[ (string) $name ] ) ) { self::$used[ (string) $name ] = true; } } } /** * Hook admin_footer (99): the sprite of the icons the page used (nothing when it used none). */ public static function print_sprite(): void { if ( array() !== self::$used ) { self::$used['sun'] = true; // the theme toggle swaps among its three symbols (0.24.0) self::$used['moon'] = true; self::$used['sun-moon'] = true; self::$used['check'] = true; // the wizard ticks the steps done as it moves (0.28.0) foreach ( array( 'info', 'circle-check', 'triangle-alert', 'circle-alert', 'message-square', 'x', 'inbox' ) as $drawn ) { self::$used[ $drawn ] = true; // the notices, the dismiss button and the empty state a script draws (roosterAdmin.ui, 0.54.0) } } $sprite = self::sprite(); if ( '' !== $sprite ) { Markup::out( $sprite ); } } /** * Hook wp_footer (99, 0.63.0): on the storefront, the sprite of the icons a table drew (Library::builders()). */ public static function print_front_sprite(): void { $sprite = self::sprite(); if ( '' !== $sprite ) { Markup::out( $sprite ); } } /** * Forgets the icons asked for (tests). * * @internal */ public static function reset_used(): void { self::$used = array(); } /** * Forgets the request cache (tests). * * @internal */ public static function forget(): void { self::$data = null; self::$rules = null; } }