| 1 |
<?php |
| 2 |
|
| 3 |
namespace Elementor\Modules\Mcp\Abilities\Utils; |
| 4 |
|
| 5 |
use Elementor\Modules\AtomicWidgets\CssConverter\Css_Converter; |
| 6 |
use Elementor\Modules\AtomicWidgets\CssConverter\Css_Media_Splitter; |
| 7 |
|
| 8 |
if ( ! defined( 'ABSPATH' ) ) { |
| 9 |
exit; |
| 10 |
} |
| 11 |
|
| 12 |
class Style_Variants_Merger { |
| 13 |
|
| 14 |
const PSEUDO_STATES = [ 'hover', 'focus', 'active' ]; |
| 15 |
|
| 16 |
/** |
| 17 |
* Matches a property declaration `name: null;` only when it is not inside |
| 18 |
* a quoted string. The boundary (^|[;{]) ensures we only capture top-level |
| 19 |
* declarations, not occurrences inside values like content: "color: null;". |
| 20 |
* The boundary char is captured so it can be preserved in replacements. |
| 21 |
*/ |
| 22 |
const NULL_DECLARATION_PATTERN = '/(^|[;{])\s*([a-zA-Z][a-zA-Z0-9-]*)\s*:\s*null\s*;?/'; |
| 23 |
|
| 24 |
/** |
| 25 |
* $get_converter is a factory callable resolved lazily — only called if the CSS split succeeds. |
| 26 |
*/ |
| 27 |
public static function parse_css_string( |
| 28 |
string $css_string, |
| 29 |
array $active_breakpoints, |
| 30 |
int $op_index, |
| 31 |
string $op_action, |
| 32 |
Bulk_Operations_Result $results, |
| 33 |
callable $get_converter |
| 34 |
): ?array { |
| 35 |
$splitter = new Css_Media_Splitter( $active_breakpoints ); |
| 36 |
$split = $splitter->split( $css_string ); |
| 37 |
|
| 38 |
if ( null !== $split['error'] ) { |
| 39 |
$results->add_error( |
| 40 |
$op_index, |
| 41 |
$op_action, |
| 42 |
'invalid_css', |
| 43 |
$split['error'] . sprintf( ' Valid breakpoints: %s.', implode( ', ', $active_breakpoints ) ) |
| 44 |
); |
| 45 |
return null; |
| 46 |
} |
| 47 |
|
| 48 |
$css_converter = $get_converter(); |
| 49 |
$breakpoint_blocks = []; |
| 50 |
$removal_breakpoints = []; |
| 51 |
|
| 52 |
foreach ( $split['breakpoints'] as $breakpoint => $css ) { |
| 53 |
if ( '' === trim( $css ) ) { |
| 54 |
$removal_breakpoints[] = $breakpoint; |
| 55 |
continue; |
| 56 |
} |
| 57 |
|
| 58 |
$result = $css_converter->parse_nested( $css ); |
| 59 |
|
| 60 |
if ( isset( $result['error'] ) ) { |
| 61 |
$results->add_error( $op_index, $op_action, 'invalid_css', $result['error'] ); |
| 62 |
return null; |
| 63 |
} |
| 64 |
|
| 65 |
$breakpoint_blocks[] = [ |
| 66 |
'breakpoint' => $breakpoint, |
| 67 |
'blocks' => $result['blocks'], |
| 68 |
]; |
| 69 |
} |
| 70 |
|
| 71 |
return [ |
| 72 |
'breakpoint_blocks' => $breakpoint_blocks, |
| 73 |
'removal_breakpoints' => $removal_breakpoints, |
| 74 |
]; |
| 75 |
} |
| 76 |
|
| 77 |
public static function build_variants( array $breakpoint_blocks, Css_Converter $converter ): array { |
| 78 |
$variants = []; |
| 79 |
|
| 80 |
foreach ( $breakpoint_blocks as $entry ) { |
| 81 |
$bp = $entry['breakpoint']; |
| 82 |
$blocks = $entry['blocks']; |
| 83 |
$base_block_css = ''; |
| 84 |
$base_custom_css_parts = []; |
| 85 |
|
| 86 |
foreach ( $blocks as $block ) { |
| 87 |
$selector = $block['selector']; |
| 88 |
$css = $block['css']; |
| 89 |
|
| 90 |
if ( null === $selector ) { |
| 91 |
$base_block_css = $css; |
| 92 |
continue; |
| 93 |
} |
| 94 |
|
| 95 |
$state = strtolower( ltrim( $selector, ':' ) ); |
| 96 |
|
| 97 |
if ( ':' !== ( $selector[0] ?? '' ) || ! in_array( $state, self::PSEUDO_STATES, true ) ) { |
| 98 |
$base_custom_css_parts[] = '&' . $selector . ' { ' . trim( $css ) . ' }'; |
| 99 |
continue; |
| 100 |
} |
| 101 |
|
| 102 |
$variant = self::make_variant( $bp, $state, $css, $converter ); |
| 103 |
if ( null !== $variant ) { |
| 104 |
$variants[] = $variant; |
| 105 |
} |
| 106 |
} |
| 107 |
|
| 108 |
$base_variant = self::make_variant( $bp, null, $base_block_css, $converter, $base_custom_css_parts ); |
| 109 |
if ( null !== $base_variant ) { |
| 110 |
$variants[] = $base_variant; |
| 111 |
} |
| 112 |
} |
| 113 |
|
| 114 |
return $variants; |
| 115 |
} |
| 116 |
|
| 117 |
public static function apply_mode( array $existing, array $new_variants, string $mode, array $affected_breakpoints ): array { |
| 118 |
if ( 'replace' === $mode ) { |
| 119 |
$kept = array_values( |
| 120 |
array_filter( $existing, fn( $v ) => ! in_array( $v['meta']['breakpoint'] ?? null, $affected_breakpoints, true ) ) |
| 121 |
); |
| 122 |
$clean_new = array_values( |
| 123 |
array_filter( |
| 124 |
array_map( |
| 125 |
function ( $v ) { |
| 126 |
unset( $v['null_props'] ); |
| 127 |
return $v; |
| 128 |
}, |
| 129 |
$new_variants |
| 130 |
), |
| 131 |
fn( $v ) => ! empty( $v['props'] ) || ! empty( $v['custom_css'] ) |
| 132 |
) |
| 133 |
); |
| 134 |
return array_merge( $kept, $clean_new ); |
| 135 |
} |
| 136 |
|
| 137 |
$result = $existing; |
| 138 |
|
| 139 |
foreach ( $new_variants as $new_variant ) { |
| 140 |
$bp = $new_variant['meta']['breakpoint'] ?? null; |
| 141 |
$state = $new_variant['meta']['state'] ?? null; |
| 142 |
$match = null; |
| 143 |
|
| 144 |
foreach ( $result as $i => $v ) { |
| 145 |
if ( ( $v['meta']['breakpoint'] ?? null ) === $bp && ( $v['meta']['state'] ?? null ) === $state ) { |
| 146 |
$match = $i; |
| 147 |
break; |
| 148 |
} |
| 149 |
} |
| 150 |
|
| 151 |
if ( null === $match ) { |
| 152 |
$clean = $new_variant; |
| 153 |
unset( $clean['null_props'] ); |
| 154 |
if ( ! empty( $clean['props'] ) || ! empty( $clean['custom_css'] ) ) { |
| 155 |
$result[] = $clean; |
| 156 |
} |
| 157 |
continue; |
| 158 |
} |
| 159 |
|
| 160 |
$variant_null_props = $new_variant['null_props'] ?? []; |
| 161 |
$wipe_all = in_array( 'all', $variant_null_props, true ); |
| 162 |
$props_to_remove = array_filter( $variant_null_props, fn( $k ) => 'all' !== $k ); |
| 163 |
|
| 164 |
$merged_props = $wipe_all |
| 165 |
? ( $new_variant['props'] ?? [] ) |
| 166 |
: array_merge( $result[ $match ]['props'] ?? [], $new_variant['props'] ?? [] ); |
| 167 |
|
| 168 |
foreach ( $props_to_remove as $key ) { |
| 169 |
unset( $merged_props[ $key ] ); |
| 170 |
} |
| 171 |
|
| 172 |
$merged_props = array_filter( $merged_props, fn( $v ) => null !== $v ); |
| 173 |
|
| 174 |
$merged_custom_css = $wipe_all |
| 175 |
? ( $new_variant['custom_css'] ?? null ) |
| 176 |
: self::merge_custom_css( $result[ $match ]['custom_css'] ?? null, $new_variant['custom_css'] ?? null ); |
| 177 |
|
| 178 |
if ( empty( $merged_props ) && null === $merged_custom_css ) { |
| 179 |
unset( $result[ $match ] ); |
| 180 |
$result = array_values( $result ); |
| 181 |
continue; |
| 182 |
} |
| 183 |
|
| 184 |
$result[ $match ]['props'] = $merged_props; |
| 185 |
$result[ $match ]['custom_css'] = $merged_custom_css; |
| 186 |
unset( $result[ $match ]['null_props'] ); |
| 187 |
} |
| 188 |
|
| 189 |
return $result; |
| 190 |
} |
| 191 |
|
| 192 |
public static function merge_custom_css( ?array $existing, ?array $incoming ): ?array { |
| 193 |
$existing_raw = isset( $existing['raw'] ) ? base64_decode( $existing['raw'] ) : ''; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding stored base64 custom_css for merge. |
| 194 |
$incoming_raw = isset( $incoming['raw'] ) ? base64_decode( $incoming['raw'] ) : ''; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decoding stored base64 custom_css for merge. |
| 195 |
|
| 196 |
$merged = trim( $existing_raw . ' ' . $incoming_raw ); |
| 197 |
|
| 198 |
return '' !== $merged |
| 199 |
? [ 'raw' => base64_encode( $merged ) ] // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Storing merged custom_css as base64. |
| 200 |
: null; |
| 201 |
} |
| 202 |
|
| 203 |
private static function make_variant( string $breakpoint, ?string $state, string $css, Css_Converter $converter, array $extra_custom_css_parts = [] ): ?array { |
| 204 |
$null_props = self::extract_null_props( $css ); |
| 205 |
$stripped_css = self::strip_null_declarations( $css ); |
| 206 |
$result = $converter->convert( $stripped_css ); |
| 207 |
$props = $result['props'] ?? []; |
| 208 |
$custom_str = $result['customCss'] ?? ''; |
| 209 |
|
| 210 |
if ( ! empty( $extra_custom_css_parts ) ) { |
| 211 |
$extra = implode( ' ', $extra_custom_css_parts ); |
| 212 |
$custom_str = '' !== $custom_str ? $custom_str . ' ' . $extra : $extra; |
| 213 |
} |
| 214 |
|
| 215 |
if ( empty( $props ) && '' === $custom_str && empty( $null_props ) ) { |
| 216 |
return null; |
| 217 |
} |
| 218 |
|
| 219 |
return [ |
| 220 |
'meta' => [ |
| 221 |
'breakpoint' => $breakpoint, |
| 222 |
'state' => $state, |
| 223 |
], |
| 224 |
'props' => $props, |
| 225 |
'custom_css' => '' !== $custom_str |
| 226 |
? [ 'raw' => base64_encode( $custom_str ) ] // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Global class custom_css.raw is stored as base64. |
| 227 |
: null, |
| 228 |
'null_props' => $null_props, |
| 229 |
]; |
| 230 |
} |
| 231 |
|
| 232 |
private static function extract_null_props( string $css ): array { |
| 233 |
$null_props = []; |
| 234 |
preg_replace_callback( |
| 235 |
self::NULL_DECLARATION_PATTERN, |
| 236 |
function ( $matches ) use ( &$null_props ) { |
| 237 |
$null_props[] = $matches[2]; |
| 238 |
}, |
| 239 |
$css |
| 240 |
); |
| 241 |
return $null_props; |
| 242 |
} |
| 243 |
|
| 244 |
private static function strip_null_declarations( string $css ): string { |
| 245 |
return (string) preg_replace( self::NULL_DECLARATION_PATTERN, '$1', $css ); |
| 246 |
} |
| 247 |
} |
| 248 |
|