PluginProbe
Gutenberg / 12.6.0
Gutenberg v12.6.0
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / compat / wordpress-5.9 / class-wp-theme-json-gutenberg.php

class-wp-theme-json-gutenberg.php in Gutenberg 12.6.0, at lib/compat/wordpress-5.9/class-wp-theme-json-gutenberg.php

1,921 lines 58.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WP_Theme_JSON_Gutenberg class
4 *
5 * @package gutenberg
6 */
7
8 /**
9 * Class that encapsulates the processing of structures that adhere to the theme.json spec.
10 *
11 * This class is for internal core usage and is not supposed to be used by extenders (plugins and/or themes).
12 * This is a low-level API that may need to do breaking changes. Please,
13 * use get_global_settings, get_global_styles, and get_global_stylesheet instead.
14 *
15 * @access private
16 */
17 class WP_Theme_JSON_Gutenberg {
18
19 /**
20 * Container of data in theme.json format.
21 *
22 * @var array
23 */
24 protected $theme_json = null;
25
26 /**
27 * Holds block metadata extracted from block.json
28 * to be shared among all instances so we don't
29 * process it twice.
30 *
31 * @var array
32 */
33 private static $blocks_metadata = null;
34
35 /**
36 * The CSS selector for the top-level styles.
37 *
38 * @var string
39 */
40 const ROOT_BLOCK_SELECTOR = 'body';
41
42 /**
43 * The sources of data this object can represent.
44 *
45 * @since 5.8.0
46 * @var string[]
47 */
48 const VALID_ORIGINS = array(
49 'default',
50 'theme',
51 'custom',
52 );
53
54 /**
55 * Presets are a set of values that serve
56 * to bootstrap some styles: colors, font sizes, etc.
57 *
58 * They are a unkeyed array of values such as:
59 *
60 * ```php
61 * array(
62 * array(
63 * 'slug' => 'unique-name-within-the-set',
64 * 'name' => 'Name for the UI',
65 * <value_key> => 'value'
66 * ),
67 * )
68 * ```
69 *
70 * This contains the necessary metadata to process them:
71 *
72 * - path => where to find the preset within the settings section
73 * - override => whether a theme preset with the same slug as a default preset
74 * can override it
75 * - value_key => the key that represents the value
76 * - value_func => optionally, instead of value_key, a function to generate
77 * the value that takes a preset as an argument
78 * (either value_key or value_func should be present)
79 * - css_vars => template string to use in generating the CSS Custom Property.
80 * Example output: "--wp--preset--duotone--blue: <value>" will generate as many CSS Custom Properties as presets defined
81 * substituting the $slug for the slug's value for each preset value.
82 * - classes => array containing a structure with the classes to
83 * generate for the presets, where for each array item
84 * the key is the class name and the value the property name.
85 * The "$slug" substring will be replaced by the slug of each preset.
86 * For example:
87 * 'classes' => array(
88 * '.has-$slug-color' => 'color',
89 * '.has-$slug-background-color' => 'background-color',
90 * '.has-$slug-border-color' => 'border-color',
91 * )
92 * - properties => array of CSS properties to be used by kses to
93 * validate the content of each preset
94 * by means of the remove_insecure_properties method.
95 */
96 const PRESETS_METADATA = array(
97 array(
98 'path' => array( 'color', 'palette' ),
99 'override' => array( 'color', 'defaultPalette' ),
100 'use_default_names' => false,
101 'value_key' => 'color',
102 'css_vars' => '--wp--preset--color--$slug',
103 'classes' => array(
104 '.has-$slug-color' => 'color',
105 '.has-$slug-background-color' => 'background-color',
106 '.has-$slug-border-color' => 'border-color',
107 ),
108 'properties' => array( 'color', 'background-color', 'border-color' ),
109 ),
110 array(
111 'path' => array( 'color', 'gradients' ),
112 'override' => array( 'color', 'defaultGradients' ),
113 'use_default_names' => false,
114 'value_key' => 'gradient',
115 'css_vars' => '--wp--preset--gradient--$slug',
116 'classes' => array( '.has-$slug-gradient-background' => 'background' ),
117 'properties' => array( 'background' ),
118 ),
119 array(
120 'path' => array( 'color', 'duotone' ),
121 'override' => true,
122 'use_default_names' => false,
123 'value_func' => 'gutenberg_get_duotone_filter_property',
124 'css_vars' => '--wp--preset--duotone--$slug',
125 'classes' => array(),
126 'properties' => array( 'filter' ),
127 ),
128 array(
129 'path' => array( 'typography', 'fontSizes' ),
130 'override' => true,
131 'use_default_names' => true,
132 'value_key' => 'size',
133 'css_vars' => '--wp--preset--font-size--$slug',
134 'classes' => array( '.has-$slug-font-size' => 'font-size' ),
135 'properties' => array( 'font-size' ),
136 ),
137 array(
138 'path' => array( 'typography', 'fontFamilies' ),
139 'override' => true,
140 'use_default_names' => false,
141 'value_key' => 'fontFamily',
142 'css_vars' => '--wp--preset--font-family--$slug',
143 'classes' => array( '.has-$slug-font-family' => 'font-family' ),
144 'properties' => array( 'font-family' ),
145 ),
146 );
147
148 /**
149 * Metadata for style properties.
150 *
151 * Each element is a direct mapping from the CSS property name to the
152 * path to the value in theme.json & block attributes.
153 */
154 const PROPERTIES_METADATA = array(
155 'background' => array( 'color', 'gradient' ),
156 'background-color' => array( 'color', 'background' ),
157 'border-radius' => array( 'border', 'radius' ),
158 'border-top-left-radius' => array( 'border', 'radius', 'topLeft' ),
159 'border-top-right-radius' => array( 'border', 'radius', 'topRight' ),
160 'border-bottom-left-radius' => array( 'border', 'radius', 'bottomLeft' ),
161 'border-bottom-right-radius' => array( 'border', 'radius', 'bottomRight' ),
162 'border-color' => array( 'border', 'color' ),
163 'border-width' => array( 'border', 'width' ),
164 'border-style' => array( 'border', 'style' ),
165 'color' => array( 'color', 'text' ),
166 'font-family' => array( 'typography', 'fontFamily' ),
167 'font-size' => array( 'typography', 'fontSize' ),
168 'font-style' => array( 'typography', 'fontStyle' ),
169 'font-weight' => array( 'typography', 'fontWeight' ),
170 'letter-spacing' => array( 'typography', 'letterSpacing' ),
171 'line-height' => array( 'typography', 'lineHeight' ),
172 'margin' => array( 'spacing', 'margin' ),
173 'margin-top' => array( 'spacing', 'margin', 'top' ),
174 'margin-right' => array( 'spacing', 'margin', 'right' ),
175 'margin-bottom' => array( 'spacing', 'margin', 'bottom' ),
176 'margin-left' => array( 'spacing', 'margin', 'left' ),
177 'padding' => array( 'spacing', 'padding' ),
178 'padding-top' => array( 'spacing', 'padding', 'top' ),
179 'padding-right' => array( 'spacing', 'padding', 'right' ),
180 'padding-bottom' => array( 'spacing', 'padding', 'bottom' ),
181 'padding-left' => array( 'spacing', 'padding', 'left' ),
182 '--wp--style--block-gap' => array( 'spacing', 'blockGap' ),
183 'text-decoration' => array( 'typography', 'textDecoration' ),
184 'text-transform' => array( 'typography', 'textTransform' ),
185 'filter' => array( 'filter', 'duotone' ),
186 );
187
188 /**
189 * Protected style properties.
190 *
191 * These style properties are only rendered if a setting enables it
192 * via a value other than `null`.
193 *
194 * Each element maps the style property to the corresponding theme.json
195 * setting key.
196 */
197 const PROTECTED_PROPERTIES = array(
198 'spacing.blockGap' => array( 'spacing', 'blockGap' ),
199 );
200
201 /**
202 * The top-level keys a theme.json can have.
203 *
204 * @var string[]
205 */
206 const VALID_TOP_LEVEL_KEYS = array(
207 'customTemplates',
208 'settings',
209 'styles',
210 'templateParts',
211 'version',
212 );
213
214 /**
215 * The valid properties under the settings key.
216 *
217 * @var array
218 */
219 const VALID_SETTINGS = array(
220 'appearanceTools' => null,
221 'border' => array(
222 'color' => null,
223 'radius' => null,
224 'style' => null,
225 'width' => null,
226 ),
227 'color' => array(
228 'background' => null,
229 'custom' => null,
230 'customDuotone' => null,
231 'customGradient' => null,
232 'defaultGradients' => null,
233 'defaultPalette' => null,
234 'duotone' => null,
235 'gradients' => null,
236 'link' => null,
237 'palette' => null,
238 'text' => null,
239 ),
240 'custom' => null,
241 'layout' => array(
242 'contentSize' => null,
243 'wideSize' => null,
244 ),
245 'spacing' => array(
246 'blockGap' => null,
247 'margin' => null,
248 'padding' => null,
249 'units' => null,
250 ),
251 'typography' => array(
252 'customFontSize' => null,
253 'dropCap' => null,
254 'fontFamilies' => null,
255 'fontSizes' => null,
256 'fontStyle' => null,
257 'fontWeight' => null,
258 'letterSpacing' => null,
259 'lineHeight' => null,
260 'textDecoration' => null,
261 'textTransform' => null,
262 ),
263 );
264
265 /**
266 * The valid properties under the styles key.
267 *
268 * @var array
269 */
270 const VALID_STYLES = array(
271 'border' => array(
272 'color' => null,
273 'radius' => null,
274 'style' => null,
275 'width' => null,
276 ),
277 'color' => array(
278 'background' => null,
279 'gradient' => null,
280 'text' => null,
281 ),
282 'filter' => array(
283 'duotone' => null,
284 ),
285 'spacing' => array(
286 'margin' => null,
287 'padding' => null,
288 'blockGap' => 'top',
289 ),
290 'typography' => array(
291 'fontFamily' => null,
292 'fontSize' => null,
293 'fontStyle' => null,
294 'fontWeight' => null,
295 'letterSpacing' => null,
296 'lineHeight' => null,
297 'textDecoration' => null,
298 'textTransform' => null,
299 ),
300 );
301
302 /**
303 * The valid elements that can be found under styles.
304 *
305 * @var string[]
306 */
307 const ELEMENTS = array(
308 'link' => 'a',
309 'h1' => 'h1',
310 'h2' => 'h2',
311 'h3' => 'h3',
312 'h4' => 'h4',
313 'h5' => 'h5',
314 'h6' => 'h6',
315 );
316
317 /**
318 * The latest version of the schema in use.
319 *
320 * @var int
321 */
322 const LATEST_SCHEMA = 2;
323
324 /**
325 * Constructor.
326 *
327 * @param array $theme_json A structure that follows the theme.json schema.
328 * @param string $origin Optional. What source of data this object represents.
329 * One of 'default', 'theme', or 'custom'. Default 'theme'.
330 */
331 public function __construct( $theme_json = array(), $origin = 'theme' ) {
332 if ( ! in_array( $origin, self::VALID_ORIGINS, true ) ) {
333 $origin = 'theme';
334 }
335
336 $this->theme_json = WP_Theme_JSON_Schema_Gutenberg::migrate( $theme_json );
337 $valid_block_names = array_keys( self::get_blocks_metadata() );
338 $valid_element_names = array_keys( self::ELEMENTS );
339 $theme_json = self::sanitize( $this->theme_json, $valid_block_names, $valid_element_names );
340 $this->theme_json = self::maybe_opt_in_into_settings( $theme_json );
341
342 // Internally, presets are keyed by origin.
343 $nodes = self::get_setting_nodes( $this->theme_json );
344 foreach ( $nodes as $node ) {
345 foreach ( self::PRESETS_METADATA as $preset_metadata ) {
346 $path = array_merge( $node['path'], $preset_metadata['path'] );
347 $preset = _wp_array_get( $this->theme_json, $path, null );
348 if ( null !== $preset ) {
349 // If the preset is not already keyed by origin.
350 if ( isset( $preset[0] ) || empty( $preset ) ) {
351 _wp_array_set( $this->theme_json, $path, array( $origin => $preset ) );
352 }
353 }
354 }
355 }
356 }
357
358 /**
359 * Enables some opt-in settings if theme declared support.
360 *
361 * @param array $theme_json A theme.json structure to modify.
362 * @return array The modified theme.json structure.
363 */
364 private static function maybe_opt_in_into_settings( $theme_json ) {
365 $new_theme_json = $theme_json;
366
367 if (
368 isset( $new_theme_json['settings']['appearanceTools'] ) &&
369 true === $new_theme_json['settings']['appearanceTools']
370 ) {
371 self::do_opt_in_into_settings( $new_theme_json['settings'] );
372 }
373
374 if ( isset( $new_theme_json['settings']['blocks'] ) && is_array( $new_theme_json['settings']['blocks'] ) ) {
375 foreach ( $new_theme_json['settings']['blocks'] as &$block ) {
376 if ( isset( $block['appearanceTools'] ) && ( true === $block['appearanceTools'] ) ) {
377 self::do_opt_in_into_settings( $block );
378 }
379 }
380 }
381
382 return $new_theme_json;
383 }
384
385 /**
386 * Enables some settings.
387 *
388 * @param array $context The context to which the settings belong.
389 */
390 private static function do_opt_in_into_settings( &$context ) {
391 $to_opt_in = array(
392 array( 'border', 'color' ),
393 array( 'border', 'radius' ),
394 array( 'border', 'style' ),
395 array( 'border', 'width' ),
396 array( 'color', 'link' ),
397 array( 'spacing', 'blockGap' ),
398 array( 'spacing', 'margin' ),
399 array( 'spacing', 'padding' ),
400 array( 'typography', 'lineHeight' ),
401 );
402
403 foreach ( $to_opt_in as $path ) {
404 // Use "unset prop" as a marker instead of "null" because
405 // "null" can be a valid value for some props (e.g. blockGap).
406 if ( 'unset prop' === _wp_array_get( $context, $path, 'unset prop' ) ) {
407 _wp_array_set( $context, $path, true );
408 }
409 }
410
411 unset( $context['appearanceTools'] );
412 }
413
414 /**
415 * Sanitizes the input according to the schemas.
416 *
417 * @param array $input Structure to sanitize.
418 * @param array $valid_block_names List of valid block names.
419 * @param array $valid_element_names List of valid element names.
420 * @return array The sanitized output.
421 */
422 private static function sanitize( $input, $valid_block_names, $valid_element_names ) {
423 $output = array();
424
425 if ( ! is_array( $input ) ) {
426 return $output;
427 }
428
429 $output = array_intersect_key( $input, array_flip( static::VALID_TOP_LEVEL_KEYS ) );
430
431 // Some styles are only meant to be available at the top-level (e.g.: blockGap),
432 // hence, the schema for blocks & elements should not have them.
433 $styles_non_top_level = self::VALID_STYLES;
434 foreach ( array_keys( $styles_non_top_level ) as $section ) {
435 foreach ( array_keys( $styles_non_top_level[ $section ] ) as $prop ) {
436 if ( 'top' === $styles_non_top_level[ $section ][ $prop ] ) {
437 unset( $styles_non_top_level[ $section ][ $prop ] );
438 }
439 }
440 }
441
442 // Build the schema based on valid block & element names.
443 $schema = array();
444 $schema_styles_elements = array();
445 foreach ( $valid_element_names as $element ) {
446 $schema_styles_elements[ $element ] = $styles_non_top_level;
447 }
448 $schema_styles_blocks = array();
449 $schema_settings_blocks = array();
450 foreach ( $valid_block_names as $block ) {
451 $schema_settings_blocks[ $block ] = self::VALID_SETTINGS;
452 $schema_styles_blocks[ $block ] = $styles_non_top_level;
453 $schema_styles_blocks[ $block ]['elements'] = $schema_styles_elements;
454 }
455 $schema['styles'] = self::VALID_STYLES;
456 $schema['styles']['blocks'] = $schema_styles_blocks;
457 $schema['styles']['elements'] = $schema_styles_elements;
458 $schema['settings'] = self::VALID_SETTINGS;
459 $schema['settings']['blocks'] = $schema_settings_blocks;
460
461 // Remove anything that's not present in the schema.
462 foreach ( array( 'styles', 'settings' ) as $subtree ) {
463 if ( ! isset( $input[ $subtree ] ) ) {
464 continue;
465 }
466
467 if ( ! is_array( $input[ $subtree ] ) ) {
468 unset( $output[ $subtree ] );
469 continue;
470 }
471
472 $result = self::remove_keys_not_in_schema( $input[ $subtree ], $schema[ $subtree ] );
473
474 if ( empty( $result ) ) {
475 unset( $output[ $subtree ] );
476 } else {
477 $output[ $subtree ] = $result;
478 }
479 }
480
481 return $output;
482 }
483
484 /**
485 * Returns the metadata for each block.
486 *
487 * Example:
488 *
489 * {
490 * 'core/paragraph': {
491 * 'selector': 'p',
492 * 'elements': {
493 * 'link' => 'link selector',
494 * 'etc' => 'element selector'
495 * }
496 * },
497 * 'core/heading': {
498 * 'selector': 'h1',
499 * 'elements': {}
500 * },
501 * 'core/image': {
502 * 'selector': '.wp-block-image',
503 * 'duotone': 'img',
504 * 'elements': {}
505 * }
506 * }
507 *
508 * @return array Block metadata.
509 */
510 private static function get_blocks_metadata() {
511 if ( null !== self::$blocks_metadata ) {
512 return self::$blocks_metadata;
513 }
514
515 self::$blocks_metadata = array();
516
517 $registry = WP_Block_Type_Registry::get_instance();
518 $blocks = $registry->get_all_registered();
519 foreach ( $blocks as $block_name => $block_type ) {
520 if (
521 isset( $block_type->supports['__experimentalSelector'] ) &&
522 is_string( $block_type->supports['__experimentalSelector'] )
523 ) {
524 self::$blocks_metadata[ $block_name ]['selector'] = $block_type->supports['__experimentalSelector'];
525 } else {
526 self::$blocks_metadata[ $block_name ]['selector'] = '.wp-block-' . str_replace( '/', '-', str_replace( 'core/', '', $block_name ) );
527 }
528
529 if (
530 isset( $block_type->supports['color']['__experimentalDuotone'] ) &&
531 is_string( $block_type->supports['color']['__experimentalDuotone'] )
532 ) {
533 self::$blocks_metadata[ $block_name ]['duotone'] = $block_type->supports['color']['__experimentalDuotone'];
534 }
535
536 // Assign defaults, then overwrite those that the block sets by itself.
537 // If the block selector is compounded, will append the element to each
538 // individual block selector.
539 $block_selectors = explode( ',', self::$blocks_metadata[ $block_name ]['selector'] );
540 foreach ( self::ELEMENTS as $el_name => $el_selector ) {
541 $element_selector = array();
542 foreach ( $block_selectors as $selector ) {
543 $element_selector[] = $selector . ' ' . $el_selector;
544 }
545 self::$blocks_metadata[ $block_name ]['elements'][ $el_name ] = implode( ',', $element_selector );
546 }
547 }
548
549 return self::$blocks_metadata;
550 }
551
552 /**
553 * Given a tree, removes the keys that are not present in the schema.
554 *
555 * It is recursive and modifies the input in-place.
556 *
557 * @param array $tree Input to process.
558 * @param array $schema Schema to adhere to.
559 * @return array Returns the modified $tree.
560 */
561 private static function remove_keys_not_in_schema( $tree, $schema ) {
562 $tree = array_intersect_key( $tree, $schema );
563
564 foreach ( $schema as $key => $data ) {
565 if ( ! isset( $tree[ $key ] ) ) {
566 continue;
567 }
568
569 if ( is_array( $schema[ $key ] ) && is_array( $tree[ $key ] ) ) {
570 $tree[ $key ] = self::remove_keys_not_in_schema( $tree[ $key ], $schema[ $key ] );
571
572 if ( empty( $tree[ $key ] ) ) {
573 unset( $tree[ $key ] );
574 }
575 } elseif ( is_array( $schema[ $key ] ) && ! is_array( $tree[ $key ] ) ) {
576 unset( $tree[ $key ] );
577 }
578 }
579
580 return $tree;
581 }
582
583 /**
584 * Returns the existing settings for each block.
585 *
586 * Example:
587 *
588 * {
589 * 'root': {
590 * 'color': {
591 * 'custom': true
592 * }
593 * },
594 * 'core/paragraph': {
595 * 'spacing': {
596 * 'customPadding': true
597 * }
598 * }
599 * }
600 *
601 * @return array Settings per block.
602 */
603 public function get_settings() {
604 if ( ! isset( $this->theme_json['settings'] ) ) {
605 return array();
606 } else {
607 return $this->theme_json['settings'];
608 }
609 }
610
611 /**
612 * Returns the stylesheet that results of processing
613 * the theme.json structure this object represents.
614 *
615 * @param array $types Types of styles to load. Will load all by default. It accepts:
616 * 'variables': only the CSS Custom Properties for presets & custom ones.
617 * 'styles': only the styles section in theme.json.
618 * 'presets': only the classes for the presets.
619 * @param array $origins A list of origins to include. By default it includes self::VALID_ORIGINS.
620 * @return string Stylesheet.
621 */
622 public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' ), $origins = self::VALID_ORIGINS ) {
623 if ( is_string( $types ) ) {
624 // Dispatch error and map old arguments to new ones.
625 _deprecated_argument( __FUNCTION__, '5.9' );
626 if ( 'block_styles' === $types ) {
627 $types = array( 'styles', 'presets' );
628 } elseif ( 'css_variables' === $types ) {
629 $types = array( 'variables' );
630 } else {
631 $types = array( 'variables', 'styles', 'presets' );
632 }
633 }
634
635 $blocks_metadata = self::get_blocks_metadata();
636 $style_nodes = self::get_style_nodes( $this->theme_json, $blocks_metadata );
637 $setting_nodes = self::get_setting_nodes( $this->theme_json, $blocks_metadata );
638
639 $stylesheet = '';
640
641 if ( in_array( 'variables', $types, true ) ) {
642 $stylesheet .= $this->get_css_variables( $setting_nodes, $origins );
643 }
644
645 if ( in_array( 'styles', $types, true ) ) {
646 $stylesheet .= $this->get_block_classes( $style_nodes );
647 }
648
649 if ( in_array( 'presets', $types, true ) ) {
650 $stylesheet .= $this->get_preset_classes( $setting_nodes, $origins );
651 }
652
653 return $stylesheet;
654 }
655
656 /**
657 * Returns the page templates of the current theme.
658 *
659 * @return array
660 */
661 public function get_custom_templates() {
662 $custom_templates = array();
663 if ( ! isset( $this->theme_json['customTemplates'] ) || ! is_array( $this->theme_json['customTemplates'] ) ) {
664 return $custom_templates;
665 }
666
667 foreach ( $this->theme_json['customTemplates'] as $item ) {
668 if ( isset( $item['name'] ) ) {
669 $custom_templates[ $item['name'] ] = array(
670 'title' => isset( $item['title'] ) ? $item['title'] : '',
671 'postTypes' => isset( $item['postTypes'] ) ? $item['postTypes'] : array( 'page' ),
672 );
673 }
674 }
675 return $custom_templates;
676 }
677
678 /**
679 * Returns the template part data of current theme.
680 *
681 * @return array
682 */
683 public function get_template_parts() {
684 $template_parts = array();
685 if ( ! isset( $this->theme_json['templateParts'] ) || ! is_array( $this->theme_json['templateParts'] ) ) {
686 return $template_parts;
687 }
688
689 foreach ( $this->theme_json['templateParts'] as $item ) {
690 if ( isset( $item['name'] ) ) {
691 $template_parts[ $item['name'] ] = array(
692 'title' => isset( $item['title'] ) ? $item['title'] : '',
693 'area' => isset( $item['area'] ) ? $item['area'] : '',
694 );
695 }
696 }
697 return $template_parts;
698 }
699
700 /**
701 * Converts each style section into a list of rulesets
702 * containing the block styles to be appended to the stylesheet.
703 *
704 * See glossary at https://developer.mozilla.org/en-US/docs/Web/CSS/Syntax
705 *
706 * For each section this creates a new ruleset such as:
707 *
708 * block-selector {
709 * style-property-one: value;
710 * }
711 *
712 * @param array $style_nodes Nodes with styles.
713 * @return string The new stylesheet.
714 */
715 private function get_block_classes( $style_nodes ) {
716 $block_rules = '';
717
718 foreach ( $style_nodes as $metadata ) {
719 if ( null === $metadata['selector'] ) {
720 continue;
721 }
722
723 $node = _wp_array_get( $this->theme_json, $metadata['path'], array() );
724 $selector = $metadata['selector'];
725 $settings = _wp_array_get( $this->theme_json, array( 'settings' ) );
726 $declarations = self::compute_style_properties( $node, $settings );
727
728 // 1. Separate the ones who use the general selector
729 // and the ones who use the duotone selector.
730 $declarations_duotone = array();
731 foreach ( $declarations as $index => $declaration ) {
732 if ( 'filter' === $declaration['name'] ) {
733 unset( $declarations[ $index ] );
734 $declarations_duotone[] = $declaration;
735 }
736 }
737
738 /*
739 * Reset default browser margin on the root body element.
740 * This is set on the root selector **before** generating the ruleset
741 * from the `theme.json`. This is to ensure that if the `theme.json` declares
742 * `margin` in its `spacing` declaration for the `body` element then these
743 * user-generated values take precedence in the CSS cascade.
744 * @link https://github.com/WordPress/gutenberg/issues/36147.
745 */
746 if ( self::ROOT_BLOCK_SELECTOR === $selector ) {
747 $block_rules .= 'body { margin: 0; }';
748 }
749
750 // 2. Generate the rules that use the general selector.
751 $block_rules .= self::to_ruleset( $selector, $declarations );
752
753 // 3. Generate the rules that use the duotone selector.
754 if ( isset( $metadata['duotone'] ) && ! empty( $declarations_duotone ) ) {
755 $selector_duotone = self::scope_selector( $metadata['selector'], $metadata['duotone'] );
756 $block_rules .= self::to_ruleset( $selector_duotone, $declarations_duotone );
757 }
758
759 if ( self::ROOT_BLOCK_SELECTOR === $selector ) {
760 $block_rules .= '.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }';
761 $block_rules .= '.wp-site-blocks > .alignright { float: right; margin-left: 2em; }';
762 $block_rules .= '.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }';
763
764 $has_block_gap_support = _wp_array_get( $this->theme_json, array( 'settings', 'spacing', 'blockGap' ) ) !== null;
765 if ( $has_block_gap_support ) {
766 $block_rules .= '.wp-site-blocks > * { margin-top: 0; margin-bottom: 0; }';
767 $block_rules .= '.wp-site-blocks > * + * { margin-top: var( --wp--style--block-gap ); }';
768 }
769 }
770 }
771
772 return $block_rules;
773 }
774
775 /**
776 * Creates new rulesets as classes for each preset value such as:
777 *
778 * .has-value-color {
779 * color: value;
780 * }
781 *
782 * .has-value-background-color {
783 * background-color: value;
784 * }
785 *
786 * .has-value-font-size {
787 * font-size: value;
788 * }
789 *
790 * .has-value-gradient-background {
791 * background: value;
792 * }
793 *
794 * p.has-value-gradient-background {
795 * background: value;
796 * }
797 *
798 * @param array $setting_nodes Nodes with settings.
799 * @param array $origins List of origins to process presets from.
800 * @return string The new stylesheet.
801 */
802 private function get_preset_classes( $setting_nodes, $origins ) {
803 $preset_rules = '';
804
805 foreach ( $setting_nodes as $metadata ) {
806 if ( null === $metadata['selector'] ) {
807 continue;
808 }
809
810 $selector = $metadata['selector'];
811 $node = _wp_array_get( $this->theme_json, $metadata['path'], array() );
812 $preset_rules .= self::compute_preset_classes( $node, $selector, $origins );
813 }
814
815 return $preset_rules;
816 }
817
818 /**
819 * Converts each styles section into a list of rulesets
820 * to be appended to the stylesheet.
821 * These rulesets contain all the css variables (custom variables and preset variables).
822 *
823 * See glossary at https://developer.mozilla.org/en-US/docs/Web/CSS/Syntax
824 *
825 * For each section this creates a new ruleset such as:
826 *
827 * block-selector {
828 * --wp--preset--category--slug: value;
829 * --wp--custom--variable: value;
830 * }
831 *
832 * @param array $nodes Nodes with settings.
833 * @param array $origins List of origins to process.
834 * @return string The new stylesheet.
835 */
836 private function get_css_variables( $nodes, $origins ) {
837 $stylesheet = '';
838 foreach ( $nodes as $metadata ) {
839 if ( null === $metadata['selector'] ) {
840 continue;
841 }
842
843 $selector = $metadata['selector'];
844
845 $node = _wp_array_get( $this->theme_json, $metadata['path'], array() );
846 $declarations = array_merge( self::compute_preset_vars( $node, $origins ), self::compute_theme_vars( $node ) );
847
848 $stylesheet .= self::to_ruleset( $selector, $declarations );
849 }
850
851 return $stylesheet;
852 }
853
854 /**
855 * Given a selector and a declaration list,
856 * creates the corresponding ruleset.
857 *
858 * To help debugging, will add some space
859 * if SCRIPT_DEBUG is defined and true.
860 *
861 * @param string $selector CSS selector.
862 * @param array $declarations List of declarations.
863 *
864 * @return string CSS ruleset.
865 */
866 private static function to_ruleset( $selector, $declarations ) {
867 if ( empty( $declarations ) ) {
868 return '';
869 }
870 $ruleset = '';
871
872 if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
873 $declaration_block = array_reduce(
874 $declarations,
875 function ( $carry, $element ) {
876 return $carry .= "\t" . $element['name'] . ': ' . $element['value'] . ";\n"; },
877 ''
878 );
879 $ruleset .= $selector . " {\n" . $declaration_block . "}\n";
880 } else {
881 $declaration_block = array_reduce(
882 $declarations,
883 function ( $carry, $element ) {
884 return $carry .= $element['name'] . ': ' . $element['value'] . ';'; },
885 ''
886 );
887 $ruleset .= $selector . '{' . $declaration_block . '}';
888 }
889
890 return $ruleset;
891 }
892
893 /**
894 * Function that appends a sub-selector to a existing one.
895 *
896 * Given the compounded $selector "h1, h2, h3"
897 * and the $to_append selector ".some-class" the result will be
898 * "h1.some-class, h2.some-class, h3.some-class".
899 *
900 * @param string $selector Original selector.
901 * @param string $to_append Selector to append.
902 * @return string
903 */
904 private static function append_to_selector( $selector, $to_append ) {
905 $new_selectors = array();
906 $selectors = explode( ',', $selector );
907 foreach ( $selectors as $sel ) {
908 $new_selectors[] = $sel . $to_append;
909 }
910
911 return implode( ',', $new_selectors );
912 }
913
914 /**
915 * Given a settings array, it returns the generated rulesets
916 * for the preset classes.
917 *
918 * @param array $settings Settings to process.
919 * @param string $selector Selector wrapping the classes.
920 * @param array $origins List of origins to process.
921 * @return string The result of processing the presets.
922 */
923 private static function compute_preset_classes( $settings, $selector, $origins ) {
924 if ( self::ROOT_BLOCK_SELECTOR === $selector ) {
925 // Classes at the global level do not need any CSS prefixed,
926 // and we don't want to increase its specificity.
927 $selector = '';
928 }
929
930 $stylesheet = '';
931 foreach ( self::PRESETS_METADATA as $preset_metadata ) {
932 $slugs = self::get_settings_slugs( $settings, $preset_metadata, $origins );
933 foreach ( $preset_metadata['classes'] as $class => $property ) {
934 foreach ( $slugs as $slug ) {
935 $css_var = self::replace_slug_in_string( $preset_metadata['css_vars'], $slug );
936 $class_name = self::replace_slug_in_string( $class, $slug );
937 $stylesheet .= self::to_ruleset(
938 self::append_to_selector( $selector, $class_name ),
939 array(
940 array(
941 'name' => $property,
942 'value' => 'var(' . $css_var . ') !important',
943 ),
944 )
945 );
946 }
947 }
948 }
949
950 return $stylesheet;
951 }
952
953 /**
954 * Function that scopes a selector with another one. This works a bit like
955 * SCSS nesting except the `&` operator isn't supported.
956 *
957 * <code>
958 * $scope = '.a, .b .c';
959 * $selector = '> .x, .y';
960 * $merged = scope_selector( $scope, $selector );
961 * // $merged is '.a > .x, .a .y, .b .c > .x, .b .c .y'
962 * </code>
963 *
964 * @param string $scope Selector to scope to.
965 * @param string $selector Original selector.
966 *
967 * @return string Scoped selector.
968 */
969 private static function scope_selector( $scope, $selector ) {
970 $scopes = explode( ',', $scope );
971 $selectors = explode( ',', $selector );
972
973 $selectors_scoped = array();
974 foreach ( $scopes as $outer ) {
975 foreach ( $selectors as $inner ) {
976 $selectors_scoped[] = trim( $outer ) . ' ' . trim( $inner );
977 }
978 }
979
980 return implode( ', ', $selectors_scoped );
981 }
982
983 /**
984 * Gets preset values keyed by slugs based on settings and metadata.
985 *
986 * <code>
987 * $settings = array(
988 * 'typography' => array(
989 * 'fontFamilies' => array(
990 * array(
991 * 'slug' => 'sansSerif',
992 * 'fontFamily' => '"Helvetica Neue", sans-serif',
993 * ),
994 * array(
995 * 'slug' => 'serif',
996 * 'colors' => 'Georgia, serif',
997 * )
998 * ),
999 * ),
1000 * );
1001 * $meta = array(
1002 * 'path' => array( 'typography', 'fontFamilies' ),
1003 * 'value_key' => 'fontFamily',
1004 * );
1005 * $values_by_slug = get_settings_values_by_slug();
1006 * // $values_by_slug === array(
1007 * // 'sans-serif' => '"Helvetica Neue", sans-serif',
1008 * // 'serif' => 'Georgia, serif',
1009 * // );
1010 * </code>
1011 *
1012 * @param array $settings Settings to process.
1013 * @param array $preset_metadata One of the PRESETS_METADATA values.
1014 * @param array $origins List of origins to process.
1015 * @return array Array of presets where each key is a slug and each value is the preset value.
1016 */
1017 private static function get_settings_values_by_slug( $settings, $preset_metadata, $origins ) {
1018 $preset_per_origin = _wp_array_get( $settings, $preset_metadata['path'], array() );
1019
1020 $result = array();
1021 foreach ( $origins as $origin ) {
1022 if ( ! isset( $preset_per_origin[ $origin ] ) ) {
1023 continue;
1024 }
1025 foreach ( $preset_per_origin[ $origin ] as $preset ) {
1026 $slug = _wp_to_kebab_case( $preset['slug'] );
1027
1028 $value = '';
1029 if ( isset( $preset_metadata['value_key'] ) ) {
1030 $value_key = $preset_metadata['value_key'];
1031 $value = $preset[ $value_key ];
1032 } elseif (
1033 isset( $preset_metadata['value_func'] ) &&
1034 is_callable( $preset_metadata['value_func'] )
1035 ) {
1036 $value_func = $preset_metadata['value_func'];
1037 $value = call_user_func( $value_func, $preset );
1038 } else {
1039 // If we don't have a value, then don't add it to the result.
1040 continue;
1041 }
1042
1043 $result[ $slug ] = $value;
1044 }
1045 }
1046 return $result;
1047 }
1048
1049 /**
1050 * Similar to get_settings_values_by_slug, but doesn't compute the value.
1051 *
1052 * @param array $settings Settings to process.
1053 * @param array $preset_metadata One of the PRESETS_METADATA values.
1054 * @param array $origins List of origins to process.
1055 * @return array Array of presets where the key and value are both the slug.
1056 */
1057 private static function get_settings_slugs( $settings, $preset_metadata, $origins = self::VALID_ORIGINS ) {
1058 $preset_per_origin = _wp_array_get( $settings, $preset_metadata['path'], array() );
1059
1060 $result = array();
1061 foreach ( $origins as $origin ) {
1062 if ( ! isset( $preset_per_origin[ $origin ] ) ) {
1063 continue;
1064 }
1065 foreach ( $preset_per_origin[ $origin ] as $preset ) {
1066 $slug = _wp_to_kebab_case( $preset['slug'] );
1067
1068 // Use the array as a set so we don't get duplicates.
1069 $result[ $slug ] = $slug;
1070 }
1071 }
1072 return $result;
1073 }
1074
1075 /**
1076 * Transform a slug into a CSS Custom Property.
1077 *
1078 * @param string $input String to replace.
1079 * @param string $slug The slug value to use to generate the custom property.
1080 * @return string The CSS Custom Property. Something along the lines of --wp--preset--color--black.
1081 */
1082 private static function replace_slug_in_string( $input, $slug ) {
1083 return strtr( $input, array( '$slug' => $slug ) );
1084 }
1085
1086 /**
1087 * Given the block settings, it extracts the CSS Custom Properties
1088 * for the presets and adds them to the $declarations array
1089 * following the format:
1090 *
1091 * ```php
1092 * array(
1093 * 'name' => 'property_name',
1094 * 'value' => 'property_value,
1095 * )
1096 * ```
1097 *
1098 * @param array $settings Settings to process.
1099 * @param array $origins List of origins to process.
1100 * @return array Returns the modified $declarations.
1101 */
1102 private static function compute_preset_vars( $settings, $origins ) {
1103 $declarations = array();
1104 foreach ( self::PRESETS_METADATA as $preset_metadata ) {
1105 $values_by_slug = self::get_settings_values_by_slug( $settings, $preset_metadata, $origins );
1106 foreach ( $values_by_slug as $slug => $value ) {
1107 $declarations[] = array(
1108 'name' => self::replace_slug_in_string( $preset_metadata['css_vars'], $slug ),
1109 'value' => $value,
1110 );
1111 }
1112 }
1113
1114 return $declarations;
1115 }
1116
1117 /**
1118 * Given an array of settings, it extracts the CSS Custom Properties
1119 * for the custom values and adds them to the $declarations
1120 * array following the format:
1121 *
1122 * ```php
1123 * array(
1124 * 'name' => 'property_name',
1125 * 'value' => 'property_value,
1126 * )
1127 * ```
1128 *
1129 * @param array $settings Settings to process.
1130 * @return array Returns the modified $declarations.
1131 */
1132 private static function compute_theme_vars( $settings ) {
1133 $declarations = array();
1134 $custom_values = _wp_array_get( $settings, array( 'custom' ), array() );
1135 $css_vars = self::flatten_tree( $custom_values );
1136 foreach ( $css_vars as $key => $value ) {
1137 $declarations[] = array(
1138 'name' => '--wp--custom--' . $key,
1139 'value' => $value,
1140 );
1141 }
1142
1143 return $declarations;
1144 }
1145
1146 /**
1147 * Given a tree, it creates a flattened one
1148 * by merging the keys and binding the leaf values
1149 * to the new keys.
1150 *
1151 * It also transforms camelCase names into kebab-case
1152 * and substitutes '/' by '-'.
1153 *
1154 * This is thought to be useful to generate
1155 * CSS Custom Properties from a tree,
1156 * although there's nothing in the implementation
1157 * of this function that requires that format.
1158 *
1159 * For example, assuming the given prefix is '--wp'
1160 * and the token is '--', for this input tree:
1161 *
1162 * {
1163 * 'some/property': 'value',
1164 * 'nestedProperty': {
1165 * 'sub-property': 'value'
1166 * }
1167 * }
1168 *
1169 * it'll return this output:
1170 *
1171 * {
1172 * '--wp--some-property': 'value',
1173 * '--wp--nested-property--sub-property': 'value'
1174 * }
1175 *
1176 * @param array $tree Input tree to process.
1177 * @param string $prefix Optional. Prefix to prepend to each variable. Default empty string.
1178 * @param string $token Optional. Token to use between levels. Default '--'.
1179 * @return array The flattened tree.
1180 */
1181 private static function flatten_tree( $tree, $prefix = '', $token = '--' ) {
1182 $result = array();
1183 foreach ( $tree as $property => $value ) {
1184 $new_key = $prefix . str_replace(
1185 '/',
1186 '-',
1187 strtolower( _wp_to_kebab_case( $property ) )
1188 );
1189
1190 if ( is_array( $value ) ) {
1191 $new_prefix = $new_key . $token;
1192 $result = array_merge(
1193 $result,
1194 self::flatten_tree( $value, $new_prefix, $token )
1195 );
1196 } else {
1197 $result[ $new_key ] = $value;
1198 }
1199 }
1200 return $result;
1201 }
1202
1203 /**
1204 * Given a styles array, it extracts the style properties
1205 * and adds them to the $declarations array following the format:
1206 *
1207 * ```php
1208 * array(
1209 * 'name' => 'property_name',
1210 * 'value' => 'property_value,
1211 * )
1212 * ```
1213 *
1214 * @param array $styles Styles to process.
1215 * @param array $settings Theme settings.
1216 * @param array $properties Properties metadata.
1217 * @return array Returns the modified $declarations.
1218 */
1219 private static function compute_style_properties( $styles, $settings = array(), $properties = self::PROPERTIES_METADATA ) {
1220 $declarations = array();
1221 if ( empty( $styles ) ) {
1222 return $declarations;
1223 }
1224
1225 foreach ( $properties as $css_property => $value_path ) {
1226 $value = self::get_property_value( $styles, $value_path );
1227
1228 // Look up protected properties, keyed by value path.
1229 // Skip protected properties that are explicitly set to `null`.
1230 if ( is_array( $value_path ) ) {
1231 $path_string = implode( '.', $value_path );
1232 if (
1233 array_key_exists( $path_string, self::PROTECTED_PROPERTIES ) &&
1234 _wp_array_get( $settings, self::PROTECTED_PROPERTIES[ $path_string ], null ) === null
1235 ) {
1236 continue;
1237 }
1238 }
1239
1240 // Skip if empty and not "0" or value represents array of longhand values.
1241 $has_missing_value = empty( $value ) && ! is_numeric( $value );
1242 if ( $has_missing_value || is_array( $value ) ) {
1243 continue;
1244 }
1245
1246 $declarations[] = array(
1247 'name' => $css_property,
1248 'value' => $value,
1249 );
1250 }
1251
1252 return $declarations;
1253 }
1254
1255 /**
1256 * Returns the style property for the given path.
1257 *
1258 * It also converts CSS Custom Property stored as
1259 * "var:preset|color|secondary" to the form
1260 * "--wp--preset--color--secondary".
1261 *
1262 * @param array $styles Styles subtree.
1263 * @param array $path Which property to process.
1264 * @return string Style property value.
1265 */
1266 private static function get_property_value( $styles, $path ) {
1267 $value = _wp_array_get( $styles, $path, '' );
1268
1269 if ( '' === $value || is_array( $value ) ) {
1270 return $value;
1271 }
1272
1273 $prefix = 'var:';
1274 $prefix_len = strlen( $prefix );
1275 $token_in = '|';
1276 $token_out = '--';
1277 if ( 0 === strncmp( $value, $prefix, $prefix_len ) ) {
1278 $unwrapped_name = str_replace(
1279 $token_in,
1280 $token_out,
1281 substr( $value, $prefix_len )
1282 );
1283 $value = "var(--wp--$unwrapped_name)";
1284 }
1285
1286 return $value;
1287 }
1288
1289 /**
1290 * Builds metadata for the setting nodes, which returns in the form of:
1291 *
1292 * [
1293 * [
1294 * 'path' => ['path', 'to', 'some', 'node' ],
1295 * 'selector' => 'CSS selector for some node'
1296 * ],
1297 * [
1298 * 'path' => [ 'path', 'to', 'other', 'node' ],
1299 * 'selector' => 'CSS selector for other node'
1300 * ],
1301 * ]
1302 *
1303 * @param array $theme_json The tree to extract setting nodes from.
1304 * @param array $selectors List of selectors per block.
1305 * @return array
1306 */
1307 private static function get_setting_nodes( $theme_json, $selectors = array() ) {
1308 $nodes = array();
1309 if ( ! isset( $theme_json['settings'] ) ) {
1310 return $nodes;
1311 }
1312
1313 // Top-level.
1314 $nodes[] = array(
1315 'path' => array( 'settings' ),
1316 'selector' => self::ROOT_BLOCK_SELECTOR,
1317 );
1318
1319 // Calculate paths for blocks.
1320 if ( ! isset( $theme_json['settings']['blocks'] ) ) {
1321 return $nodes;
1322 }
1323
1324 foreach ( $theme_json['settings']['blocks'] as $name => $node ) {
1325 $selector = null;
1326 if ( isset( $selectors[ $name ]['selector'] ) ) {
1327 $selector = $selectors[ $name ]['selector'];
1328 }
1329
1330 $nodes[] = array(
1331 'path' => array( 'settings', 'blocks', $name ),
1332 'selector' => $selector,
1333 );
1334 }
1335
1336 return $nodes;
1337 }
1338
1339 /**
1340 * Builds metadata for the style nodes, which returns in the form of:
1341 *
1342 * [
1343 * [
1344 * 'path' => [ 'path', 'to', 'some', 'node' ],
1345 * 'selector' => 'CSS selector for some node',
1346 * 'duotone' => 'CSS selector for duotone for some node'
1347 * ],
1348 * [
1349 * 'path' => ['path', 'to', 'other', 'node' ],
1350 * 'selector' => 'CSS selector for other node',
1351 * 'duotone' => null
1352 * ],
1353 * ]
1354 *
1355 * @param array $theme_json The tree to extract style nodes from.
1356 * @param array $selectors List of selectors per block.
1357 * @return array
1358 */
1359 private static function get_style_nodes( $theme_json, $selectors = array() ) {
1360 $nodes = array();
1361 if ( ! isset( $theme_json['styles'] ) ) {
1362 return $nodes;
1363 }
1364
1365 // Top-level.
1366 $nodes[] = array(
1367 'path' => array( 'styles' ),
1368 'selector' => self::ROOT_BLOCK_SELECTOR,
1369 );
1370
1371 if ( isset( $theme_json['styles']['elements'] ) ) {
1372 foreach ( $theme_json['styles']['elements'] as $element => $node ) {
1373 $nodes[] = array(
1374 'path' => array( 'styles', 'elements', $element ),
1375 'selector' => self::ELEMENTS[ $element ],
1376 );
1377 }
1378 }
1379
1380 // Blocks.
1381 if ( ! isset( $theme_json['styles']['blocks'] ) ) {
1382 return $nodes;
1383 }
1384
1385 foreach ( $theme_json['styles']['blocks'] as $name => $node ) {
1386 $selector = null;
1387 if ( isset( $selectors[ $name ]['selector'] ) ) {
1388 $selector = $selectors[ $name ]['selector'];
1389 }
1390
1391 $duotone_selector = null;
1392 if ( isset( $selectors[ $name ]['duotone'] ) ) {
1393 $duotone_selector = $selectors[ $name ]['duotone'];
1394 }
1395
1396 $nodes[] = array(
1397 'path' => array( 'styles', 'blocks', $name ),
1398 'selector' => $selector,
1399 'duotone' => $duotone_selector,
1400 );
1401
1402 if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) {
1403 foreach ( $theme_json['styles']['blocks'][ $name ]['elements'] as $element => $node ) {
1404 $nodes[] = array(
1405 'path' => array( 'styles', 'blocks', $name, 'elements', $element ),
1406 'selector' => $selectors[ $name ]['elements'][ $element ],
1407 );
1408 }
1409 }
1410 }
1411
1412 return $nodes;
1413 }
1414
1415 /**
1416 * Merge new incoming data.
1417 *
1418 * @param WP_Theme_JSON $incoming Data to merge.
1419 */
1420 public function merge( $incoming ) {
1421 $incoming_data = $incoming->get_raw_data();
1422 $this->theme_json = array_replace_recursive( $this->theme_json, $incoming_data );
1423
1424 /*
1425 * The array_replace_recursive algorithm merges at the leaf level,
1426 * but we don't want leaf arrays to be merged, so we overwrite it.
1427 *
1428 * For leaf values that are sequential arrays it will use the numeric indexes for replacement.
1429 * We rather replace the existing with the incoming value, if it exists.
1430 * This is the case of spacing.units.
1431 *
1432 * For leaf values that are associative arrays it will merge them as expected.
1433 * This is also not the behavior we want for the current associative arrays (presets).
1434 * We rather replace the existing with the incoming value, if it exists.
1435 * This happens, for example, when we merge data from theme.json upon existing
1436 * theme supports or when we merge anything coming from the same source twice.
1437 * This is the case of color.palette, color.gradients, color.duotone,
1438 * typography.fontSizes, or typography.fontFamilies.
1439 *
1440 * Additionally, for some preset types, we also want to make sure the
1441 * values they introduce don't conflict with default values. We do so
1442 * by checking the incoming slugs for theme presets and compare them
1443 * with the equivalent default presets: if a slug is present as a default
1444 * we remove it from the theme presets.
1445 */
1446 $nodes = self::get_setting_nodes( $incoming_data );
1447 $slugs_global = self::get_default_slugs( $this->theme_json, array( 'settings' ) );
1448 foreach ( $nodes as $node ) {
1449 $slugs_node = self::get_default_slugs( $this->theme_json, $node['path'] );
1450 $slugs = array_merge_recursive( $slugs_global, $slugs_node );
1451
1452 // Replace the spacing.units.
1453 $path = array_merge( $node['path'], array( 'spacing', 'units' ) );
1454 $content = _wp_array_get( $incoming_data, $path, null );
1455 if ( isset( $content ) ) {
1456 _wp_array_set( $this->theme_json, $path, $content );
1457 }
1458
1459 // Replace the presets.
1460 foreach ( self::PRESETS_METADATA as $preset ) {
1461 $override_preset = self::should_override_preset( $this->theme_json, $node['path'], $preset['override'] );
1462
1463 foreach ( self::VALID_ORIGINS as $origin ) {
1464 $base_path = array_merge( $node['path'], $preset['path'] );
1465 $path = array_merge( $base_path, array( $origin ) );
1466 $content = _wp_array_get( $incoming_data, $path, null );
1467 if ( ! isset( $content ) ) {
1468 continue;
1469 }
1470
1471 if ( 'theme' === $origin && $preset['use_default_names'] ) {
1472 foreach ( $content as &$item ) {
1473 if ( ! array_key_exists( 'name', $item ) ) {
1474 $name = self::get_name_from_defaults( $item['slug'], $base_path );
1475 if ( null !== $name ) {
1476 $item['name'] = $name;
1477 }
1478 }
1479 }
1480 }
1481
1482 if (
1483 ( 'theme' !== $origin ) ||
1484 ( 'theme' === $origin && $override_preset )
1485 ) {
1486 _wp_array_set( $this->theme_json, $path, $content );
1487 } else {
1488 $slugs_for_preset = _wp_array_get( $slugs, $preset['path'], array() );
1489 $content = self::filter_slugs( $content, $slugs_for_preset );
1490 _wp_array_set( $this->theme_json, $path, $content );
1491 }
1492 }
1493 }
1494 }
1495 }
1496
1497 /**
1498 * Converts all filter (duotone) presets into SVGs.
1499 *
1500 * @param array $origins List of origins to process.
1501 *
1502 * @return string SVG filters.
1503 */
1504 public function get_svg_filters( $origins ) {
1505 $blocks_metadata = self::get_blocks_metadata();
1506 $setting_nodes = self::get_setting_nodes( $this->theme_json, $blocks_metadata );
1507
1508 foreach ( $setting_nodes as $metadata ) {
1509 $node = _wp_array_get( $this->theme_json, $metadata['path'], array() );
1510 if ( empty( $node['color']['duotone'] ) ) {
1511 continue;
1512 }
1513
1514 $duotone_presets = $node['color']['duotone'];
1515
1516 $filters = '';
1517 foreach ( $origins as $origin ) {
1518 if ( ! isset( $duotone_presets[ $origin ] ) ) {
1519 continue;
1520 }
1521 foreach ( $duotone_presets[ $origin ] as $duotone_preset ) {
1522 $filters .= gutenberg_get_duotone_filter_svg( $duotone_preset );
1523 }
1524 }
1525 }
1526
1527 return $filters;
1528 }
1529
1530 /**
1531 * Returns whether a presets should be overriden or not.
1532 *
1533 * @param array $theme_json The theme.json like structure to inspect.
1534 * @param array $path Path to inspect.
1535 * @param bool|array $override Data to compute whether to override the preset.
1536 * @return boolean
1537 */
1538 private static function should_override_preset( $theme_json, $path, $override ) {
1539 if ( is_bool( $override ) ) {
1540 return $override;
1541 }
1542
1543 // The relationship between whether to override the defaults
1544 // and whether the defaults are enabled is inverse:
1545 //
1546 // - If defaults are enabled => theme presets should not be overriden
1547 // - If defaults are disabled => theme presets should be overriden
1548 //
1549 // For example, a theme sets defaultPalette to false,
1550 // making the default palette hidden from the user.
1551 // In that case, we want all the theme presets to be present,
1552 // so they should override the defaults.
1553 if ( is_array( $override ) ) {
1554 $value = _wp_array_get( $theme_json, array_merge( $path, $override ) );
1555 if ( isset( $value ) ) {
1556 return ! $value;
1557 }
1558
1559 // Search the top-level key if none was found for this node.
1560 $value = _wp_array_get( $theme_json, array_merge( array( 'settings' ), $override ) );
1561 if ( isset( $value ) ) {
1562 return ! $value;
1563 }
1564
1565 return true;
1566 }
1567 }
1568
1569 /**
1570 * Returns the default slugs for all the presets in an associative array
1571 * whose keys are the preset paths and the leafs is the list of slugs.
1572 *
1573 * For example:
1574 *
1575 * array(
1576 * 'color' => array(
1577 * 'palette' => array( 'slug-1', 'slug-2' ),
1578 * 'gradients' => array( 'slug-3', 'slug-4' ),
1579 * ),
1580 * )
1581 *
1582 * @param array $data A theme.json like structure.
1583 * @param array $node_path The path to inspect. It's 'settings' by default.
1584 *
1585 * @return array
1586 */
1587 private static function get_default_slugs( $data, $node_path ) {
1588 $slugs = array();
1589
1590 foreach ( self::PRESETS_METADATA as $metadata ) {
1591 $path = array_merge( $node_path, $metadata['path'], array( 'default' ) );
1592 $preset = _wp_array_get( $data, $path, null );
1593 if ( ! isset( $preset ) ) {
1594 continue;
1595 }
1596
1597 $slugs_for_preset = array();
1598 $slugs_for_preset = array_map(
1599 function( $value ) {
1600 return isset( $value['slug'] ) ? $value['slug'] : null;
1601 },
1602 $preset
1603 );
1604 _wp_array_set( $slugs, $metadata['path'], $slugs_for_preset );
1605 }
1606
1607 return $slugs;
1608 }
1609
1610 /**
1611 * Get a `default`'s preset name by a provided slug.
1612 *
1613 * @param string $slug The slug we want to find a match from default presets.
1614 * @param array $base_path The path to inspect. It's 'settings' by default.
1615 *
1616 * @return string|null
1617 */
1618 private function get_name_from_defaults( $slug, $base_path ) {
1619 $path = array_merge( $base_path, array( 'default' ) );
1620 $default_content = _wp_array_get( $this->theme_json, $path, null );
1621 if ( ! $default_content ) {
1622 return null;
1623 }
1624 foreach ( $default_content as $item ) {
1625 if ( $slug === $item['slug'] ) {
1626 return $item['name'];
1627 }
1628 }
1629 return null;
1630 }
1631
1632 /**
1633 * Removes the preset values whose slug is equal to any of given slugs.
1634 *
1635 * @param array $node The node with the presets to validate.
1636 * @param array $slugs The slugs that should not be overriden.
1637 *
1638 * @return array The new node
1639 */
1640 private static function filter_slugs( $node, $slugs ) {
1641 if ( empty( $slugs ) ) {
1642 return $node;
1643 }
1644
1645 $new_node = array();
1646 foreach ( $node as $value ) {
1647 if ( isset( $value['slug'] ) && ! in_array( $value['slug'], $slugs, true ) ) {
1648 $new_node[] = $value;
1649 }
1650 }
1651
1652 return $new_node;
1653 }
1654
1655 /**
1656 * Removes insecure data from theme.json.
1657 *
1658 * @param array $theme_json Structure to sanitize.
1659 * @return array Sanitized structure.
1660 */
1661 public static function remove_insecure_properties( $theme_json ) {
1662 $sanitized = array();
1663
1664 $theme_json = WP_Theme_JSON_Schema_Gutenberg::migrate( $theme_json );
1665
1666 $valid_block_names = array_keys( self::get_blocks_metadata() );
1667 $valid_element_names = array_keys( self::ELEMENTS );
1668 $theme_json = self::sanitize( $theme_json, $valid_block_names, $valid_element_names );
1669
1670 $blocks_metadata = self::get_blocks_metadata();
1671 $style_nodes = self::get_style_nodes( $theme_json, $blocks_metadata );
1672 foreach ( $style_nodes as $metadata ) {
1673 $input = _wp_array_get( $theme_json, $metadata['path'], array() );
1674 if ( empty( $input ) ) {
1675 continue;
1676 }
1677
1678 $output = self::remove_insecure_styles( $input );
1679 if ( ! empty( $output ) ) {
1680 _wp_array_set( $sanitized, $metadata['path'], $output );
1681 }
1682 }
1683
1684 $setting_nodes = self::get_setting_nodes( $theme_json );
1685 foreach ( $setting_nodes as $metadata ) {
1686 $input = _wp_array_get( $theme_json, $metadata['path'], array() );
1687 if ( empty( $input ) ) {
1688 continue;
1689 }
1690
1691 $output = self::remove_insecure_settings( $input );
1692 if ( ! empty( $output ) ) {
1693 _wp_array_set( $sanitized, $metadata['path'], $output );
1694 }
1695 }
1696
1697 if ( empty( $sanitized['styles'] ) ) {
1698 unset( $theme_json['styles'] );
1699 } else {
1700 $theme_json['styles'] = $sanitized['styles'];
1701 }
1702
1703 if ( empty( $sanitized['settings'] ) ) {
1704 unset( $theme_json['settings'] );
1705 } else {
1706 $theme_json['settings'] = $sanitized['settings'];
1707 }
1708
1709 return $theme_json;
1710 }
1711
1712 /**
1713 * Processes a setting node and returns the same node
1714 * without the insecure settings.
1715 *
1716 * @param array $input Node to process.
1717 * @return array
1718 */
1719 private static function remove_insecure_settings( $input ) {
1720 $output = array();
1721 foreach ( self::PRESETS_METADATA as $preset_metadata ) {
1722 foreach ( self::VALID_ORIGINS as $origin ) {
1723 $path_with_origin = array_merge( $preset_metadata['path'], array( $origin ) );
1724 $presets = _wp_array_get( $input, $path_with_origin, null );
1725 if ( null === $presets ) {
1726 continue;
1727 }
1728
1729 $escaped_preset = array();
1730 foreach ( $presets as $preset ) {
1731 if (
1732 esc_attr( esc_html( $preset['name'] ) ) === $preset['name'] &&
1733 sanitize_html_class( $preset['slug'] ) === $preset['slug']
1734 ) {
1735 $value = null;
1736 if ( isset( $preset_metadata['value_key'] ) ) {
1737 $value = $preset[ $preset_metadata['value_key'] ];
1738 } elseif (
1739 isset( $preset_metadata['value_func'] ) &&
1740 is_callable( $preset_metadata['value_func'] )
1741 ) {
1742 $value = call_user_func( $preset_metadata['value_func'], $preset );
1743 }
1744
1745 $preset_is_valid = true;
1746 foreach ( $preset_metadata['properties'] as $property ) {
1747 if ( ! self::is_safe_css_declaration( $property, $value ) ) {
1748 $preset_is_valid = false;
1749 break;
1750 }
1751 }
1752
1753 if ( $preset_is_valid ) {
1754 $escaped_preset[] = $preset;
1755 }
1756 }
1757 }
1758
1759 if ( ! empty( $escaped_preset ) ) {
1760 _wp_array_set( $output, $path_with_origin, $escaped_preset );
1761 }
1762 }
1763 }
1764 return $output;
1765 }
1766
1767 /**
1768 * Processes a style node and returns the same node
1769 * without the insecure styles.
1770 *
1771 * @param array $input Node to process.
1772 * @return array
1773 */
1774 private static function remove_insecure_styles( $input ) {
1775 $output = array();
1776 $declarations = self::compute_style_properties( $input );
1777
1778 foreach ( $declarations as $declaration ) {
1779 if ( self::is_safe_css_declaration( $declaration['name'], $declaration['value'] ) ) {
1780 $path = self::PROPERTIES_METADATA[ $declaration['name'] ];
1781
1782 // Check the value isn't an array before adding so as to not
1783 // double up shorthand and longhand styles.
1784 $value = _wp_array_get( $input, $path, array() );
1785 if ( ! is_array( $value ) ) {
1786 _wp_array_set( $output, $path, $value );
1787 }
1788 }
1789 }
1790 return $output;
1791 }
1792
1793 /**
1794 * Checks that a declaration provided by the user is safe.
1795 *
1796 * @param string $property_name Property name in a CSS declaration, i.e. the `color` in `color: red`.
1797 * @param string $property_value Value in a CSS declaration, i.e. the `red` in `color: red`.
1798 * @return boolean
1799 */
1800 private static function is_safe_css_declaration( $property_name, $property_value ) {
1801 $style_to_validate = $property_name . ': ' . $property_value;
1802 $filtered = esc_html( safecss_filter_attr( $style_to_validate ) );
1803 return ! empty( trim( $filtered ) );
1804 }
1805
1806 /**
1807 * Returns the raw data.
1808 *
1809 * @return array Raw data.
1810 */
1811 public function get_raw_data() {
1812 return $this->theme_json;
1813 }
1814
1815 /**
1816 * Transforms the given editor settings according the
1817 * add_theme_support format to the theme.json format.
1818 *
1819 * @param array $settings Existing editor settings.
1820 * @return array Config that adheres to the theme.json schema.
1821 */
1822 public static function get_from_editor_settings( $settings ) {
1823 $theme_settings = array(
1824 'version' => self::LATEST_SCHEMA,
1825 'settings' => array(),
1826 );
1827
1828 // Deprecated theme supports.
1829 if ( isset( $settings['disableCustomColors'] ) ) {
1830 if ( ! isset( $theme_settings['settings']['color'] ) ) {
1831 $theme_settings['settings']['color'] = array();
1832 }
1833 $theme_settings['settings']['color']['custom'] = ! $settings['disableCustomColors'];
1834 }
1835
1836 if ( isset( $settings['disableCustomGradients'] ) ) {
1837 if ( ! isset( $theme_settings['settings']['color'] ) ) {
1838 $theme_settings['settings']['color'] = array();
1839 }
1840 $theme_settings['settings']['color']['customGradient'] = ! $settings['disableCustomGradients'];
1841 }
1842
1843 if ( isset( $settings['disableCustomFontSizes'] ) ) {
1844 if ( ! isset( $theme_settings['settings']['typography'] ) ) {
1845 $theme_settings['settings']['typography'] = array();
1846 }
1847 $theme_settings['settings']['typography']['customFontSize'] = ! $settings['disableCustomFontSizes'];
1848 }
1849
1850 if ( isset( $settings['enableCustomLineHeight'] ) ) {
1851 if ( ! isset( $theme_settings['settings']['typography'] ) ) {
1852 $theme_settings['settings']['typography'] = array();
1853 }
1854 $theme_settings['settings']['typography']['lineHeight'] = $settings['enableCustomLineHeight'];
1855 }
1856
1857 if ( isset( $settings['enableCustomUnits'] ) ) {
1858 if ( ! isset( $theme_settings['settings']['spacing'] ) ) {
1859 $theme_settings['settings']['spacing'] = array();
1860 }
1861 $theme_settings['settings']['spacing']['units'] = ( true === $settings['enableCustomUnits'] ) ?
1862 array( 'px', 'em', 'rem', 'vh', 'vw', '%' ) :
1863 $settings['enableCustomUnits'];
1864 }
1865
1866 if ( isset( $settings['colors'] ) ) {
1867 if ( ! isset( $theme_settings['settings']['color'] ) ) {
1868 $theme_settings['settings']['color'] = array();
1869 }
1870 $theme_settings['settings']['color']['palette'] = $settings['colors'];
1871 }
1872
1873 if ( isset( $settings['gradients'] ) ) {
1874 if ( ! isset( $theme_settings['settings']['color'] ) ) {
1875 $theme_settings['settings']['color'] = array();
1876 }
1877 $theme_settings['settings']['color']['gradients'] = $settings['gradients'];
1878 }
1879
1880 if ( isset( $settings['fontSizes'] ) ) {
1881 $font_sizes = $settings['fontSizes'];
1882 // Back-compatibility for presets without units.
1883 foreach ( $font_sizes as $key => $font_size ) {
1884 if ( is_numeric( $font_size['size'] ) ) {
1885 $font_sizes[ $key ]['size'] = $font_size['size'] . 'px';
1886 }
1887 }
1888 if ( ! isset( $theme_settings['settings']['typography'] ) ) {
1889 $theme_settings['settings']['typography'] = array();
1890 }
1891 $theme_settings['settings']['typography']['fontSizes'] = $font_sizes;
1892 }
1893
1894 // This allows to make the plugin work with WordPress 5.7 beta
1895 // as well as lower versions. The second check can be removed
1896 // as soon as the minimum WordPress version for the plugin
1897 // is bumped to 5.7.
1898 if ( isset( $settings['enableCustomSpacing'] ) ) {
1899 if ( ! isset( $theme_settings['settings']['spacing'] ) ) {
1900 $theme_settings['settings']['spacing'] = array();
1901 }
1902 $theme_settings['settings']['spacing']['padding'] = $settings['enableCustomSpacing'];
1903 }
1904
1905 // Things that didn't land in core yet, so didn't have a setting assigned.
1906 // This should be removed when the plugin minimum WordPress version
1907 // is bumped to 5.8.
1908 //
1909 // Do not port this to WordPress core.
1910 if ( current( (array) get_theme_support( 'experimental-link-color' ) ) ) {
1911 if ( ! isset( $theme_settings['settings']['color'] ) ) {
1912 $theme_settings['settings']['color'] = array();
1913 }
1914 $theme_settings['settings']['color']['link'] = true;
1915 }
1916
1917 return $theme_settings;
1918 }
1919
1920 }
1921