layout, r: the added rows' keys}); nothing changes for anyone until they change it, * and Reset layout puts the page back as the product arranged it, rows and layouts included. * * @package RoosterAdmin */ namespace Rooster\Admin; defined( 'ABSPATH' ) || exit; /** * Static. */ final class Board { /** * The widths (rem) at which a band may go to one column (one container rule each in chrome.css): a band takes the * smallest that its columns and its cards' minimum widths need. */ public const THRESHOLDS = array( 45, 50, 55, 60, 65, 69, 75, 80, 90, 100, 120 ); /** * The width a band's columns need by default, by layout (FR9: about 69 rem for two, 90 for three; a 2:1 pair as two), * where the band has the layout the page gave it. A layout the person chose (0.80.0), or one without a default here, * needs its narrowest column LAYOUT_MIN wide (threshold()). */ public const DEFAULT_AT = array( '1:1' => 69, '2:1' => 69, '1:2' => 69, '1:1:1' => 90, ); /** * The layouts a row of columns may take (0.80.0), each column's width in parts: the order the layout menu lists them. */ public const LAYOUTS = array( '1', '1:1', '1:2', '2:1', '1:3', '3:1', '1:1:1', '1:2:1', '2:1:1', '1:1:2', '1:1:1:1' ); /** * The narrowest column a layout the person chose keeps before its row goes to one column, in rem, where no card asks * for more (0.80.0): four columns show from 69 rem of board, so a layout chosen on a laptop's screen shows as chosen. */ public const LAYOUT_MIN = 16; /** * The most columns a row takes (0.80.0: four). */ public const MAX_COLS = 4; /** * A row the person added (0.80.0): its key (a dot, which no band's slug has), and the most they may add to a board. */ public const ROW_PATTERN = '/^u\.row-[1-9][0-9]?$/'; public const MAX_ROWS = 20; /** * The gap between cards, in rem (--ra-gap-card: 20 px). */ public const GAP = 1.25; /** * The most bytes of JSON one product's saved boards take; the boards saved longest ago go first. */ public const MAX_BYTES = Prefs::LIMITS[ Prefs::BOARDS ]; /** * The most bytes of JSON one board's arrangement takes when it is saved (0.80.0: its rows and their layouts with * it; Rest::save()), half the product's boards. */ public const VALUE_BYTES = 8192; /** * The most bands, columns per band and cards per band a saved board names. */ public const MAX_BANDS = 40; public const MAX_CARDS = 80; /** * A board's key, a band's and a card's (an HTML id). */ public const KEY_PATTERN = '/^[a-z0-9][a-z0-9_.\/-]{0,119}$/'; public const CARD_PATTERN = '/^[A-Za-z0-9][A-Za-z0-9_:.-]{0,119}$/'; /** * The boards being printed (open() to close()), the innermost last. * * @var list> */ private static array $stack = array(); /** * The boards whose Reset layout the page head carries (Ui::pagehead()'s `board`), by key. * * @var array */ private static array $head_reset = array(); /** * What went wrong this request (a band printed with content between its cards, a board left open), by board. * * @var array */ private static array $problems = array(); /** * A counter for the bands' title ids. * * @var int */ private static int $bands = 0; /** * Whether the reset dialog was printed. * * @var bool */ private static bool $dialog = false; /** * The boards whose Edit layout the page head (or the Home's summary) carries, by key (0.80.0). * * @var array */ private static array $head_edit = array(); /** * Whether the edit mode's template (the row's tools, Add a row, the layout menu) was printed (0.80.0; once a page). * * @var bool */ private static bool $tools = false; /** * The arranged board the buffer's handler (emit()) sends next (0.81.0): set by capture() and end() right before * they flush the board's buffer, sent ahead of what the buffer holds, then emptied. * * @var string */ private static string $arranged = ''; /** * The screen page() is printing: the index of its open board in the stack ('board', -1 when none); null outside * page(). * * @var array{board: int}|null */ private static ?array $page = null; /** * Prints a board whose cards $render echoes (0.69.0, the wordpress.org review: the board's one buffer starts and ends * here, in this function, at the same level): `ob_start()` with the board's handler (emit()), $render, then * `ob_end_flush()`; when $render throws, the `catch` closes the buffer, forgets the board and throws the error on. * 0.81.0 (the wordpress.org review): nothing the board received is echoed again; PHP sends the buffer through * emit(), which puts the arranged board in its place, the product's cards as their own escaped output printed them. $render states the bands with band_open() * and band_close() and echoes the cards; cards it prints outside a band are one band of one column, in their order * (loose()). A band still open when $render returns ends with the board. * * @param string $key The board's key: the screen, unique in the product ("frontend/gallery_layout"). * @param array $opts See render(). * @param callable $render Echoes the board's bands and cards. * * @throws \Throwable What $render throws. */ public static function capture( string $key, array $opts, callable $render ): void { self::$stack[] = array( 'key' => $key, 'opts' => $opts, 'bands' => array(), 'band' => null, ); $i = count( self::$stack ) - 1; ob_start( array( self::class, 'emit' ) ); try { $render(); if ( is_array( self::$stack[ $i ]['band'] ) ) { self::band_close(); } } catch ( \Throwable $e ) { ob_end_clean(); array_splice( self::$stack, $i ); throw $e; } self::loose( $i, self::take() ); $board = array_pop( self::$stack ); $opts = (array) $board['opts']; if ( isset( $board['head'] ) ) { $opts['head'] = (string) $board['head']; // the module's tab strip, as it printed it (0.58.2) } self::$arranged = self::render( (string) $board['key'], $board['bands'], $opts ); ob_end_flush(); // emit(): the arranged board, in place of what the buffer held } /** * The board of a screen built as a string (0.83.0; the owner, 7 Nov 2026: every page's layout is the person's, Rooster * LMS Pro's screens among them, which build their markup as one string and echo it): what capture() makes of what a * callback prints, without a buffer. A leading tab strip is the board's head; the cards are split at the top level; * anything seen between them keeps the whole as it came (P4, problems()). HTML that is blank comes back as it is. * * @param string $key The board's key: the screen, unique in the product. * @param string $html The screen's cards (escaped: the product's own markup). * @param array $opts See render(). */ public static function from_html( string $key, string $html, array $opts = array() ): string { if ( self::blank( $html ) ) { return $html; } self::$stack[] = array( 'key' => $key, 'opts' => $opts, 'bands' => array(), 'band' => null, ); self::loose( count( self::$stack ) - 1, $html ); $board = array_pop( self::$stack ); if ( isset( $board['head'] ) ) { $opts['head'] = (string) $board['head']; // the screen's tab strip, as it built it } return self::render( $key, $board['bands'], $opts ); } /** * The handler of a board's buffer (0.81.0, capture() and page()): PHP calls it with what the buffer holds when the * buffer is flushed or ended, and sends what it returns. The board arranged last (capture(), end()) goes first, then * what the buffer holds (what the screen printed after its board); a buffer being emptied (take(), a throw) sends * nothing and keeps the board for its flush. * * @param string $buffer What the buffer holds. * @param int $phase PHP_OUTPUT_HANDLER_* flags. */ public static function emit( string $buffer, int $phase ): string { if ( $phase & PHP_OUTPUT_HANDLER_CLEAN ) { return ''; } $out = self::$arranged . $buffer; self::$arranged = ''; return $out; } /** * Prints a whole screen whose board starts and ends somewhere in it (0.69.0): the screen's render callback runs in * one buffer, started and ended here at the same level (`ob_start()` with the board's handler, emit(), $render, * `ob_end_flush()`; when $render throws, the `catch` closes the buffer, forgets the board and throws the error on); * inside it start() marks where the board begins (what was printed before goes out as it came: the page head) and * end() where it ends (the board arranged from what was printed between, bands included, sent in its place by * emit()). A product wraps its screens' callbacks once (the LMS's * UI\Admin\Page::screen()) and its page helper calls start() and end(), so a screen with several ways out (an early * return) needs no closure of its own. A board still open when $render returns ends there. * * @param callable $render Prints the screen. * * @throws \Throwable What $render throws. */ public static function page( callable $render ): void { $outer = self::$page; self::$page = array( 'board' => -1 ); $level = count( self::$stack ); ob_start( array( self::class, 'emit' ) ); try { $render(); self::end(); } catch ( \Throwable $e ) { ob_end_clean(); self::$page = $outer; array_splice( self::$stack, $level ); throw $e; } ob_end_flush(); // what the screen printed after its board (the board itself went out from end()) self::$page = $outer; } /** * Starts the board of the screen page() prints, here: what the screen printed so far goes out as it came. False * (and nothing starts) outside page() or when its board is open already. * * @param string $key The board's key. * @param array $opts See render(). */ public static function start( string $key, array $opts = array() ): bool { if ( null === self::$page || self::$page['board'] >= 0 ) { return false; } if ( ob_get_level() > 0 ) { ob_flush(); // the page head, as the screen printed it, to the level page() found; the buffer stays open } self::$stack[] = array( 'key' => $key, 'opts' => $opts, 'bands' => array(), 'band' => null, ); self::$page['board'] = count( self::$stack ) - 1; return true; } /** * Ends the board start() began: what the screen printed since is arranged (bands, loose cards) and sent in its place * (the buffer flushed through emit(), 0.81.0); what follows is the screen's again. Nothing when no board of page() * is open. */ public static function end(): void { if ( null === self::$page || self::$page['board'] < 0 ) { return; } $i = self::$page['board']; if ( is_array( self::$stack[ $i ]['band'] ) ) { self::band_close(); } self::loose( $i, self::take() ); $board = array_pop( self::$stack ); self::$page['board'] = -1; $opts = (array) $board['opts']; if ( isset( $board['head'] ) ) { $opts['head'] = (string) $board['head']; // the module's tab strip, as it printed it (0.58.2) } self::$arranged = self::render( (string) $board['key'], $board['bands'], $opts ); if ( ob_get_level() > 0 ) { ob_flush(); // page()'s buffer, through emit(): the arranged board; the buffer stays open for the rest of the screen } } /** * Starts a printed board the old way (before 0.69.0): it no longer buffers (the wordpress.org review: a buffer must * end in the function that starts it), so what follows until close() is printed where it comes, one card under the * other, nothing moves, and band_open() / band_close() inside it do nothing. A product moves to capture(); this * stays so a product built for an older library keeps working when a newer copy is loaded. * * @param string $key The board's key. * @param array $opts See render(). */ public static function open( string $key, array $opts = array() ): void { unset( $opts ); self::$stack[] = array( 'key' => $key, 'flat' => true, 'bands' => array(), 'band' => null, ); } /** * Starts a band of the board being printed; its cards are echoed until band_close(). * * @param array $opts See render()'s bands (title, description, key, id, cols, rows, fixed, min). */ public static function band_open( array $opts = array() ): void { $i = count( self::$stack ) - 1; if ( $i < 0 || ! empty( self::$stack[ $i ]['flat'] ) ) { return; } self::loose( $i, self::take() ); self::$stack[ $i ]['band'] = $opts; } /** * Ends the band being printed. */ public static function band_close(): void { $i = count( self::$stack ) - 1; if ( $i < 0 || ! empty( self::$stack[ $i ]['flat'] ) || ! is_array( self::$stack[ $i ]['band'] ) ) { return; } $html = self::take(); $band = self::$stack[ $i ]['band']; $units = self::split( $html ); if ( null === $units || self::unseen_only( $units ) ) { // Something stands between the cards (P4): the band is printed as it came, one column, nothing moves. A band // that holds only what is never seen (a script) is printed as it came too, without a problem. if ( null === $units ) { self::$problems[ (string) self::$stack[ $i ]['key'] ] = 'content between the cards of a band (P4): printed as it came'; } $band = array( 'raw' => $html ); } else { $band['cards'] = $units; } self::$stack[ $i ]['bands'][] = $band; self::$stack[ $i ]['band'] = null; } /** * What the board's buffer (capture()'s or page()'s) holds so far, the buffer emptied and kept open (band_open(), * band_close() and end() cut the board's one buffer instead of closing it and starting their own). Only called * inside capture() or page(), for a board that is not flat, so the buffer is always the board's. */ private static function take(): string { if ( ob_get_level() < 1 ) { return ''; } $html = (string) ob_get_contents(); ob_clean(); return $html; } /** * Ends a board open() started (before 0.69.0): it printed as it came, so nothing is left to echo. */ public static function close(): void { $i = count( self::$stack ) - 1; if ( $i >= 0 && ! empty( self::$stack[ $i ]['flat'] ) ) { array_pop( self::$stack ); } } /** * What a printed board holds outside its bands: cards alone are one band of one column, in their order (a page * that turns its board on before its modules state bands: Rooster One's page.php around every module); anything * else stays in place as it came, never moving (P4). * * @param int $i The board's index in the stack. * @param string $html What was printed. */ private static function loose( int $i, string $html ): void { if ( self::blank( $html ) ) { return; } if ( array() === self::$stack[ $i ]['bands'] && ! isset( self::$stack[ $i ]['head'] ) ) { $head = self::tab_head( $html ); if ( null !== $head ) { self::$stack[ $i ]['head'] = $head[0]; $html = $head[1]; if ( self::blank( $html ) ) { return; } } } $units = self::split( $html ); if ( null === $units || self::unseen_only( $units ) ) { if ( null === $units ) { self::$problems[ (string) self::$stack[ $i ]['key'] ] = 'content between the cards of a board (P4): printed as it came'; } self::$stack[ $i ]['bands'][] = array( 'raw' => $html ); return; } self::$stack[ $i ]['bands'][] = array( 'cards' => $units ); } /** * A module's leading tab strip, set apart as the board's head (0.58.2, Rooster One's wave 1: about 24 of its modules * print their in-page tabs at the top of their render, after page.php has opened the board, and the strip stopped * the split, P4): when the first visible node a board receives is a `.rooster-admin-tabs` nav, or an element whose * only child is one (a plain wrapper), it is printed above the bands, out of the move set, and the cards after it * form the board as usual. Anything else seen still stops the split. [head HTML, the rest], or null. * * @param string $html What the board received before its first band. * @return array{0: string, 1: string}|null */ private static function tab_head( string $html ): ?array { $at = 1 === preg_match( '/^(?:\s+|)*/s', $html, $space ) ? strlen( $space[0] ) : 0; if ( 1 !== preg_match( '/\G<([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/', $html, $open, 0, $at ) ) { return null; } $tag = strtolower( $open[1] ); $end = self::end_of( $html, $tag, $at ); if ( null === $end ) { return null; } $node = substr( $html, $at, $end - $at ); if ( ! self::is_tabs( $node ) ) { if ( 'nav' === $tag || 1 !== preg_match( '#^<' . preg_quote( $open[1], '#' ) . '\b[^>]*>(.*)$#is', $node, $inner ) ) { return null; } $only = trim( (string) preg_replace( '//s', '', $inner[1] ) ); $nav = '' !== $only ? self::end_of( $only, 'nav', 0 ) : null; if ( null === $nav || strlen( $only ) !== $nav || ! self::is_tabs( $only ) ) { return null; } } return array( substr( $html, $at, $end - $at ), substr( $html, $end ) ); } /** * Whether HTML starts with the library's tab strip (`