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