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 {
/**
* 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 );
foreach ( $devices as $device ) {
$media = Helper::breakpoint_media_query( $device );
$bucket_key = StyleBuckets::device_bucket_key( $device );
foreach ( StyleBuckets::state_keys() as $state ) {
$declarations = [];
if ( $has_styles ) {
$props = StyleBuckets::read_bucket( $styles, $bucket_key, $state );
if ( ! empty( $props ) ) {
$declarations = self::apply_background_reset(
self::state_declarations( $props ),
'' === $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;
}
/**
* 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 ) ];
}
/** 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( $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 );
$css .= ( '' !== $media ) ? $media . '{' . $body . '}' : $body;
}
return $css;
}
/**
* 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