CSS" transformer for the atomic style system, so a block's * local class and a reusable global class compile identically. * * A bucket is one set of style props for a given (breakpoint x interaction * state). Where the bucket lives is StyleBuckets' concern; which props exist * and what CSS they become is StylesSchema's. This class only turns one * bucket's props into declarations, and walks the tree to build rules. */ class AtomicStyles { /** * Revision of the CSS this compiler emits. Bump it whenever the emitted * output changes for the same stored styles. * * Front-end pages do not compile per request: each page is baked once * into an uploads stylesheet, and Assets::build_revision() decides when * that file is stale. It was keyed on ABLOCKS_VERSION alone, so an * emission change with no version bump never reached pages baked before * it. The centring-margin fix (emitted_value() / flex_child_css()) showed * up in the editor, which compiles live, while every existing page kept * serving the old `margin-left:auto` and its wide flex-row gap. * * 2: centring margins resolved per parent layout. * 3: a bucket that sets a border width/colour but no type keeps the type it * inherits instead of `solid`. */ const OUTPUT_REVISION = 3; /** * Compile a full bucket tree for one selector base into CSS. * * The stored state key IS the pseudo-selector, so it is appended directly; * the breakpoint comes from the bucket's device via the one media-query * builder. Buckets emit widest-first, which is what makes a narrower * breakpoint win in cascade mode. */ public static function compile_variants( $selector_base, $styles ) { return self::rules_to_css( self::compile_rules( $styles ), $selector_base ); } /** * Compile a bucket tree into a normalised rule list: * * [ [ media-query, state-selector, [ [ prop, value ], … ] ], … ] * * Values are cast to strings and the structure is a plain list, so the JSON * encoding is byte-identical to the JS mirror's `JSON.stringify` — that is * what makes the style hash reproducible across the editor and the front end. * * `$alignment` (the block's own alignment attribute, which lives outside the * bucket tree and is still device-suffixed) folds into each device's normal * state. It has to participate in the hash: two blocks with identical styles * but different alignment are not interchangeable. */ public static function compile_rules( $styles, $alignment = [] ) { $rules = []; $devices = Helper::get_responsive_devices(); $has_styles = StyleBuckets::has_schema_version( $styles ); $devices = array_values( $devices ); foreach ( $devices as $index => $device ) { $media = Helper::breakpoint_media_query( $device ); $bucket_key = StyleBuckets::device_bucket_key( $device ); $cascade = self::cascade_devices( $devices, $index ); foreach ( StyleBuckets::state_keys() as $state ) { $declarations = []; if ( $has_styles ) { $props = StyleBuckets::read_bucket( $styles, $bucket_key, $state ); if ( ! empty( $props ) ) { // The border type in force from the rest of the cascade, // so a bucket that only changes width/colour keeps it // rather than falling back to `solid`. $inherited_style = StyleBuckets::inherited_prop( $styles, $bucket_key, $state, 'borderStyle', $cascade ); $declarations = self::apply_background_reset( self::state_declarations( $props, is_scalar( $inherited_style ) ? (string) $inherited_style : '' ), '' === $state && '' === $bucket_key ); } } // Alignment applies to the normal state, last so it beats a // `textAlign` style prop set at the same level. if ( '' === $state && ! empty( $alignment ) ) { $declarations = array_merge( $declarations, Alignment::get_css( $alignment, 'text-align', $device['suffix'] ) ); } if ( empty( $declarations ) ) { continue; } $pairs = []; foreach ( $declarations as $property => $value ) { if ( '' === $value || null === $value ) { continue; } $pairs[] = [ (string) $property, (string) $value ]; } if ( ! empty( $pairs ) ) { $rules[] = [ $media, $state, $pairs ]; } } } return $rules; } /** * The devices a device's rules cascade from on the page: those that also * apply at its width and are emitted before it, widest-first, ending at the * device itself. Mirror of the JS `cascadeDevices()` (atomic-shared/hash.js), * which builds it from `devicesApplyingAt( representativeWidth() )`. * * @param array $devices Ordered device list (widest-first, base first). * @param int $index The device's position in it. * @return array Devices to inherit from. */ private static function cascade_devices( $devices, $index ) { $own = (array) $devices[ $index ]; // A device's representative width: its upper bound, else its lower // bound, else "very wide" for the base device. $width = ! empty( $own['max'] ) ? (int) $own['max'] : ( ! empty( $own['min'] ) ? (int) $own['min'] : 99999 ); $out = []; foreach ( array_slice( $devices, 0, $index + 1 ) as $device ) { list( $min, $max ) = Helper::breakpoint_bounds( $device ); if ( ( $min > 0 && $width < $min ) || ( $max > 0 && $width > $max ) ) { continue; } $out[] = $device; } return $out; } /** * FNV-1a 32-bit over the normalised rule list. * * Deliberately not md5: the editor has to compute the identical hash at save * time, and FNV-1a is a handful of lines in both languages rather than a * crypto dependency in the editor bundle. */ public static function style_hash( $rules ) { $json = wp_json_encode( $rules, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $hash = 2166136261; $len = strlen( $json ); for ( $i = 0; $i < $len; $i++ ) { $hash ^= ord( $json[ $i ] ); $hash = ( $hash * 16777619 ) & 0xFFFFFFFF; } return str_pad( dechex( $hash ), 8, '0', STR_PAD_LEFT ); } /** The shared style class for a bucket tree, or '' when it compiles to nothing. */ public static function style_class( $styles, $alignment = [] ) { $rules = self::compile_rules( $styles, $alignment ); return empty( $rules ) ? '' : 'ablocks-s-' . self::style_hash( $rules ); } /** Hashes already emitted this request, so each rule set is written once. */ private static $emitted = []; /** Forget what has been emitted (test/CLI helper). */ public static function reset_emitted() { self::$emitted = []; } /** * Register a block's compiled styles and return its shared class plus the * CSS that still needs emitting — empty on every block after the first with * the same rule set, which is where the duplicate-CSS reduction comes from. * * The class is repeated in the selector (0-2-0) so a block's own styles beat * an applied global class (0-1-0) without resorting to `!important`, which * would make global classes unable to override anything. */ public static function register_styles( $styles, $alignment = [] ) { $rules = self::compile_rules( $styles, $alignment ); if ( empty( $rules ) ) { return [ 'class' => '', 'css' => '' ]; } $hash = self::style_hash( $rules ); $class = 'ablocks-s-' . $hash; if ( isset( self::$emitted[ $hash ] ) ) { return [ 'class' => $class, 'css' => '' ]; } self::$emitted[ $hash ] = true; return [ 'class' => $class, 'css' => self::rules_to_css( $rules, '.' . $class . '.' . $class ) ]; } /** * Point a block's saved markup at its current style class. * * The editor stamps `ablocks-s-{hash}` into the markup at save time, but the * front end emits rules for the hash of what the compiler produces NOW. When * the compiled output changes for the same stored styles (OUTPUT_REVISION), * a post saved before carries a class no rule targets any more and the * block renders unstyled until someone re-saves it. Only the block's own * opening tag is touched — inner blocks are rendered, and fixed, on their * own — and only when it lacks the current class. * * @param string $content The block's saved markup. * @param array $styles The block's styles attribute. * @param array $alignment The block's alignment attribute. * @return string The markup, with a stale style class replaced. */ public static function refresh_style_class( $content, $styles, $alignment = [] ) { if ( ! is_string( $content ) || false === strpos( $content, 'ablocks-s-' ) ) { return $content; } if ( ! preg_match( '/^\s*<[a-zA-Z][^>]*>/', $content, $tag ) ) { return $content; } $open = $tag[0]; if ( ! preg_match( '/(?<=[\s"\'])ablocks-s-[0-9a-z]+(?=[\s"\'])/', $open, $stale ) ) { return $content; } $current = self::style_class( $styles, $alignment ); if ( '' === $current || preg_match( '/(?<=[\s"\'])' . preg_quote( $current, '/' ) . '(?=[\s"\'])/', $open ) ) { return $content; } $fixed = preg_replace( '/(?<=[\s"\'])' . preg_quote( $stale[0], '/' ) . '(?=[\s"\'])/', $current, $open, 1 ); return substr_replace( $content, $fixed, strpos( $content, $open ), strlen( $open ) ); } /** Render a normalised rule list against a selector base. */ public static function rules_to_css( $rules, $selector_base ) { $css = ''; foreach ( $rules as $rule ) { list( $media, $state, $pairs ) = $rule; $declarations = ''; foreach ( $pairs as $pair ) { // Escaped here rather than at the schema, so every kind of prop // — scalar, range, colour, typography, effect, overlay — passes // through one guard on its way out. This runs after style_hash() // has already read $rules, so the hash the editor writes into // the markup is unaffected. $declarations .= Helper::esc_css_value( $pair[0] ) . ':' . Helper::esc_css_value( self::emitted_value( $pair[0], $pair[1] ) ) . ';'; } $body = $selector_base . $state . '{' . $declarations . '}'; // A wrapping container's own children must size from their content, // or the line can never be over-subscribed and `flex-wrap` never // breaks one. That is a statement about THIS container's children, // so it is emitted as a child rule here rather than as an inherited // custom property: a custom property inherits down the whole tree, // so a wrapping container silently re-sized the children of every // non-wrapping container nested inside it — measured, a // non-wrapping inner container's children came out `flex-basis: // auto` (content-sized) instead of `0%` (equal share). // // Derived from the pairs rather than stored, so it costs nothing in // the bucket tree, and — because this runs after style_hash() has // read $rules — the hash in already-saved markup is unaffected. $body .= self::wrap_child_css( $pairs, $selector_base . $state ); $body .= self::flex_child_css( $pairs, $selector_base . $state ); $css .= ( '' !== $media ) ? $media . '{' . $body . '}' : $body; } return $css; } /** * The value a declaration is emitted with, which is its compiled value * except for the centring guard's auto margins (see state_declarations()). * * Those centre a width-capped box in normal flow, but in a flex row an * auto margin swallows the free space on the main axis and overrides the * parent's justify-content: two 180px children of a centred, 24px-gap row * came out ~116px apart, the leftover space split into all four margins. * Emitted through a custom property instead, which the parent resolves * for its own children (flex_child_css()) and which falls back to the * same `auto` everywhere else. An `auto` here can only be the guard's: the * Dimensions control always appends a unit, and the guard stands down * when the author set a horizontal margin. * * Rewritten at emission, after style_hash() has read the pairs, so the * class in already-saved markup is unaffected. * * Mirrors emittedValue() in atomic-shared/styles.js. * * @param string $property The CSS property. * @param string $value The compiled value. * @return string The value to emit. */ public static function emitted_value( $property, $value ) { if ( 'auto' === $value && ( 'margin-left' === $property || 'margin-right' === $property ) ) { return 'var(--ablocks-center-margin,auto)'; } return $value; } /** * The child rule a bucket that sets `display` or `flex-direction` needs, * or ''. Resolves --ablocks-center-margin (see emitted_value()) for this * container's own children: 0 across a flex row's main axis, the `auto` * fallback in a flex column (where horizontal is the cross axis and * centring cannot open a gap) and in any other display. * * Display and direction are separate variables because they may come * from different breakpoints — a row at Desktop turned into a column at * Tablet only compiles `flex-direction` there — and the cascade has to * combine them. The inner var() is substituted on the child itself, so * it reads the direction set for that same child. * * Custom properties inherit, so the grandchildren are reset to the * guaranteed-invalid value; `:where()` keeps that at 0-0-0, below any * nested container's own child rule. Grid is left alone on purpose: an * auto margin there centres an item in its own cell and opens no gap. * * Mirrors flexChildCss() in atomic-shared/styles.js. * * @param array $pairs The bucket's declaration pairs. * @param string $selector The already-composed selector for this bucket. * @return string A CSS rule, or ''. */ public static function flex_child_css( $pairs, $selector ) { $declarations = ''; foreach ( $pairs as $pair ) { if ( 'display' === $pair[0] ) { $declarations .= ( 'flex' === $pair[1] || 'inline-flex' === $pair[1] ) ? '--ablocks-center-margin:var(--ablocks-column-margin,0);' : '--ablocks-center-margin:initial;'; } elseif ( 'flex-direction' === $pair[0] ) { $declarations .= ( 'column' === $pair[1] || 'column-reverse' === $pair[1] ) ? '--ablocks-column-margin:auto;' : '--ablocks-column-margin:initial;'; } } if ( '' === $declarations ) { return ''; } return $selector . '>*{' . $declarations . '}' . ':where(' . $selector . '>*>*){--ablocks-center-margin:initial;--ablocks-column-margin:initial;}'; } /** * The child rule a wrapping container needs, or ''. * * Scoped to container children only, matching the base stylesheets — a leaf * block is sized by its own block, not by the row it sits in. `:where()` * keeps the selector at the same specificity as those base rules, and this * so // authored CSS can't break out of the inline