PluginProbe
Elementor Website Builder – more than just a page builder / 4.3.0-beta3
Elementor Website Builder – more than just a page builder v4.3.0-beta3
4.3.0-beta3 4.3.0-beta2 4.3.0-beta1 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.5 4.2.0-beta2 4.2.0-dev2 4.2.0-beta1 4.1.4 4.1.3 4.1.2 4.1.1 4.1.0 4.1.0-beta3 4.1.0-dev3 4.0.9 4.1.0-beta2 4.1.0-dev2 4.0.8 4.1.0-beta1 4.1.0-dev1 All 452 releases
elementor / modules / atomic-widgets / css-converter / css-converter.php

css-converter.php in Elementor Website Builder – more than just a page builder 4.3.0-beta3, at modules/atomic-widgets/css-converter/css-converter.php

428 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Elementor\Modules\AtomicWidgets\CssConverter;
4
5 use Elementor\Modules\AtomicWidgets\CssConverter\Metrics\Conversion_Failure_Reporter;
6 use Elementor\Modules\AtomicWidgets\Parsers\Props_Parser;
7 use Elementor\Modules\AtomicWidgets\Styles\Style_Schema;
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit; // Exit if accessed directly.
11 }
12
13 class Css_Converter {
14
15 use Css_Block_Scanner_Trait;
16
17 const BLOCKED_PROPERTIES = [ 'behavior', '-moz-binding' ];
18 const BLOCKED_VALUE_NEEDLES = [ 'expression(', 'javascript:' ];
19
20 private Converter_Registry $registry;
21
22 private Conversion_Failure_Reporter $failure_reporter;
23
24 private Expander_Registry $expanders;
25
26 private ?Variable_Prop_Value_Transformer $variable_transformer;
27
28 public function __construct(
29 Converter_Registry $registry,
30 Conversion_Failure_Reporter $failure_reporter,
31 ?Expander_Registry $expanders = null,
32 ?Variable_Prop_Value_Transformer $variable_transformer = null
33 ) {
34 $this->registry = $registry;
35 $this->failure_reporter = $failure_reporter;
36 $this->expanders = $expanders ?? new Expander_Registry();
37 $this->variable_transformer = $variable_transformer;
38 }
39
40 /**
41 * @return array{blocks: array<int, array{selector: string|null, css: string}>}|array{blocks: array, error: string}
42 */
43 public function parse_nested( string $css ): array {
44 $result = $this->scan_ampersand_blocks( $css );
45
46 if ( isset( $result['error'] ) ) {
47 return [
48 'blocks' => [],
49 'error' => $result['error'],
50 ];
51 }
52
53 return [
54 'blocks' => array_merge(
55 [
56 [
57 'selector' => null,
58 'css' => $result['base_css'],
59 ],
60 ],
61 $result['nested_blocks']
62 ),
63 ];
64 }
65
66 private function scan_ampersand_blocks( string $css ): array {
67 $nested_blocks = [];
68 $base_css = '';
69 $len = strlen( $css );
70 $i = 0;
71 $segment_start = 0;
72 $in_string = false;
73 $string_char = '';
74
75 while ( $i < $len ) {
76 $char = $css[ $i ];
77
78 if ( $in_string ) {
79 if ( $string_char === $char && ! $this->is_escaped( $css, $i ) ) {
80 $in_string = false;
81 }
82 ++$i;
83 continue;
84 }
85
86 if ( '"' === $char || "'" === $char ) {
87 $in_string = true;
88 $string_char = $char;
89 ++$i;
90 continue;
91 }
92
93 if ( '{' === $char ) {
94 return [ 'error' => 'Unsupported nested selector. Only &:hover, &:focus, and &:active pseudo-class blocks are allowed.' ];
95 }
96
97 if ( '}' === $char ) {
98 return [ 'error' => 'Unclosed brace detected in CSS input.' ];
99 }
100
101 if ( '&' !== $char ) {
102 ++$i;
103 continue;
104 }
105
106 $j = $i + 1;
107 while ( $j < $len && '{' !== $css[ $j ] && ';' !== $css[ $j ] && '}' !== $css[ $j ] ) {
108 ++$j;
109 }
110
111 if ( $j >= $len || ';' === $css[ $j ] || '}' === $css[ $j ] ) {
112 ++$i;
113 continue;
114 }
115
116 $selector = rtrim( substr( $css, $i + 1, $j - $i - 1 ) );
117
118 if ( '' === $selector ) {
119 return [ 'error' => 'Bare & block without a selector is not valid CSS.' ];
120 }
121
122 $block_end = $this->find_block_end( $css, $j + 1, $len );
123
124 if ( null === $block_end ) {
125 return [ 'error' => 'Unclosed brace detected in CSS input.' ];
126 }
127
128 $block_content = substr( $css, $j + 1, $block_end - $j - 2 );
129 $base_css .= substr( $css, $segment_start, $i - $segment_start );
130 $segment_start = $block_end;
131
132 $nested_blocks[] = [
133 'selector' => $selector,
134 'css' => $block_content,
135 ];
136
137 $i = $block_end;
138 }
139
140 $base_css .= substr( $css, $segment_start );
141
142 return [
143 'base_css' => $base_css,
144 'nested_blocks' => $nested_blocks,
145 ];
146 }
147
148 /**
149 * @return array{props: array, customCss: string, rejected: string[]}
150 */
151 public function convert( string $css ): array {
152 $rules = $this->dedupe( $this->expand_shorthands( $this->parse( $css ) ) );
153 $context = new Conversion_Context( $rules );
154 $leftover = [];
155
156 foreach ( $rules as $rule ) {
157 if ( ! $this->try_convert( $context, $rule ) ) {
158 $leftover[] = $rule['declaration'] . ';';
159 }
160 }
161
162 $props = $context->get_props();
163 $rejected = $context->get_rejected();
164
165 if ( $this->variable_transformer ) {
166 $schema = $this->style_schema();
167 $props = $this->variable_transformer->transform( $props, $schema );
168
169 $ejected = $this->variable_transformer->eject_unresolved_var_props( $props, $schema, $rules );
170 $props = $ejected['props'];
171 $leftover = array_merge( $leftover, $ejected['custom_css'] );
172 $rejected = array_merge( $rejected, $ejected['rejected'] );
173
174 // Post-processing: handle edge cases that the general transform pipeline cannot cover.
175 $gradient_color_stops_leftovers = $this->variable_transformer->normalize_gradient_color_stops( $props, $rules );
176 $leftover = array_merge( $leftover, $gradient_color_stops_leftovers );
177
178 $props = $this->validate_props( $props, $schema );
179 }
180
181 $props = $this->cleanup_props( $props );
182
183 return [
184 'props' => $props,
185 'customCss' => implode( ' ', $leftover ),
186 'rejected' => $rejected,
187 ];
188 }
189
190 private function validate_props( array $props, array $schema ): array {
191 if ( empty( $props ) ) {
192 return [];
193 }
194
195 $null_resets = array_filter( $props, fn( $v ) => null === $v || $this->has_null_leaf( $v ) );
196 $value_props = array_filter( $props, fn( $v ) => null !== $v && ! $this->has_null_leaf( $v ) );
197
198 $validated = empty( $value_props )
199 ? []
200 : Props_Parser::make( $schema )->validate( $value_props )->unwrap();
201
202 return array_merge( $validated, $null_resets );
203 }
204
205 private function has_null_leaf( $value ): bool {
206 if ( ! is_array( $value ) || ! is_array( $value['value'] ?? null ) ) {
207 return false;
208 }
209
210 foreach ( $value['value'] as $v ) {
211 if ( null === $v || $this->has_null_leaf( $v ) ) {
212 return true;
213 }
214 }
215
216 return false;
217 }
218
219 /**
220 * Recursively collapses prop values where all present sub-values are null or empty arrays into a
221 * single null. This propagates null resets up the tree so the client receives a clean signal:
222 * e.g. a Dimensions object where every side was set to null becomes just null at the prop level.
223 */
224 private function cleanup_props( array $props ): array {
225 $result = [];
226
227 foreach ( $props as $key => $value ) {
228 $result[ $key ] = $this->cleanup_value( $value );
229 }
230
231 return $result;
232 }
233
234 private function cleanup_value( $value ) {
235 if ( null === $value || ! is_array( $value ) ) {
236 return $value;
237 }
238
239 $inner = $value['value'] ?? $value;
240
241 if ( ! is_array( $inner ) ) {
242 return $value;
243 }
244
245 $cleaned = [];
246
247 foreach ( $inner as $k => $v ) {
248 $cleaned[ $k ] = $this->cleanup_value( $v );
249 }
250
251 if ( $this->is_empty_or_all_null( $cleaned ) ) {
252 return null;
253 }
254
255 return isset( $value['$$type'] )
256 ? [
257 '$$type' => $value['$$type'],
258 'value' => $cleaned,
259 ]
260 : $cleaned;
261 }
262
263 private function is_empty_or_all_null( array $arr ): bool {
264 if ( empty( $arr ) ) {
265 return false;
266 }
267
268 foreach ( $arr as $v ) {
269 if ( null !== $v ) {
270 return false;
271 }
272 }
273
274 return true;
275 }
276
277 private function style_schema(): array {
278 if ( function_exists( 'apply_filters' ) ) {
279 return Style_Schema::get();
280 }
281
282 return Style_Schema::get_style_schema();
283 }
284
285 /**
286 * Pre-processing pass: rewrite shorthands (e.g. `border`) into the longhand declarations the
287 * schema-bound converters understand, in place so the source cascade order is preserved. A rule
288 * with no matching expander, or whose expander declines (empty result) or throws, is kept as-is so
289 * it still reaches the converter loop (and custom_css fallback).
290 *
291 * @param array<int, array{property: string, value: string, declaration: string}> $rules
292 * @return array<int, array{property: string, value: string, declaration: string}>
293 */
294 private function dedupe( array $rules ): array {
295 $last_index = [];
296
297 foreach ( $rules as $i => $rule ) {
298 $last_index[ $rule['property'] ] = $i;
299 }
300
301 return array_values(
302 array_filter( $rules, fn( $rule, $i ) => $last_index[ $rule['property'] ] === $i, ARRAY_FILTER_USE_BOTH )
303 );
304 }
305
306 private function expand_shorthands( array $rules ): array {
307 $expanded = [];
308
309 foreach ( $rules as $rule ) {
310 foreach ( $this->expand_rule( $rule ) as $result_rule ) {
311 $expanded[] = $result_rule;
312 }
313 }
314
315 return $expanded;
316 }
317
318 /**
319 * @param array{property: string, value: string, declaration: string} $rule
320 * @return array<int, array{property: string, value: string, declaration: string}>
321 */
322 private function expand_rule( array $rule ): array {
323 foreach ( $this->expanders->all() as $expander ) {
324 if ( ! $expander->is_supported( $rule ) ) {
325 continue;
326 }
327
328 try {
329 $expanded = $expander->expand( $rule );
330 } catch ( \Throwable $error ) {
331 $this->failure_reporter->report(
332 $rule['property'],
333 Conversion_Failure_Reporter::CATEGORY_EXCEPTION,
334 [ 'message' => $error->getMessage() ]
335 );
336
337 return [ $rule ];
338 }
339
340 return empty( $expanded ) ? [ $rule ] : $expanded;
341 }
342
343 return [ $rule ];
344 }
345
346 /**
347 * Try-until-success: iterate converters in registration order; the first one that
348 * converts wins. A thrown error is treated as a decline and reported as a defect.
349 *
350 * @param Conversion_Context $context The shared mutable conversion context.
351 * @param array{property: string, value: string} $rule A single parsed CSS declaration.
352 */
353 private function try_convert( Conversion_Context $context, array $rule ): bool {
354 foreach ( $this->registry->all() as $converter ) {
355 if ( ! $converter->is_supported( $rule ) ) {
356 continue;
357 }
358
359 try {
360 if ( $converter->convert( $context, $rule ) ) {
361 return true;
362 }
363 } catch ( \Throwable $error ) {
364 $this->failure_reporter->report(
365 $rule['property'],
366 Conversion_Failure_Reporter::CATEGORY_EXCEPTION,
367 [ 'message' => $error->getMessage() ]
368 );
369 }
370 }
371
372 return false;
373 }
374
375 /**
376 * Naive top-level split on ';' (breaks on values containing ';'); acceptable for clean
377 * LLM input. Splits each declaration on the first ':' so values keep colons (e.g. url()).
378 *
379 * @return array<int, array{property: string, value: string}>
380 */
381 private function parse( string $css ): array {
382 $rules = [];
383
384 foreach ( explode( ';', $css ) as $declaration ) {
385 $declaration = trim( $declaration );
386
387 $separator = strpos( $declaration, ':' );
388
389 if ( false === $separator ) {
390 continue;
391 }
392
393 $property = strtolower( trim( substr( $declaration, 0, $separator ) ) );
394 $raw_value = trim( substr( $declaration, $separator + 1 ) );
395
396 if ( '' === $property || '' === $raw_value || $this->is_blocked( $property, $raw_value ) ) {
397 continue;
398 }
399
400 $value = 'null' === $raw_value ? null : $raw_value;
401
402 $rules[] = [
403 'property' => $property,
404 'value' => $value,
405 'declaration' => $declaration,
406 ];
407 }
408
409 return $rules;
410 }
411
412 private function is_blocked( string $property, string $value ): bool {
413 if ( in_array( $property, self::BLOCKED_PROPERTIES, true ) ) {
414 return true;
415 }
416
417 $value = strtolower( $value );
418
419 foreach ( self::BLOCKED_VALUE_NEEDLES as $needle ) {
420 if ( false !== strpos( $value, $needle ) ) {
421 return true;
422 }
423 }
424
425 return false;
426 }
427 }
428