current_product() : ''; return '' !== $product ? $product : self::$front_product; } /** * A fresh registry, the products registered again (a consumer's tests, after they changed what their product * registers: the LMS's fixtures add rows to its own registry and forget it). Null before boot. * * @internal Tests. */ public static function rebuild(): ?Registry { if ( null === self::$registry ) { return null; } self::$registry = new Registry(); do_action( 'rooster/admin/register', self::$registry ); Prefs::forget(); return self::$registry; } /** * Whether boot() ran. */ public static function booted(): bool { return null !== self::$registry; } /** * The booted copy's folder. */ public static function dir(): string { return self::$dir; } /** * The booted copy's URL. */ public static function url(): string { return self::$url; } /** * The URL of an asset of the booted copy. * * @param string $path Path under the copy's folder ('assets/chrome.css'). */ public static function asset( string $path ): string { return self::$url . '/' . ltrim( $path, '/' ); } /** * The URL of a copy's folder, by the folder's path, as WordPress's own functions give it (wordpress.org review 4: no * folder constant): a copy in the active theme or its parent from get_stylesheet_directory_uri() / * get_template_directory_uri() (the theme folder's real path too, a junction or symlink on a development site); a * copy in a plugin or a must-use plugin from plugins_url() with the copy's own loader file, which follows a symlinked * plugin folder through WordPress's registered real paths. A consumer that knows its URL passes it to * rooster_admin_register() instead. * * @param string $dir Folder. */ public static function url_of( string $dir ): string { $dir = rtrim( wp_normalize_path( $dir ), '/' ); $real = rtrim( wp_normalize_path( (string) realpath( $dir ) ), '/' ); $themes = array( array( get_stylesheet_directory(), get_stylesheet_directory_uri() ), array( get_template_directory(), get_template_directory_uri() ), ); foreach ( $themes as [ $root, $url ] ) { $url = rtrim( $url, '/' ); foreach ( array_unique( array( rtrim( wp_normalize_path( $root ), '/' ), rtrim( wp_normalize_path( (string) realpath( $root ) ), '/' ) ) ) as $candidate ) { foreach ( array( $dir, $real ) as $path ) { if ( '' !== $candidate && '' !== $path && str_starts_with( $path . '/', $candidate . '/' ) ) { return $url . substr( $path, strlen( $candidate ) ); } } } } return rtrim( plugins_url( '', $dir . '/rooster-admin.php' ), '/' ); } /** * Filter admin_body_class: a class on the chrome's screens (the chrome's spacing), with the product's slug. * * @param mixed $classes Space-separated classes. */ public static function body_class( $classes ): string { $classes = is_string( $classes ) ? $classes : ''; $product = self::$registry ? self::$registry->current_product() : ''; if ( '' === $product ) { return $classes; } $theme = Prefs::theme( get_current_user_id() ); // the scheme (0.24.0): light by default, the dark class, or the device's return $classes . ' rooster-admin-has-chrome rooster-admin-product-' . sanitize_html_class( $product ) . ( 'dark' === $theme ? ' rooster-admin-dark' : ( 'auto' === $theme ? ' rooster-admin-scheme-auto' : '' ) ); } /** * Hook admin_head (0): on a chrome screen whose user chose the dark scheme (or the device's), the document's * colour scheme, so a page load never flashes a white canvas between two dark screens (0.44.0; the owner: "the whole * page flickers on reload"). Nothing on a light screen, nothing off the chrome. The canvas's own colour is * scheme_css(), the chrome stylesheet's inline style (0.81.0, the wordpress.org review: no style element echoed). */ public static function early_scheme(): void { if ( null === self::$registry || '' === self::$registry->current_product() ) { return; } $theme = Prefs::theme( get_current_user_id() ); if ( 'dark' === $theme || 'auto' === $theme ) { printf( '' . "\n", esc_attr( 'dark' === $theme ? 'dark' : 'light dark' ) ); } } /** * The dark canvas of a scheme (0.81.0): the document's background before the chrome's own rules paint the page, for * the dark scheme, or for the device's when it is dark; '' for the light one. * * @param string $theme Prefs::theme(): light, dark or auto. */ public static function scheme_css( string $theme ): string { if ( 'dark' === $theme ) { return 'html{background:#09090b}'; } return 'auto' === $theme ? '@media (prefers-color-scheme: dark){html{background:#09090b}}' : ''; } /** * Whether this admin screen is a Rooster page (0.81.0): a screen of a registered product (the chrome's), or a page * of one that draws without the chrome (its Home, a page a catalogue entry names). The library's assets that are not * a table's (the side menu's colours, the view nonce's script) load there and nowhere else. */ public static function rooster_page(): bool { if ( null === self::$registry ) { return false; } if ( '' !== self::$registry->current_product() ) { return true; } $page = isset( $GLOBALS['plugin_page'] ) && is_string( $GLOBALS['plugin_page'] ) ? $GLOBALS['plugin_page'] : ''; return '' !== $page && in_array( $page, Request::pages( self::$registry ), true ); } /** * The suffix of the shipped assets: the minified twins tools/build.mjs writes (`.min`, each with its sourcemap) * unless SCRIPT_DEBUG asks for the readable files. */ public static function suffix(): string { return defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '' : '.min'; } /** * Hook admin_enqueue_scripts (20): the chrome's style and script with its data, on the chrome's screens only * (never on other admin screens, never in the block editor, never on the front end). */ public static function enqueue(): void { $registry = self::$registry; if ( null === $registry ) { return; } self::register_assets(); // 0.65.1: on every admin screen, registration only (a library table off the chrome brings what it needs) // 0.69.0: the view nonce on links and GET forms to a Rooster page: assets/view.js, 1 KB, with its data. 0.81.0 (the // wordpress.org review: a plugin's scripts only on the screens that need them): on a Rooster page, and on another // screen only when a product says its links into Rooster pages are there (rooster_admin_view_script). $view = Request::view_data( $registry ); /** * Filters whether this admin screen loads the view nonce's script (assets/view.js), which gives a link or a GET * form to a Rooster page the person's view nonce when it is followed or sent. On by default on the Rooster pages * themselves; a product turns it on for another screen where it prints links into its pages with view arguments * that it did not sign with rooster_admin_view_url() (a row action on a WordPress list, a meta box). * * @since 0.81.0 * * @param bool $load Whether to load it. * @param \WP_Screen|null $screen The current screen. */ if ( (bool) apply_filters( 'rooster_admin_view_script', self::rooster_page(), function_exists( 'get_current_screen' ) ? get_current_screen() : null ) ) { wp_enqueue_script( self::VIEW_HANDLE, self::asset( 'assets/view' . self::suffix() . '.js' ), array(), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); wp_add_inline_script( self::VIEW_HANDLE, 'var roosterAdminView = ' . wp_json_encode( $view, JSON_HEX_TAG | JSON_HEX_AMP ) . ';', 'before' ); } if ( '' === $registry->current_product() ) { return; } wp_enqueue_style( self::HANDLE, self::asset( 'assets/chrome' . self::suffix() . '.css' ), array(), self::VERSION ); wp_add_inline_style( self::HANDLE, wp_strip_all_tags( Colour::css( $registry ) ) ); // every value checked where it enters the registry (Colour::hex(), the keys' pattern, esc_url_raw()) $canvas = self::scheme_css( Prefs::theme( get_current_user_id() ) ); if ( '' !== $canvas ) { wp_add_inline_style( self::HANDLE, $canvas ); // the dark canvas (early_scheme() prints its meta) } // The screen's product's adoption layer (its alias file, compiled on its own: 0.31.0), after the chrome; only that // one (0.45.4: every registered product's loaded on every chrome screen, Rooster One's 67 KB on an LMS screen). $slug = $registry->current_product(); $file = 'assets/adopt-' . $slug . self::suffix() . '.css'; if ( is_file( self::$dir . '/' . $file ) ) { wp_enqueue_style( self::HANDLE . '-' . $slug, self::asset( $file ), array( self::HANDLE ), self::VERSION ); } if ( SettingsScreen::is_current() ) { wp_enqueue_style( 'wp-color-picker' ); wp_enqueue_script( 'wp-color-picker' ); $chrome = wp_scripts()->query( self::HANDLE ); // registered above for every screen: the colour picker comes first here if ( $chrome && ! in_array( 'wp-color-picker', $chrome->deps, true ) ) { $chrome->deps[] = 'wp-color-picker'; } } wp_enqueue_script( self::HANDLE ); wp_add_inline_script( self::HANDLE, 'var roosterAdminChrome = ' . wp_json_encode( Chrome::script_data( $registry ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';', 'before' ); // The local table (0.48.1): a product's table engine answers a filter change from the rows on the page. wp_enqueue_script( 'rooster-admin-table' ); // The searchable select (0.36.0): every native select on a chrome screen becomes the kit's combobox. wp_enqueue_script( 'rooster-admin-select', self::asset( 'assets/select' . self::suffix() . '.js' ), array( self::HANDLE ), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); } /** * The table's assets registered (0.65.1), on every admin screen: the chrome's stylesheet and script, the engine and * what a table may ask for (its editor, its local glue, the filter controls, the board's moves, the field patterns). * Registration prints nothing; a screen pays only for what a builder enqueues. */ private static function register_assets(): void { if ( wp_script_is( 'rooster-admin-table', 'registered' ) ) { return; } wp_register_style( self::HANDLE, self::asset( 'assets/chrome' . self::suffix() . '.css' ), array(), self::VERSION ); wp_register_script( self::HANDLE, self::asset( 'assets/chrome' . self::suffix() . '.js' ), array(), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); wp_register_script( 'rooster-admin-table', self::asset( 'assets/table' . self::suffix() . '.js' ), array(), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // The table's script twin (0.74.0, TABLES-PLAN 1e): Ui::table() and Ui::rows() for a script, joining the kit's // twins (roosterAdmin.ui, the chrome's script); registered here, loaded by a screen whose script draws tables // (table_assets( array( 'twin' ) )), so the common path pays nothing for it. wp_register_script( 'rooster-admin-table-twin', self::asset( 'assets/table-twin' . self::suffix() . '.js' ), array( self::HANDLE ), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // Editable cells (0.52.0): the engine's editor, registered here and loaded by the first Ui::editable() a screen // prints, so a screen without an editable cell pays nothing for it. wp_register_script( 'rooster-admin-table-edit', self::asset( 'assets/table-edit' . self::suffix() . '.js' ), array( 'rooster-admin-table' ), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // The local table's glue (0.62.0): registered here and loaded by the first Ui::table() a screen prints with `local`. wp_register_script( 'rooster-admin-table-local', self::asset( 'assets/table-local' . self::suffix() . '.js' ), array( 'rooster-admin-table' ), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // A live table's totals row (0.65.7, out of the glue): after the glue, whose view event it follows; loaded by the // first Ui::table() a screen prints with totals and local or remote. wp_register_script( 'rooster-admin-table-totals', self::asset( 'assets/table-totals' . self::suffix() . '.js' ), array( 'rooster-admin-table-local' ), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // A declared filter's counts (0.65.6, Filters' `counts`): after the glue, whose events it follows; loaded by the // first filter a screen prints that counts, so a table without one pays nothing for it. wp_register_script( 'rooster-admin-table-counts', self::asset( 'assets/table-counts' . self::suffix() . '.js' ), array( 'rooster-admin-table-local' ), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // The filter controls (0.64.4, Filters): the calendar, the presets, the slider and the buckets; registered here and // loaded by the first one a screen prints. It depends on nothing: its twin on the front end is the same file. wp_register_style( Filters::HANDLE, self::asset( 'assets/filters' . self::suffix() . '.css' ), array( self::HANDLE ), self::VERSION ); // 0.65.0: its own stylesheet, with the script // A card per row (0.73.0, TABLES-PLAN 1d, Ui::table()'s `cards`): its own stylesheet, loaded by the first table that // asks for it (an opt-in for the lists that want it: the common path pays nothing). wp_register_style( 'rooster-admin-table-cards', self::asset( 'assets/table-cards' . self::suffix() . '.css' ), array( self::HANDLE ), self::VERSION ); wp_register_script( Filters::HANDLE, self::asset( 'assets/filters' . self::suffix() . '.js' ), array(), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // The page board's moves (0.58.0): registered here, enqueued by the first board a screen prints with something // to move (Board::html(), the Home's included), so a screen without one pays nothing for it. wp_register_script( 'rooster-admin-board', self::asset( 'assets/board' . self::suffix() . '.js' ), array( self::HANDLE ), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // A table's text size (0.68.0): one button cycles five sizes, kept per person and table; front-end safe (its twin // is rooster-table-size); loaded by the first button a screen prints, so a table without one pays nothing. wp_register_script( 'rooster-admin-table-size', self::asset( 'assets/table-size' . self::suffix() . '.js' ), array(), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // The side panel (0.46.0; its layouts since 0.67.0): docked at the start or the end, floating, folded; out of // chrome.js since 0.67.0, loaded by the first Ui::panel_open() a screen prints, so a screen without one pays nothing. wp_register_script( 'rooster-admin-panel', self::asset( 'assets/panel' . self::suffix() . '.js' ), array( self::HANDLE ), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // A page's windows (0.68.13, Ui::windows()): docked in their order, floating, folded; loaded by the first layout a // screen prints, so a screen without one pays nothing. Its rules decide the first paint, so they are chrome.css's. wp_register_script( 'rooster-admin-windows', self::asset( 'assets/windows' . self::suffix() . '.js' ), array( self::HANDLE ), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // A row's menu (0.79.0, Ui::row_menu()): its place and its keys, and its list's own stylesheet (the list is hidden // until opened, so it may come with the footer); loaded by the first menu a screen prints, or by table_assets( // array( 'row-menu' ) ) for a page whose rows come later. The script depends on nothing (off the chrome too). wp_register_style( 'rooster-admin-row-menu', self::asset( 'assets/row-menu' . self::suffix() . '.css' ), array( self::HANDLE ), self::VERSION ); wp_register_script( 'rooster-admin-row-menu', self::asset( 'assets/row-menu' . self::suffix() . '.js' ), array(), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); // The field patterns' behaviour (0.59.0, Fields): the colour field, the segmented switch's fallback, the secret's // Replace, the media picker; registered here, enqueued by the builder that prints one. wp_register_script( 'rooster-admin-fields', self::asset( 'assets/fields' . self::suffix() . '.js' ), array( self::HANDLE ), self::VERSION, array( 'in_footer' => true, 'strategy' => 'defer', ) ); require_once dirname( __DIR__ ) . '/table-engine.php'; // its words' function (functions only: nothing runs) wp_add_inline_script( 'rooster-admin-table-edit', 'window.roosterTableEditLabels = ' . wp_json_encode( rooster_table_edit_labels() ) . ';', 'before' ); } /** * Whether this request draws an admin screen without the chrome (0.65.1): a WooCommerce order, a post's editor, a * core screen. A library table drawn there brings its own assets and sits in an island (Ui::table()). */ public static function off_chrome(): bool { return is_admin() && ! wp_doing_ajax() && did_action( 'admin_enqueue_scripts' ) > 0 && null !== self::$registry && '' === self::$registry->current_product(); } /** * A library table's assets on an admin screen without the chrome (0.65.1; rooster_admin_table_assets()): the * chrome's stylesheet and the engine, with the parts named (local, edit, move, filters, counts, totals). Called on * `admin_enqueue_scripts` for a screen that prints a table, they come in the head; a table that finds them missing * enqueues them itself (printed with the page's footer then). On a chrome screen it adds only the parts. * * @param array $parts local, edit, move, filters, counts (0.65.6), totals (0.65.7), cards (0.73.0: a * stylesheet alone, a list shown as a card per row), twin (0.74.0: the table's * script twin, roosterAdmin.ui.table(), for a screen whose script draws tables; * its own icons go into the page's sprite), row-menu (0.79.0: a row's * menu's script and stylesheet, for a page whose rows a script or a REST * answer draws later). * @param array $icons 0.74.0: the Lucide names a script's table draws (a cell's icon, an action's), * into the page's sprite (Icons::need()). */ public static function table_assets( array $parts = array(), array $icons = array() ): void { if ( ! self::island_style() ) { return; } wp_enqueue_script( 'rooster-admin-table' ); $handles = array( 'local' => 'rooster-admin-table-local', 'edit' => 'rooster-admin-table-edit', 'move' => 'rooster-admin-board', 'filters' => Filters::HANDLE, 'counts' => 'rooster-admin-table-counts', 'totals' => 'rooster-admin-table-totals', 'cards' => 'rooster-admin-table-cards', 'twin' => 'rooster-admin-table-twin', 'row-menu' => 'rooster-admin-row-menu', ); foreach ( array_intersect_key( $handles, array_flip( $parts ) ) as $part => $handle ) { if ( 'cards' === $part ) { wp_enqueue_style( $handle ); // a stylesheet alone continue; } wp_enqueue_script( $handle ); if ( 'filters' === $part || 'row-menu' === $part ) { wp_enqueue_style( $handle ); } } if ( in_array( 'twin', $parts, true ) ) { Icons::need( array( 'chevron-down', 'check', 'minus' ) ); // the twin's own: a sort link's caret, a boolean's yes and no } if ( in_array( 'row-menu', $parts, true ) ) { Icons::need( array( 'ellipsis-vertical' ) ); // a row's menu's button, in rows drawn later } Icons::need( $icons ); if ( in_array( 'move', $parts, true ) && '' === self::$registry->current_product() && ! self::$board_words ) { self::$board_words = true; // the chrome's data, which carries them, is not on this screen wp_add_inline_script( 'rooster-admin-board', 'window.roosterAdminBoardWords = ' . wp_json_encode( Chrome::board_words() ) . ';', 'before' ); } } /** * The chrome's stylesheet on an admin screen without the chrome (0.65.1): an island's tokens and recipes (a table, * a box of facts). False outside the admin or before the library boots. */ public static function island_style(): bool { if ( null === self::$registry || ! is_admin() ) { return false; } self::register_assets(); wp_enqueue_style( self::HANDLE ); return true; } /** * Whether the board's words were added for a screen without the chrome. * * @var bool */ private static bool $board_words = false; }