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;
}