'font-family', 'fs' => 'font-size', 'fw' => 'font-weight', 'lh' => 'line-height', 'ls' => 'letter-spacing', 'tt' => 'text-transform', 'fst' => 'font-style', ); const STATES = array( 'n', 'h', 'f', 'fw', 'a', 'sk' ); const SYSTEM_FONTS = '/^(inherit|initial|system-ui|sans-serif|serif|monospace|cursive|var\(|-apple-system|Arial|Helvetica|Georgia|Times|Courier|Verdana|Tahoma|Trebuchet|ui-)/i'; private static $schema = null; private static $props = null; private static $functions = null; // --------------------------------------------------------------------- // Public API // --------------------------------------------------------------------- /** * CSS for a sanitized document: base rules of the widgets it uses, element styles, hide-on-device * rules and the page background/color. Returns array( 'css' => string, 'fonts' => family => weights ). */ public static function document( array $doc, $bps = null ) { $bps = null === $bps ? Options::breakpoints() : $bps; $ids = array_column( $bps, 'id' ); $widgets = self::schema()['widgets']; $rules = self::rules( $bps ); $fonts = array(); $types = array(); $tip = false; $els = isset( $doc['els'] ) && is_array( $doc['els'] ) ? $doc['els'] : array(); foreach ( self::keys( $els ) as $key ) { $el = $els[ $key ]; if ( ! is_array( $el ) || ! isset( $el['t'], $el['id'] ) || ! is_string( $el['t'] ) || ! isset( $widgets[ $el['t'] ] ) || ! preg_match( '/^[A-Za-z0-9_-]{1,24}$/', self::str( $el['id'] ) ) ) { continue; } $types[ $el['t'] ] = true; $root = self::element( $rules, $el, $widgets[ $el['t'] ], $ids, $fonts ); $s = isset( $el['s'] ) && is_array( $el['s'] ) ? $el['s'] : array(); if ( isset( $s['_tip'] ) && is_array( $s['_tip'] ) && self::truthy( $s['_tip']['text'] ?? null ) ) { $tip = true; } // Hide on device. if ( isset( $s['_hide'] ) && is_array( $s['_hide'] ) ) { foreach ( self::keys( $s['_hide'] ) as $bp ) { if ( self::truthy( $s['_hide'][ $bp ] ) && in_array( $bp, $ids, true ) ) { self::query( $rules, self::range_query( $bps, $bp ), $root . '{display:none!important}' ); } } } } $types = array_map( 'strval', array_keys( $types ) ); sort( $types, SORT_STRING ); return array( 'css' => self::base( $types, $tip ) . self::rules_css( $rules ) . self::page( isset( $doc['page'] ) && is_array( $doc['page'] ) ? $doc['page'] : array() ), 'fonts' => self::font_list( $fonts ), ); } /** Page settings: background and text color of the whole page. */ public static function page( array $page ) { $css = ''; foreach ( array( 'bg' => 'background', 'color' => 'color', ) as $key => $prop ) { if ( self::truthy( $page[ $key ] ?? null ) && self::value_ok( self::str( $page[ $key ] ) ) ) { $css .= 'body{' . $prop . ':' . self::str( $page[ $key ] ) . '}'; } } return $css; } /** * Global design system CSS: color/font/type tokens, theme element styles, global classes and the shared * runtime rules. Returns array( 'css' => string, 'fonts' => family => weights ). */ public static function globals( array $g, $bps = null ) { $bps = null === $bps ? Options::breakpoints() : $bps; $ids = array_column( $bps, 'id' ); $schema = self::schema(); $defaults = is_array( $schema['globals'] ?? null ) ? $schema['globals'] : array(); $theme = array_merge( is_array( $defaults['theme'] ?? null ) ? $defaults['theme'] : array(), isset( $g['theme'] ) && is_array( $g['theme'] ) ? $g['theme'] : array() ); $g = array_merge( $defaults, $g ); $fonts = array(); $rules = self::rules( $bps ); $root = ''; foreach ( self::lst( $g['colors'] ?? null ) as $c ) { if ( is_array( $c ) && self::truthy( $c['id'] ?? null ) && self::truthy( $c['value'] ?? null ) ) { $root .= self::decl( '--nx-c-' . self::slug( $c['id'] ), self::str( $c['value'] ) ); } } foreach ( self::lst( $g['fonts'] ?? null ) as $f ) { if ( ! is_array( $f ) || ! self::truthy( $f['id'] ?? null ) || ! self::truthy( $f['family'] ?? null ) ) { continue; } $family = preg_replace( '/["\';{}]/', '', self::str( $f['family'] ) ); $decl = self::decl( '--nx-f-' . self::slug( $f['id'] ), '"' . $family . '",' . ( self::truthy( $f['fallback'] ?? null ) ? self::str( $f['fallback'] ) : 'system-ui,-apple-system,"Segoe UI",sans-serif' ) ); if ( '' !== $decl ) { $root .= $decl; self::add_font( $fonts, $family, '400' ); } } $container = self::to_num( $theme['container'] ?? null ); $gap = self::to_num( $theme['gap'] ?? null ); $root .= '--nx-boxed:' . self::num( is_nan( $container ) || 0.0 === (float) $container ? 1200 : $container ) . 'px;' . '--nx-gap:' . self::num( is_nan( $gap ) || 0.0 === (float) $gap ? 20 : $gap ) . 'px;' . '--nx-r:' . self::num( self::to_num( $theme['radius'] ?? 16 ) ) . 'px;' . '--nx-btn-r:' . self::num( self::to_num( $theme['btnRadius'] ?? 10 ) ) . 'px;'; self::add( $rules, 'd', ':root', $root ); // Responsive type scale tokens. $types = self::lst( $g['type'] ?? null ); foreach ( $types as $t ) { if ( ! is_array( $t ) ) { continue; } $id = self::slug( $t['id'] ?? '' ); foreach ( $ids as $bp ) { $v = $t[ $bp ] ?? null; if ( ! is_array( $v ) || ! self::truthy( $v ) ) { continue; } $body = ''; foreach ( array_keys( self::TYPO_KEYS ) as $k ) { if ( array_key_exists( $k, $v ) && '' !== $v[ $k ] ) { $body .= self::decl( '--nx-t-' . $id . '-' . $k, 'ff' === $k ? self::font_var( $v[ $k ] ) : self::str( $v[ $k ] ) ); } } if ( self::truthy( $v['ff'] ?? null ) && self::font_var( $v['ff'] ) === self::str( $v['ff'] ) ) { self::add_font( $fonts, $v['ff'], $v['fw'] ?? null ); } if ( self::truthy( $v['fw'] ?? null ) && isset( $v['ff'] ) ) { foreach ( self::lst( $g['fonts'] ?? null ) as $f ) { if ( is_array( $f ) && isset( $f['id'], $f['family'] ) && $f['id'] === $v['ff'] ) { self::add_font( $fonts, $f['family'], $v['fw'] ); } } } self::add( $rules, $bp, ':root', $body ); } } // Typography preset utility classes (.nx-ts-*) and theme element styles. $scope = 'site' === ( $theme['apply'] ?? '' ) ? 'body' : self::SCOPE; $typo = static function ( $id ) { return "font-family:var(--nx-t-{$id}-ff);font-size:var(--nx-t-{$id}-fs);font-weight:var(--nx-t-{$id}-fw);line-height:var(--nx-t-{$id}-lh);letter-spacing:var(--nx-t-{$id}-ls,normal);text-transform:var(--nx-t-{$id}-tt,none);"; }; $color = static function ( $v, $fallback ) { return self::truthy( $v ) && self::value_ok( self::str( $v ) ) ? self::str( $v ) : $fallback; }; $bg = $color( $theme['bodyBg'] ?? '', '' ); $base = $scope . '{' . $typo( 'body' ) . 'color:' . $color( $theme['bodyColor'] ?? '', 'var(--nx-c-text)' ) . ';' . ( '' !== $bg ? 'background-color:' . $bg . ';' : '' ) . '-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizeLegibility}'; if ( 'site' !== ( $theme['apply'] ?? '' ) ) { $base .= self::SCOPE . '{box-sizing:border-box}' . self::SCOPE . ' *,' . self::SCOPE . ' *::before,' . self::SCOPE . ' *::after{box-sizing:inherit}'; } foreach ( array( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ) as $h ) { $base .= $scope . ' ' . $h . '{' . $typo( $h ) . 'color:inherit;margin-top:0}'; } $base .= $scope . ' p{margin-top:0}' . $scope . ' a{color:' . $color( $theme['link'] ?? '', 'var(--nx-c-primary)' ) . '}' . $scope . ' a:hover{color:' . $color( $theme['linkHover'] ?? '', 'var(--nx-c-secondary)' ) . '}' . $scope . ' img{max-width:100%;height:auto}'; $selection = $color( $theme['selection'] ?? '', '' ); if ( '' !== $selection ) { $base .= $scope . ' ::selection{background:' . $selection . ';color:#fff}'; } foreach ( $types as $t ) { if ( is_array( $t ) ) { $id = self::slug( $t['id'] ?? '' ); $base .= self::SCOPE . ' .nx-ts-' . $id . '{' . $typo( $id ) . '}'; } } // Global classes share the element style structure (root part only). foreach ( self::lst( $g['classes'] ?? null ) as $c ) { if ( ! is_array( $c ) || ! self::truthy( $c['slug'] ?? null ) || ! is_array( $c['st'] ?? null ) || ! preg_match( '/^[A-Za-z0-9_-]{1,60}$/', self::str( $c['slug'] ) ) ) { continue; } $st = self::truthy( $c['st']['root'] ?? null ) ? $c['st']['root'] : $c['st']; self::styles( $rules, array( 'root' => $st ), self::SCOPE . ' .nxc-' . self::str( $c['slug'] ), array(), $fonts ); } return array( 'css' => $base . self::rules_css( $rules ) . (string) ( $schema['foundation'] ?? '' ), 'fonts' => self::font_list( $fonts ), ); } /** * Strict CSS value check. Allowed: letters, digits, spaces and # % . , ( ) - + * / _ [ ] outside quoted * strings; balanced quotes and parentheses; only known CSS functions; url() addresses that are relative, * http(s) or image data. Rejected anywhere: ; { } < > \ @ ! ` control characters and comments. */ public static function value_ok( $v ) { if ( ! is_string( $v ) ) { return false; } if ( '' === $v ) { return true; } if ( strlen( $v ) > 32768 || preg_match( '/[{}<>\\\\@!`\x00-\x1f\x7f]|\/\*|\*\//', $v ) || ! preg_match( '//u', $v ) ) { return false; } if ( null === self::$functions ) { self::$functions = array_flip( preg_split( '/\s+/', trim( self::FUNCTIONS ) ) ); } $n = strlen( $v ); $stack = array(); $i = 0; while ( $i < $n ) { $ch = $v[ $i ]; if ( '"' === $ch || "'" === $ch ) { $end = strpos( $v, $ch, $i + 1 ); if ( false === $end || ( $stack && 'url' === end( $stack ) && ! self::url_ok( substr( $v, $i + 1, $end - $i - 1 ) ) ) ) { return false; } $i = $end + 1; continue; } if ( '(' === $ch ) { preg_match( '/[A-Za-z0-9-]*$/', substr( $v, 0, $i ), $m ); $name = strtolower( $m[0] ); if ( '' !== $name && ! isset( self::$functions[ $name ] ) ) { return false; } $stack[] = $name; ++$i; $rest = ltrim( substr( $v, $i ), ' ' ); if ( 'url' === $name && '' !== $rest && '"' !== $rest[0] && "'" !== $rest[0] ) { // Unquoted url(...): the address runs to the closing parenthesis. $end = strpos( $v, ')', $i ); if ( false === $end ) { return false; } $url = trim( substr( $v, $i, $end - $i ) ); if ( preg_match( '/[\s"\'(]/', $url ) || ! self::url_ok( $url ) ) { return false; } $i = $end; } continue; } if ( ')' === $ch ) { if ( ! $stack ) { return false; } array_pop( $stack ); ++$i; continue; } if ( preg_match( '/[A-Za-z0-9 #%.,\-+*\/_\[\]]/', $ch ) ) { ++$i; continue; } // Non-ASCII outside quotes: only letters, digits and combining marks (font names in any script). $o = ord( $ch ); if ( $o >= 0xC0 ) { $len = $o >= 0xF0 ? 4 : ( $o >= 0xE0 ? 3 : 2 ); if ( preg_match( '/^[\p{L}\p{N}\p{M}]$/u', substr( $v, $i, $len ) ) ) { $i += $len; continue; } } return false; } return ! $stack; } // --------------------------------------------------------------------- // Element styles // --------------------------------------------------------------------- /** Widget schema built from the editor engine (selectors, base CSS, control rules). */ private static function schema() { if ( null === self::$schema ) { $data = wp_json_file_decode( AKRAM_DIR . 'assets/build/css-schema.json', array( 'associative' => true ) ); self::$schema = is_array( $data ) ? $data : array(); self::$schema += array( 'widgets' => array(), 'tip' => array(), 'foundation' => '', 'globals' => array(), ); } return self::$schema; } /** One element's rules. Returns its root selector. */ private static function element( array &$rules, array $el, array $def, array $ids, array &$fonts ) { $root = self::SCOPE . ' .nx-e.nx-' . self::str( $el['id'] ); self::styles( $rules, $el['st'] ?? null, $root, is_array( $def['parts'] ?? null ) ? $def['parts'] : array(), $fonts ); $s = isset( $el['s'] ) && is_array( $el['s'] ) ? $el['s'] : array(); // Responsive settings exposed as CSS custom properties or small rules. foreach ( (array) ( $def['controls'] ?? array() ) as $c ) { if ( array_key_exists( $c['n'], $s ) ) { $v = $s[ $c['n'] ]; } elseif ( array_key_exists( 'd', $c ) ) { $v = $c['d']; } else { continue; } if ( '' === $v ) { continue; } foreach ( self::each_bp( $v ) as list( $bp, $val ) ) { if ( ! in_array( $bp, $ids, true ) ) { continue; } if ( isset( $c['v'] ) ) { $x = self::str( $val ); self::add( $rules, $bp, $root, self::decl( $c['v'], isset( $c['u'] ) && preg_match( '/^-?[\d.]+$/', $x ) ? $x . $c['u'] : $x ) ); } foreach ( isset( $c['r'] ) && is_array( $c['r'] ) ? $c['r'] : array() as $sel => $body ) { if ( false !== strpos( $body, '@{v}' ) ) { $x = self::str( $val ); if ( self::value_ok( $x ) ) { self::add( $rules, $bp, str_replace( '&', $root, $sel ), str_replace( '@{v}', $x, $body ) ); } } elseif ( self::truthy( $val ) && '0' !== $val ) { self::add( $rules, $bp, str_replace( '&', $root, $sel ), $body ); } } } } // The widget's own stylesheet, filled with its settings. if ( ! empty( $def['styles'] ) ) { $st = $def['styles']; $get = static function ( $k ) use ( $s, $st ) { if ( array_key_exists( $k, $s ) ) { return $s[ $k ]; } return array_key_exists( $k, (array) $st['d'] ) ? $st['d'][ $k ] : null; }; $bp = '' !== $st['bp'] ? $get( $st['bp'] ) : 'd'; if ( is_string( $bp ) && 'none' !== $bp && isset( $rules['by'][ $bp ] ) ) { $css = preg_replace_callback( '/@\{(\w+)\}/', static function ( $m ) use ( $get, $st ) { $v = $get( $m[1] ); $fallback = array_key_exists( $m[1], (array) $st['fb'] ) ? self::str( $st['fb'][ $m[1] ] ) : ''; $v = null === $v || '' === $v ? $fallback : self::str( $v ); return self::value_ok( $v ) ? $v : ( self::value_ok( $fallback ) ? $fallback : '' ); }, str_replace( '&', $root, $st['css'] ) ); $rules['by'][ $bp ] .= $css; } } return $root; } /** * Compile a style map: { part: { state: { bp: { prop: value } } } }. * $parts: { name: { sel: ' .child' | '&.mod' | 'a|b', hover: 'root' | '' } }. */ private static function styles( array &$rules, $st, $root, array $parts, array &$fonts ) { if ( ! is_array( $st ) ) { return; } foreach ( self::keys( $st ) as $part ) { $pdef = 'root' === $part ? array( 'sel' => '' ) : ( $parts[ $part ] ?? null ); if ( ! is_array( $pdef ) || ! is_array( $st[ $part ] ) ) { continue; } $states = $st[ $part ]; $hover_root = 'root' === ( $pdef['hover'] ?? '' ); $has_hover = false; if ( isset( $states['h'] ) && is_array( $states['h'] ) ) { foreach ( $states['h'] as $x ) { if ( ( is_array( $x ) && $x ) || ( is_string( $x ) && '' !== $x ) ) { $has_hover = true; break; } } } foreach ( self::keys( $states ) as $state ) { $by = $states[ $state ]; if ( ! in_array( $state, self::STATES, true ) || ! self::truthy( $by ) ) { continue; } $sel = array(); foreach ( explode( '|', (string) ( $pdef['sel'] ?? '' ) ) as $s ) { $sel[] = self::state_sel( $state, 0 === strpos( $s, '&' ) ? $root . substr( $s, 1 ) : $root . $s, $root, '' === $s, $hover_root ); } $sel = implode( ',', $sel ); $by = is_array( $by ) ? $by : array(); foreach ( self::keys( $by ) as $bp ) { $props = $by[ $bp ]; if ( ! self::truthy( $props ) ) { continue; } $props = is_array( $props ) ? $props : array(); $body = self::declarations( $props, $fonts ); if ( 'n' === $state && 'd' === $bp && $has_hover && ! array_key_exists( 'trans', $props ) ) { $body .= 'transition:all .3s ease;'; } self::add( $rules, $bp, $sel, $body ); } if ( 'n' === $state && $has_hover && ! self::truthy( $by['d'] ?? null ) ) { self::add( $rules, 'd', $sel, 'transition:all .3s ease;' ); } } } } private static function state_sel( $state, $sel, $root, $is_root, $hover_root ) { switch ( $state ) { case 'h': if ( $is_root ) { return $root . ':hover'; } return $hover_root ? self::replace_first( $sel, $root, $root . ':hover' ) : $sel . ':hover'; case 'f': return $sel . ':focus-visible'; case 'fw': return $sel . ':focus-within'; case 'a': return $sel . '.is-active'; case 'sk': return self::replace_first( $sel, $root, $root . '.nx-stuck' ); } return $sel; } /** Style-map props to declarations. Structured props (bg, shadow, tf…) expand to their CSS. */ private static function declarations( array $props, array &$fonts ) { $out = ''; foreach ( self::keys( $props ) as $k ) { $v = $props[ $k ]; $k = (string) $k; if ( null === $v || '' === $v ) { continue; } switch ( $k ) { case 'bg': $out .= self::bg( $v ); break; case 'shadow': $out .= self::decl( 'box-shadow', self::shadow( $v ) ); break; case 'textShadow': $a = is_array( $v ) ? $v : array(); $out .= self::decl( 'text-shadow', is_string( $v ) ? $v : self::str( $a['x'] ?? 0 ) . 'px ' . self::str( $a['y'] ?? 0 ) . 'px ' . self::str( $a['blur'] ?? 0 ) . 'px ' . ( self::truthy( $a['c'] ?? null ) ? self::str( $a['c'] ) : 'rgba(0,0,0,.3)' ) ); break; case 'tf': $out .= self::decl( 'transform', self::transform( $v ) ); break; case 'flt': $out .= self::decl( 'filter', self::filter( $v ) ); break; case 'bflt': $f = self::filter( $v ); $out .= self::decl( '-webkit-backdrop-filter', $f ) . self::decl( 'backdrop-filter', $f ); break; case 'typo': if ( preg_match( '/^[A-Za-z0-9_-]{1,60}$/', self::str( $v ) ) ) { foreach ( self::TYPO_KEYS as $t => $prop ) { $out .= $prop . ':var(--nx-t-' . self::str( $v ) . '-' . $t . ');'; } } break; case 'gridCols': case 'gridRows': $prop = 'gridCols' === $k ? 'grid-template-columns' : 'grid-template-rows'; $out .= self::decl( $prop, preg_match( '/^\d+$/', self::str( $v ) ) ? 'repeat(' . self::str( $v ) . ', minmax(0, ' . ( 'gridCols' === $k ? '1fr' : 'auto' ) . '))' : self::str( $v ) ); break; case 'colSpan': case 'rowSpan': $x = self::str( $v ); $out .= self::decl( 'colSpan' === $k ? 'grid-column' : 'grid-row', preg_match( '/^\d+$/', $x ) ? "span {$x} / span {$x}" : $x ); break; case 'trans': $out .= self::decl( 'transition', 'all ' . self::str( $v ) . 's ' . ( self::truthy( $props['ease'] ?? null ) ? self::str( $props['ease'] ) : 'ease' ) ); break; case 'ease': break; case 'textStroke': if ( is_array( $v ) && self::truthy( $v['w'] ?? null ) ) { $out .= self::decl( '-webkit-text-stroke', self::str( $v['w'] ) . 'px ' . ( self::truthy( $v['c'] ?? null ) ? self::str( $v['c'] ) : 'currentColor' ) ); } break; case 'gradText': $g = self::decl( 'background-image', self::gradient( $v ) ); if ( '' !== $g ) { $out .= $g . '-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;'; } break; case 'clamp': if ( self::value_ok( self::str( $v ) ) ) { $out .= 'display:-webkit-box;-webkit-line-clamp:' . self::str( $v ) . ';-webkit-box-orient:vertical;overflow:hidden;'; } break; case 'vars': if ( is_array( $v ) ) { foreach ( self::keys( $v ) as $name ) { if ( preg_match( '/^[A-Za-z0-9_-]{1,64}$/', (string) $name ) ) { $out .= self::decl( '--' . $name, self::str( $v[ $name ] ) ); } } } break; case 'fontFamily': $f = self::str( $v ); $val = ( preg_match( '/[\s,]/', $f ) && ! preg_match( '/^["\'v]/', $f ) && false === strpos( $f, ',' ) ? '"' . $f . '"' : $f ) . ( preg_match( self::SYSTEM_FONTS, $f ) || false !== strpos( $f, ',' ) ? '' : ', sans-serif' ); $d = self::decl( 'font-family', $val ); if ( '' !== $d ) { $out .= $d; if ( ! preg_match( self::SYSTEM_FONTS, $f ) ) { self::add_font( $fonts, $f, $props['fontWeight'] ?? null ); } } break; default: if ( self::is_obj( $v ) ) { break; } $prop = strtolower( preg_replace( '/[A-Z]/', '-$0', $k ) ); if ( null === self::$props ) { self::$props = array_flip( preg_split( '/\s+/', trim( self::PROPERTIES ) ) ); } if ( isset( self::$props[ $prop ] ) && preg_match( '/^[a-z][A-Za-z]*$/', $k ) ) { $out .= self::decl( $prop, ( is_int( $v ) || is_float( $v ) ) && ! in_array( $k, self::UNITLESS, true ) && 0 != $v ? self::num( $v ) . 'px' : self::str( $v ) ); // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual -- numeric zero of either type. } } } return $out; } private static function bg( $b ) { if ( is_string( $b ) ) { return self::decl( 'background', $b ); } if ( ! is_array( $b ) || ! $b ) { return ''; } $out = ''; if ( self::truthy( $b['color'] ?? null ) ) { $out .= self::decl( 'background-color', self::str( $b['color'] ) ); } $layers = array(); $sizes = array(); $pos = array(); $rep = array(); if ( self::truthy( $b['grad'] ?? null ) ) { $layers[] = self::gradient( $b['grad'] ); $sizes[] = 'auto'; $pos[] = '0 0'; $rep[] = 'no-repeat'; } $img = $b['img'] ?? null; if ( is_array( $img ) && self::truthy( $img['url'] ?? null ) ) { $layers[] = 'url("' . str_replace( '"', '%22', self::str( $img['url'] ) ) . '")'; $sizes[] = self::truthy( $img['size'] ?? null ) ? self::str( $img['size'] ) : 'cover'; $pos[] = self::truthy( $img['pos'] ?? null ) ? self::str( $img['pos'] ) : 'center center'; $rep[] = self::truthy( $img['repeat'] ?? null ) ? self::str( $img['repeat'] ) : 'no-repeat'; if ( self::truthy( $img['attach'] ?? null ) && 'scroll' !== $img['attach'] ) { $out .= self::decl( 'background-attachment', ( count( $layers ) > 1 ? 'scroll, ' : '' ) . self::str( $img['attach'] ) ); } } if ( $layers ) { $group = array( 'background-image' => implode( ', ', $layers ), 'background-size' => implode( ', ', $sizes ), 'background-position' => implode( ', ', $pos ), 'background-repeat' => implode( ', ', $rep ), ); if ( count( array_filter( array_map( array( __CLASS__, 'value_ok' ), $group ) ) ) === 4 ) { foreach ( $group as $prop => $val ) { $out .= $prop . ':' . $val . ';'; } } } elseif ( self::truthy( $b['none'] ?? null ) ) { $out .= 'background-image:none;'; } return $out; } private static function gradient( $g ) { if ( is_string( $g ) ) { return $g; } $g = is_array( $g ) ? $g : array(); $stops = array(); foreach ( self::lst( $g['stops'] ?? null ) as $s ) { $s = is_array( $s ) ? $s : array(); $stops[] = ( array_key_exists( 'c', $s ) ? self::str( $s['c'] ) : 'undefined' ) . ' ' . self::str( $s['p'] ?? 0 ) . '%'; } $stops = implode( ', ', $stops ); $at = self::truthy( $g['pos'] ?? null ) ? self::str( $g['pos'] ) : 'center'; $type = $g['type'] ?? ''; if ( 'radial' === $type ) { return 'radial-gradient(' . ( self::truthy( $g['shape'] ?? null ) ? self::str( $g['shape'] ) : 'circle' ) . ' at ' . $at . ', ' . $stops . ')'; } if ( 'conic' === $type ) { return 'conic-gradient(from ' . self::str( $g['angle'] ?? 0 ) . 'deg at ' . $at . ', ' . $stops . ')'; } return 'linear-gradient(' . self::str( $g['angle'] ?? 180 ) . 'deg, ' . $stops . ')'; } private static function shadow( $list ) { $arr = is_array( $list ) && ( array() === $list || ! self::is_obj( $list ) ) ? $list : array( $list ); $out = array(); foreach ( $arr as $s ) { if ( ! self::truthy( $s ) ) { continue; } if ( is_string( $s ) ) { $out[] = $s; continue; } $s = is_array( $s ) ? $s : array(); $out[] = ( self::truthy( $s['inset'] ?? null ) ? 'inset ' : '' ) . self::str( $s['x'] ?? 0 ) . 'px ' . self::str( $s['y'] ?? 0 ) . 'px ' . self::str( $s['blur'] ?? 0 ) . 'px ' . self::str( $s['spread'] ?? 0 ) . 'px ' . ( self::truthy( $s['c'] ?? null ) ? self::str( $s['c'] ) : 'rgba(0,0,0,.15)' ); } return $out ? implode( ', ', $out ) : 'none'; } private static function transform( $t ) { if ( is_string( $t ) ) { return $t; } $t = is_array( $t ) ? $t : array(); $p = array(); $on = static function ( $k ) use ( $t ) { return self::truthy( $t[ $k ] ?? null ); }; if ( $on( 'persp' ) ) { $p[] = 'perspective(' . self::str( $t['persp'] ) . 'px)'; } if ( $on( 'x' ) || $on( 'y' ) ) { $p[] = 'translate(' . ( $on( 'x' ) ? self::str( $t['x'] ) : '0' ) . ', ' . ( $on( 'y' ) ? self::str( $t['y'] ) : '0' ) . ')'; } foreach ( array( 'r' => 'rotate', 'rx' => 'rotateX', 'ry' => 'rotateY', ) as $k => $fn ) { if ( $on( $k ) ) { $p[] = $fn . '(' . self::str( $t[ $k ] ) . 'deg)'; } } if ( array_key_exists( 's', $t ) && '' !== $t['s'] && 1.0 !== (float) self::to_num( $t['s'] ) ) { $p[] = 'scale(' . self::str( $t['s'] ) . ')'; } if ( $on( 'kx' ) || $on( 'ky' ) ) { $p[] = 'skew(' . ( $on( 'kx' ) ? self::str( $t['kx'] ) : '0' ) . 'deg, ' . ( $on( 'ky' ) ? self::str( $t['ky'] ) : '0' ) . 'deg)'; } return $p ? implode( ' ', $p ) : 'none'; } private static function filter( $f ) { if ( is_string( $f ) ) { return $f; } $f = is_array( $f ) ? $f : array(); $p = array(); $on = static function ( $k ) use ( $f ) { return self::truthy( $f[ $k ] ?? null ); }; // Brightness, contrast and saturation default to 100%: written only when set to something else. $pct = static function ( $k ) use ( $f ) { return array_key_exists( $k, $f ) && '' !== $f[ $k ] && 100.0 !== (float) self::to_num( $f[ $k ] ); }; if ( $on( 'blur' ) ) { $p[] = 'blur(' . self::str( $f['blur'] ) . 'px)'; } foreach ( array( 'br' => 'brightness', 'ct' => 'contrast', 'sat' => 'saturate', ) as $k => $fn ) { if ( $pct( $k ) ) { $p[] = $fn . '(' . self::str( $f[ $k ] ) . '%)'; } } foreach ( array( 'gs' => 'grayscale(%s%%)', 'hue' => 'hue-rotate(%sdeg)', 'inv' => 'invert(%s%%)', 'sep' => 'sepia(%s%%)', ) as $k => $fmt ) { if ( $on( $k ) ) { $p[] = sprintf( $fmt, self::str( $f[ $k ] ) ); } } return $p ? implode( ' ', $p ) : 'none'; } private static function font_var( $ff ) { $s = self::str( $ff ); return self::truthy( $ff ) && preg_match( '/^(heading|body|[a-z0-9_-]+)$/', $s ) ? 'var(--nx-f-' . $s . ')' : $s; } private static function add_font( array &$fonts, $family, $weight ) { $family = trim( explode( ',', str_replace( array( '"', "'" ), '', self::str( $family ) ) )[0] ); if ( '' === $family ) { return; } if ( ! isset( $fonts[ $family ] ) ) { $fonts[ $family ] = array( '400' => true ); } if ( self::truthy( $weight ) && preg_match( '/^\d+$/', self::str( $weight ) ) ) { $fonts[ $family ][ self::str( $weight ) ] = true; } } private static function font_list( array $fonts ) { $out = array(); foreach ( $fonts as $family => $weights ) { $w = array_map( 'strval', array_keys( $weights ) ); sort( $w, SORT_STRING ); $out[ (string) $family ] = $w; } return $out; } /** Base CSS of the widget types used (rules shared by several widgets are written once). */ private static function base( array $types, $tip ) { $schema = self::schema(); $seen = array(); $out = ''; $lists = array(); foreach ( $types as $t ) { $lists[] = (array) ( $schema['widgets'][ $t ]['base'] ?? array() ); } if ( $tip ) { $lists[] = (array) $schema['tip']; } foreach ( $lists as $list ) { foreach ( $list as $rule ) { if ( ! isset( $seen[ $rule ] ) ) { $seen[ $rule ] = true; $out .= $rule; } } } return $out; } // --------------------------------------------------------------------- // Rule sets: one block per breakpoint, desktop first // --------------------------------------------------------------------- private static function rules( array $bps ) { $r = array( 'bps' => $bps, 'by' => array(), 'raw' => '', 'extra' => array(), ); foreach ( $bps as $b ) { $r['by'][ $b['id'] ] = ''; } return $r; } private static function add( array &$r, $bp, $sel, $body ) { if ( '' !== $body && is_string( $bp ) && isset( $r['by'][ $bp ] ) ) { $r['by'][ $bp ] .= $sel . '{' . $body . '}'; } } private static function query( array &$r, $query, $css ) { if ( null === $query || '' === $query ) { $r['raw'] .= $css; return; } $r['extra'][ $query ] = ( $r['extra'][ $query ] ?? '' ) . $css; } private static function rules_css( array $r ) { $out = $r['raw']; foreach ( $r['bps'] as $b ) { $body = $r['by'][ $b['id'] ]; if ( '' !== $body ) { $out .= ! empty( $b['max'] ) ? '@media (max-width:' . self::num( $b['max'] ) . 'px){' . $body . '}' : $body; } } foreach ( $r['extra'] as $q => $css ) { $out .= $q . '{' . $css . '}'; } return $out; } /** Exclusive device range (used for "hide on device"). */ private static function range_query( array $bps, $id ) { $bps = array_values( $bps ); foreach ( $bps as $i => $bp ) { if ( $bp['id'] !== $id ) { continue; } $parts = array(); if ( ! empty( $bp['max'] ) ) { $parts[] = '(max-width:' . self::num( $bp['max'] ) . 'px)'; } if ( isset( $bps[ $i + 1 ] ) ) { $parts[] = '(min-width:' . self::num( (int) $bps[ $i + 1 ]['max'] + 1 ) . 'px)'; } return $parts ? '@media ' . implode( ' and ', $parts ) : ''; } return null; } // --------------------------------------------------------------------- // Values // --------------------------------------------------------------------- /** "prop:value;" when the value passes the check, else nothing. */ private static function decl( $prop, $value ) { return self::value_ok( $value ) ? $prop . ':' . $value . ';' : ''; } private static function url_ok( $url ) { $url = trim( $url ); if ( '' === $url ) { return false; } if ( preg_match( '#^data:#i', $url ) ) { return (bool) preg_match( '#^data:image/(png|jpe?g|gif|webp|avif|svg\+xml)[;,]#i', $url ); } if ( preg_match( '#^[a-z][a-z0-9+.\-]*:#i', $url ) ) { return (bool) preg_match( '#^https?://#i', $url ); } return true; } /** Responsive value: a scalar, or { d, l, t, m } per breakpoint. Returns list( bp, value ) pairs. */ private static function each_bp( $v ) { $out = array(); if ( self::is_obj( $v ) && array_intersect( array( 'd', 'l', 't', 'm' ), array_map( 'strval', array_keys( $v ) ) ) ) { foreach ( self::keys( $v ) as $bp ) { if ( '' !== $v[ $bp ] && null !== $v[ $bp ] ) { $out[] = array( $bp, $v[ $bp ] ); } } } elseif ( '' !== $v && null !== $v ) { $out[] = array( 'd', $v ); } return $out; } /** Same as the editor's slug(): lowercase ASCII letters, digits and dashes. */ private static function slug( $s ) { $s = self::truthy( $s ) ? self::str( $s ) : ''; $s = function_exists( 'mb_strtolower' ) ? mb_strtolower( $s, 'UTF-8' ) : strtolower( $s ); if ( class_exists( 'Normalizer' ) ) { $s = (string) \Normalizer::normalize( $s, \Normalizer::FORM_KD ); } elseif ( function_exists( 'remove_accents' ) ) { $s = remove_accents( $s ); } $s = trim( (string) preg_replace( '/[^A-Za-z0-9_\s-]/', '', $s ) ); return substr( (string) preg_replace( '/[\s_]+/', '-', $s ), 0, 60 ); } // --------------------------------------------------------------------- // JSON data read with JavaScript semantics, so the result matches the editor exactly // --------------------------------------------------------------------- /** Truthiness as in JavaScript: objects and arrays are true, "0" is true, 0 and "" are false. */ private static function truthy( $v ) { if ( is_array( $v ) ) { return true; } if ( is_string( $v ) ) { return '' !== $v; } if ( is_float( $v ) ) { return 0.0 !== $v && ! is_nan( $v ); } return (bool) $v; } /** A JSON object (not a list). Empty arrays count as objects. */ private static function is_obj( $v ) { return is_array( $v ) && ( array() === $v || array_keys( $v ) !== range( 0, count( $v ) - 1 ) ); } private static function lst( $v ) { return is_array( $v ) && ! self::is_obj( $v ) ? $v : array(); } /** Object keys in JavaScript order: integer keys ascending, then the rest in insertion order. */ private static function keys( array $a ) { $ints = array(); $rest = array(); foreach ( array_keys( $a ) as $k ) { if ( is_int( $k ) && $k >= 0 && $k < 4294967295 ) { $ints[] = $k; } else { $rest[] = $k; } } sort( $ints, SORT_NUMERIC ); return array_merge( $ints, $rest ); } /** String(v) as in JavaScript. */ private static function str( $v ) { if ( is_string( $v ) ) { return $v; } if ( is_bool( $v ) ) { return $v ? 'true' : 'false'; } if ( is_int( $v ) || is_float( $v ) ) { return self::num( $v ); } if ( null === $v ) { return 'null'; } if ( is_array( $v ) ) { if ( self::is_obj( $v ) && array() !== $v ) { return '[object Object]'; } return implode( ',', array_map( static function ( $x ) { return null === $x ? '' : self::str( $x ); }, $v ) ); } return ''; } /** Unary plus as in JavaScript (numbers, numeric strings; anything else is NaN). */ private static function to_num( $v ) { if ( is_int( $v ) || is_float( $v ) ) { return (float) $v; } if ( is_bool( $v ) || null === $v ) { return $v ? 1.0 : 0.0; } if ( ! is_string( $v ) ) { return NAN; } $v = trim( $v, " \t\n\r\x0B\f" ); if ( '' === $v ) { return 0.0; } return preg_match( '/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/', $v ) ? (float) $v : NAN; } /** Number to string as in JavaScript (shortest round-trip digits, no trailing ".0"). */ private static function num( $n ) { if ( is_int( $n ) ) { return (string) $n; } $n = (float) $n; if ( is_nan( $n ) ) { return 'NaN'; } if ( is_infinite( $n ) ) { return $n > 0 ? 'Infinity' : '-Infinity'; } if ( 0.0 === $n ) { return '0'; } if ( floor( $n ) === $n && abs( $n ) < 1e21 ) { return sprintf( '%.0f', $n ); } for ( $p = 0; $p < 17; $p++ ) { $e = sprintf( '%.' . $p . 'e', abs( $n ) ); if ( (float) $e === abs( $n ) ) { break; } } list( $mantissa, $exp ) = explode( 'e', $e ); $digits = rtrim( str_replace( '.', '', $mantissa ), '0' ); $len = strlen( $digits ); $point = (int) $exp + 1; if ( $len <= $point && $point <= 21 ) { $s = $digits . str_repeat( '0', $point - $len ); } elseif ( 0 < $point && $point <= 21 ) { $s = substr( $digits, 0, $point ) . '.' . substr( $digits, $point ); } elseif ( -6 < $point && $point <= 0 ) { $s = '0.' . str_repeat( '0', -$point ) . $digits; } else { $s = $digits[0] . ( $len > 1 ? '.' . substr( $digits, 1 ) : '' ) . 'e' . ( $point - 1 >= 0 ? '+' : '-' ) . abs( $point - 1 ); } return ( $n < 0 ? '-' : '' ) . $s; } private static function replace_first( $s, $find, $replace ) { $p = strpos( $s, $find ); return false === $p ? $s : substr_replace( $s, $replace, $p, strlen( $find ) ); } }