PluginProbe
Gutenberg / 23.2.2
Gutenberg v23.2.2
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 / class-wp-theme-json-gutenberg.php

class-wp-theme-json-gutenberg.php in Gutenberg 23.2.2, at lib/class-wp-theme-json-gutenberg.php

5,452 lines 196.1 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 * @since 5.8.0
7 */
8
9 /**
10 * Class that encapsulates the processing of structures that adhere to the theme.json spec.
11 *
12 * This class is for internal core usage and is not supposed to be used by extenders (plugins and/or themes).
13 * This is a low-level API that may need to do breaking changes. Please,
14 * use gutenberg_get_global_settings, gutenberg_get_global_styles, and gutenberg_get_global_stylesheet instead.
15 *
16 * @access private
17 */
18 #[AllowDynamicProperties]
19 class WP_Theme_JSON_Gutenberg {
20
21 /**
22 * Container of data in theme.json format.
23 *
24 * @since 5.8.0
25 * @var array
26 */
27 protected $theme_json = null;
28
29 /**
30 * Holds block metadata extracted from block.json
31 * to be shared among all instances so we don't
32 * process it twice.
33 *
34 * @since 5.8.0
35 * @since 6.1.0 Initialize as an empty array.
36 * @var array
37 */
38 protected static $blocks_metadata = array();
39
40 /**
41 * The CSS selector for the top-level preset settings.
42 *
43 * @since 6.6.0
44 * @var string
45 */
46 const ROOT_CSS_PROPERTIES_SELECTOR = ':root';
47
48 /**
49 * The CSS selector for the top-level styles.
50 *
51 * @since 5.8.0
52 * @var string
53 */
54 const ROOT_BLOCK_SELECTOR = 'body';
55
56 /**
57 * The sources of data this object can represent.
58 *
59 * @since 5.8.0
60 * @since 6.1.0 Added 'blocks'.
61 * @var string[]
62 */
63 const VALID_ORIGINS = array(
64 'default',
65 'blocks',
66 'theme',
67 'custom',
68 );
69
70 /**
71 * Presets are a set of values that serve
72 * to bootstrap some styles: colors, font sizes, etc.
73 *
74 * They are a unkeyed array of values such as:
75 *
76 * ```php
77 * array(
78 * array(
79 * 'slug' => 'unique-name-within-the-set',
80 * 'name' => 'Name for the UI',
81 * <value_key> => 'value'
82 * ),
83 * )
84 * ```
85 *
86 * This contains the necessary metadata to process them:
87 *
88 * - path => Where to find the preset within the settings section.
89 * - prevent_override => Disables override of default presets by theme presets.
90 * The relationship between whether to override the defaults
91 * and whether the defaults are enabled is inverse:
92 * - If defaults are enabled => theme presets should not be overridden
93 * - If defaults are disabled => theme presets should be overridden
94 * For example, a theme sets defaultPalette to false,
95 * making the default palette hidden from the user.
96 * In that case, we want all the theme presets to be present,
97 * so they should override the defaults by setting this false.
98 * - use_default_names => whether to use the default names
99 * - value_key => the key that represents the value
100 * - value_func => optionally, instead of value_key, a function to generate
101 * the value that takes a preset as an argument
102 * (either value_key or value_func should be present)
103 * - css_vars => template string to use in generating the CSS Custom Property.
104 * Example output: "--wp--preset--duotone--blue: <value>" will generate as many CSS Custom Properties as presets defined
105 * substituting the $slug for the slug's value for each preset value.
106 * - classes => array containing a structure with the classes to
107 * generate for the presets, where for each array item
108 * the key is the class name and the value the property name.
109 * The "$slug" substring will be replaced by the slug of each preset.
110 * For example:
111 * 'classes' => array(
112 * '.has-$slug-color' => 'color',
113 * '.has-$slug-background-color' => 'background-color',
114 * '.has-$slug-border-color' => 'border-color',
115 * )
116 * - properties => array of CSS properties to be used by kses to
117 * validate the content of each preset
118 * by means of the remove_insecure_properties method.
119 *
120 * @since 5.8.0
121 * @since 5.9.0 Added the `color.duotone` and `typography.fontFamilies` presets,
122 * `use_default_names` preset key, and simplified the metadata structure.
123 * @since 6.0.0 Replaced `override` with `prevent_override` and updated the
124 * `prevent_override` value for `color.duotone` to use `color.defaultDuotone`.
125 * @since 6.2.0 Added 'shadow' presets.
126 * @since 6.6.0 Updated the 'prevent_override' value for font size presets to use 'typography.defaultFontSizes' and spacing size presets to use `spacing.defaultSpacingSizes`.
127 * @since 6.6.0 Added `aspectRatios`.
128 * @var array
129 */
130 const PRESETS_METADATA = array(
131 array(
132 'path' => array( 'dimensions', 'aspectRatios' ),
133 'prevent_override' => array( 'dimensions', 'defaultAspectRatios' ),
134 'use_default_names' => false,
135 'value_key' => 'ratio',
136 'css_vars' => '--wp--preset--aspect-ratio--$slug',
137 'classes' => array(),
138 'properties' => array( 'aspect-ratio' ),
139 ),
140 array(
141 'path' => array( 'color', 'palette' ),
142 'prevent_override' => array( 'color', 'defaultPalette' ),
143 'use_default_names' => false,
144 'value_key' => 'color',
145 'css_vars' => '--wp--preset--color--$slug',
146 'classes' => array(
147 '.has-$slug-color' => 'color',
148 '.has-$slug-background-color' => 'background-color',
149 '.has-$slug-border-color' => 'border-color',
150 ),
151 'properties' => array( 'color', 'background-color', 'border-color' ),
152 ),
153 array(
154 'path' => array( 'color', 'gradients' ),
155 'prevent_override' => array( 'color', 'defaultGradients' ),
156 'use_default_names' => false,
157 'value_key' => 'gradient',
158 'css_vars' => '--wp--preset--gradient--$slug',
159 'classes' => array( '.has-$slug-gradient-background' => 'background' ),
160 'properties' => array( 'background' ),
161 ),
162 array(
163 'path' => array( 'color', 'duotone' ),
164 'prevent_override' => array( 'color', 'defaultDuotone' ),
165 'use_default_names' => false,
166 'value_func' => null, // CSS Custom Properties for duotone are handled by block supports in class-wp-duotone-gutenberg.php.
167 'css_vars' => null,
168 'classes' => array(),
169 'properties' => array( 'filter' ),
170 ),
171 array(
172 'path' => array( 'typography', 'fontSizes' ),
173 'prevent_override' => array( 'typography', 'defaultFontSizes' ),
174 'use_default_names' => true,
175 'value_func' => 'gutenberg_get_typography_font_size_value',
176 'css_vars' => '--wp--preset--font-size--$slug',
177 'classes' => array( '.has-$slug-font-size' => 'font-size' ),
178 'properties' => array( 'font-size' ),
179 ),
180 array(
181 'path' => array( 'typography', 'fontFamilies' ),
182 'prevent_override' => false,
183 'use_default_names' => false,
184 'value_key' => 'fontFamily',
185 'css_vars' => '--wp--preset--font-family--$slug',
186 'classes' => array( '.has-$slug-font-family' => 'font-family' ),
187 'properties' => array( 'font-family' ),
188 ),
189 array(
190 'path' => array( 'spacing', 'spacingSizes' ),
191 'prevent_override' => array( 'spacing', 'defaultSpacingSizes' ),
192 'use_default_names' => true,
193 'value_key' => 'size',
194 'css_vars' => '--wp--preset--spacing--$slug',
195 'classes' => array(),
196 'properties' => array( 'padding', 'margin' ),
197 ),
198 array(
199 'path' => array( 'shadow', 'presets' ),
200 'prevent_override' => array( 'shadow', 'defaultPresets' ),
201 'use_default_names' => false,
202 'value_key' => 'shadow',
203 'css_vars' => '--wp--preset--shadow--$slug',
204 'classes' => array(),
205 'properties' => array( 'box-shadow' ),
206 ),
207 array(
208 'path' => array( 'border', 'radiusSizes' ),
209 'prevent_override' => false,
210 'use_default_names' => false,
211 'value_key' => 'size',
212 'css_vars' => '--wp--preset--border-radius--$slug',
213 'classes' => array(),
214 'properties' => array( 'border-radius' ),
215 ),
216 array(
217 'path' => array( 'dimensions', 'dimensionSizes' ),
218 'prevent_override' => false,
219 'use_default_names' => false,
220 'value_key' => 'size',
221 'css_vars' => '--wp--preset--dimension--$slug',
222 'classes' => array(),
223 'properties' => array( 'width', 'height', 'min-height' ),
224 ),
225 );
226
227 /**
228 * Metadata for style properties.
229 *
230 * Each element is a direct mapping from the CSS property name to the
231 * path to the value in theme.json & block attributes.
232 *
233 * @since 5.8.0
234 * @since 5.9.0 Added the `border-*`, `font-family`, `font-style`, `font-weight`,
235 * `letter-spacing`, `margin-*`, `padding-*`, `--wp--style--block-gap`,
236 * `text-decoration`, `text-transform`, and `filter` properties,
237 * simplified the metadata structure.
238 * @since 6.1.0 Added the `border-*-color`, `border-*-width`, `border-*-style`,
239 * `--wp--style--root--padding-*`, and `box-shadow` properties,
240 * removed the `--wp--style--block-gap` property.
241 * @since 6.2.0 Added `outline-*`, and `min-height` properties.
242 * @since 6.3.0 Added `writing-mode` property.
243 * @since 6.6.0 Added `background-[image|position|repeat|size]` properties.
244 * @since 7.0.0 Added `dimensions.width`, `dimensions.height`. and
245 * `typography.textIndent` properties.
246 *
247 * @var array
248 */
249 const PROPERTIES_METADATA = array(
250 'aspect-ratio' => array( 'dimensions', 'aspectRatio' ),
251 'background' => array( 'color', 'gradient' ),
252 'background-color' => array( 'color', 'background' ),
253 'background-image' => array( 'background', 'backgroundImage' ),
254 'background-position' => array( 'background', 'backgroundPosition' ),
255 'background-repeat' => array( 'background', 'backgroundRepeat' ),
256 'background-size' => array( 'background', 'backgroundSize' ),
257 'background-attachment' => array( 'background', 'backgroundAttachment' ),
258 'border-radius' => array( 'border', 'radius' ),
259 'border-top-left-radius' => array( 'border', 'radius', 'topLeft' ),
260 'border-top-right-radius' => array( 'border', 'radius', 'topRight' ),
261 'border-bottom-left-radius' => array( 'border', 'radius', 'bottomLeft' ),
262 'border-bottom-right-radius' => array( 'border', 'radius', 'bottomRight' ),
263 'border-color' => array( 'border', 'color' ),
264 'border-width' => array( 'border', 'width' ),
265 'border-style' => array( 'border', 'style' ),
266 'border-top-color' => array( 'border', 'top', 'color' ),
267 'border-top-width' => array( 'border', 'top', 'width' ),
268 'border-top-style' => array( 'border', 'top', 'style' ),
269 'border-right-color' => array( 'border', 'right', 'color' ),
270 'border-right-width' => array( 'border', 'right', 'width' ),
271 'border-right-style' => array( 'border', 'right', 'style' ),
272 'border-bottom-color' => array( 'border', 'bottom', 'color' ),
273 'border-bottom-width' => array( 'border', 'bottom', 'width' ),
274 'border-bottom-style' => array( 'border', 'bottom', 'style' ),
275 'border-left-color' => array( 'border', 'left', 'color' ),
276 'border-left-width' => array( 'border', 'left', 'width' ),
277 'border-left-style' => array( 'border', 'left', 'style' ),
278 'color' => array( 'color', 'text' ),
279 'text-align' => array( 'typography', 'textAlign' ),
280 'column-count' => array( 'typography', 'textColumns' ),
281 'font-family' => array( 'typography', 'fontFamily' ),
282 'font-size' => array( 'typography', 'fontSize' ),
283 'font-style' => array( 'typography', 'fontStyle' ),
284 'font-weight' => array( 'typography', 'fontWeight' ),
285 'letter-spacing' => array( 'typography', 'letterSpacing' ),
286 'line-height' => array( 'typography', 'lineHeight' ),
287 'margin' => array( 'spacing', 'margin' ),
288 'margin-top' => array( 'spacing', 'margin', 'top' ),
289 'margin-right' => array( 'spacing', 'margin', 'right' ),
290 'margin-bottom' => array( 'spacing', 'margin', 'bottom' ),
291 'margin-left' => array( 'spacing', 'margin', 'left' ),
292 'min-height' => array( 'dimensions', 'minHeight' ),
293 'min-width' => array( 'dimensions', 'minWidth' ),
294 'outline-color' => array( 'outline', 'color' ),
295 'outline-offset' => array( 'outline', 'offset' ),
296 'outline-style' => array( 'outline', 'style' ),
297 'outline-width' => array( 'outline', 'width' ),
298 'padding' => array( 'spacing', 'padding' ),
299 'padding-top' => array( 'spacing', 'padding', 'top' ),
300 'padding-right' => array( 'spacing', 'padding', 'right' ),
301 'padding-bottom' => array( 'spacing', 'padding', 'bottom' ),
302 'padding-left' => array( 'spacing', 'padding', 'left' ),
303 '--wp--style--root--padding' => array( 'spacing', 'padding' ),
304 '--wp--style--root--padding-top' => array( 'spacing', 'padding', 'top' ),
305 '--wp--style--root--padding-right' => array( 'spacing', 'padding', 'right' ),
306 '--wp--style--root--padding-bottom' => array( 'spacing', 'padding', 'bottom' ),
307 '--wp--style--root--padding-left' => array( 'spacing', 'padding', 'left' ),
308 'text-decoration' => array( 'typography', 'textDecoration' ),
309 'text-transform' => array( 'typography', 'textTransform' ),
310 'text-indent' => array( 'typography', 'textIndent' ),
311 'filter' => array( 'filter', 'duotone' ),
312 'box-shadow' => array( 'shadow' ),
313 'height' => array( 'dimensions', 'height' ),
314 'width' => array( 'dimensions', 'width' ),
315 'writing-mode' => array( 'typography', 'writingMode' ),
316 );
317
318 /**
319 * Indirect metadata for style properties that are not directly output.
320 *
321 * Each element maps from a CSS property name to an array of
322 * paths to the value in theme.json & block attributes.
323 *
324 * Indirect properties are not output directly by `compute_style_properties`,
325 * but are used elsewhere in the processing of global styles. The indirect
326 * property is used to validate whether a style value is allowed.
327 *
328 * @since 6.2.0
329 * @since 6.6.0 Added background-image properties.
330 *
331 * @var array
332 */
333 const INDIRECT_PROPERTIES_METADATA = array(
334 'gap' => array(
335 array( 'spacing', 'blockGap' ),
336 ),
337 'column-gap' => array(
338 array( 'spacing', 'blockGap', 'left' ),
339 ),
340 'row-gap' => array(
341 array( 'spacing', 'blockGap', 'top' ),
342 ),
343 'max-width' => array(
344 array( 'layout', 'contentSize' ),
345 array( 'layout', 'wideSize' ),
346 ),
347 'background-image' => array(
348 array( 'background', 'backgroundImage', 'url' ),
349 array( 'background', 'gradient' ),
350 ),
351 );
352
353
354
355 /**
356 * The top-level keys a theme.json can have.
357 *
358 * @since 5.8.0 As `ALLOWED_TOP_LEVEL_KEYS`.
359 * @since 5.9.0 Renamed from `ALLOWED_TOP_LEVEL_KEYS` to `VALID_TOP_LEVEL_KEYS`,
360 * added the `customTemplates` and `templateParts` values.
361 * @since 6.3.0 Added the `description` value.
362 * @var string[]
363 */
364 const VALID_TOP_LEVEL_KEYS = array(
365 'blockTypes',
366 'customTemplates',
367 'description',
368 'patterns',
369 'settings',
370 'styles',
371 'templateParts',
372 'title',
373 'slug',
374 'version',
375 );
376
377 /**
378 * The valid properties under the settings key.
379 *
380 * @since 5.8.0 As `ALLOWED_SETTINGS`.
381 * @since 5.9.0 Renamed from `ALLOWED_SETTINGS` to `VALID_SETTINGS`,
382 * added new properties for `border`, `color`, `spacing`,
383 * and `typography`, and renamed others according to the new schema.
384 * @since 6.0.0 Added `color.defaultDuotone`.
385 * @since 6.1.0 Added `layout.definitions` and `useRootPaddingAwareAlignments`.
386 * @since 6.2.0 Added `dimensions.minHeight`, 'shadow.presets', 'shadow.defaultPresets',
387 * `position.fixed` and `position.sticky`.
388 * @since 6.3.0 Removed `layout.definitions`. Added `typography.writingMode`.
389 * @since 6.4.0 Added `layout.allowEditing`.
390 * @since 6.4.0 Added `lightbox`.
391 * @since 7.0.0 Added type markers to the schema for boolean values.
392 * @since 7.0.0 Added `dimensions.width`, `dimensions.height`. and
393 * `typography.textIndent` properties.
394 * @var array
395 */
396 const VALID_SETTINGS = array(
397 'appearanceTools' => null,
398 'useRootPaddingAwareAlignments' => null,
399 'background' => array(
400 'backgroundImage' => null,
401 'backgroundSize' => null,
402 'gradient' => null,
403 ),
404 'border' => array(
405 'color' => null,
406 'radius' => null,
407 'style' => null,
408 'width' => null,
409 'radiusSizes' => null,
410 ),
411 'color' => array(
412 'background' => null,
413 'custom' => null,
414 'customDuotone' => null,
415 'customGradient' => null,
416 'defaultDuotone' => null,
417 'defaultGradients' => null,
418 'defaultPalette' => null,
419 'duotone' => null,
420 'gradients' => null,
421 'link' => null,
422 'heading' => null,
423 'button' => null,
424 'caption' => null,
425 'palette' => null,
426 'text' => null,
427 ),
428 'custom' => null,
429 'dimensions' => array(
430 'aspectRatio' => null,
431 'aspectRatios' => null,
432 'defaultAspectRatios' => null,
433 'dimensionSizes' => null,
434 'height' => null,
435 'minHeight' => null,
436 'minWidth' => null,
437 'width' => null,
438 ),
439 'layout' => array(
440 'contentSize' => null,
441 'wideSize' => null,
442 'allowEditing' => null,
443 'allowCustomContentAndWideSize' => null,
444 ),
445 'lightbox' => array(
446 'enabled' => true,
447 'allowEditing' => true,
448 ),
449 'position' => array(
450 'fixed' => null,
451 'sticky' => null,
452 ),
453 'spacing' => array(
454 'customSpacingSize' => null,
455 'defaultSpacingSizes' => null,
456 'spacingSizes' => null,
457 'spacingScale' => null,
458 'blockGap' => null,
459 'margin' => null,
460 'padding' => null,
461 'units' => null,
462 ),
463 'shadow' => array(
464 'presets' => null,
465 'defaultPresets' => null,
466 ),
467 'typography' => array(
468 'fluid' => null,
469 'customFontSize' => null,
470 'defaultFontSizes' => null,
471 'dropCap' => null,
472 'fontFamilies' => null,
473 'fontSizes' => null,
474 'fontStyle' => null,
475 'fontWeight' => null,
476 'letterSpacing' => null,
477 'lineHeight' => null,
478 'textAlign' => null,
479 'textColumns' => null,
480 'textDecoration' => null,
481 'textIndent' => null,
482 'textTransform' => null,
483 'writingMode' => null,
484 ),
485 );
486
487 const FONT_FAMILY_SCHEMA = array(
488 array(
489 'fontFamily' => null,
490 'name' => null,
491 'slug' => null,
492 'fontFace' => array(
493 array(
494 'ascentOverride' => null,
495 'descentOverride' => null,
496 'fontDisplay' => null,
497 'fontFamily' => null,
498 'fontFeatureSettings' => null,
499 'fontStyle' => null,
500 'fontStretch' => null,
501 'fontVariationSettings' => null,
502 'fontWeight' => null,
503 'lineGapOverride' => null,
504 'sizeAdjust' => null,
505 'src' => null,
506 'unicodeRange' => null,
507 ),
508 ),
509 ),
510 );
511
512 /**
513 * The valid properties under the styles key.
514 *
515 * @since 5.8.0 As `ALLOWED_STYLES`.
516 * @since 5.9.0 Renamed from `ALLOWED_STYLES` to `VALID_STYLES`,
517 * added new properties for `border`, `filter`, `spacing`,
518 * and `typography`.
519 * @since 6.1.0 Added new side properties for `border`,
520 * added new property `shadow`,
521 * updated `blockGap` to be allowed at any level.
522 * @since 6.2.0 Added `outline`, and `minHeight` properties.
523 * @since 6.6.0 Added `background` sub properties to top-level only.
524 * @since 6.6.0 Added `dimensions.aspectRatio`.
525 * @since 7.0.0 Added `dimensions.width`, `dimensions.height`. and
526 * `typography.textIndent` properties.
527 * @var array
528 */
529 const VALID_STYLES = array(
530 'background' => array(
531 'backgroundImage' => null,
532 'backgroundAttachment' => null,
533 'backgroundPosition' => null,
534 'backgroundRepeat' => null,
535 'backgroundSize' => null,
536 'gradient' => null,
537 ),
538 'border' => array(
539 'color' => null,
540 'radius' => null,
541 'style' => null,
542 'width' => null,
543 'top' => null,
544 'right' => null,
545 'bottom' => null,
546 'left' => null,
547 ),
548 'color' => array(
549 'background' => null,
550 'gradient' => null,
551 'text' => null,
552 ),
553 'dimensions' => array(
554 'aspectRatio' => null,
555 'height' => null,
556 'minHeight' => null,
557 'minWidth' => null,
558 'width' => null,
559 ),
560 'filter' => array(
561 'duotone' => null,
562 ),
563 'outline' => array(
564 'color' => null,
565 'offset' => null,
566 'style' => null,
567 'width' => null,
568 ),
569 'shadow' => null,
570 'spacing' => array(
571 'margin' => null,
572 'padding' => null,
573 'blockGap' => null,
574 ),
575 'typography' => array(
576 'fontFamily' => null,
577 'fontSize' => null,
578 'fontStyle' => null,
579 'fontWeight' => null,
580 'letterSpacing' => null,
581 'lineHeight' => null,
582 'textAlign' => null,
583 'textColumns' => null,
584 'textDecoration' => null,
585 'textIndent' => null,
586 'textTransform' => null,
587 'writingMode' => null,
588 ),
589 'css' => null,
590 );
591
592 /**
593 * Defines which pseudo selectors are enabled for which elements.
594 *
595 * The order of the selectors should be: link, any-link, visited, hover, focus, focus-visible, active.
596 * This is to ensure the user action (hover, focus and active) styles have a higher
597 * specificity than the visited styles, which in turn have a higher specificity than
598 * the unvisited styles.
599 *
600 * See https://core.trac.wordpress.org/ticket/56928.
601 * Note: this will affect both top-level and block-level elements.
602 *
603 * @since 6.1.0
604 * @since 6.2.0 Added support for `:link` and `:any-link`.
605 * @since 6.8.0 Added support for `:focus-visible`.
606 */
607 const VALID_ELEMENT_PSEUDO_SELECTORS = array(
608 'link' => array( ':link', ':any-link', ':visited', ':hover', ':focus', ':focus-visible', ':active' ),
609 'button' => array( ':link', ':any-link', ':visited', ':hover', ':focus', ':focus-visible', ':active' ),
610 );
611
612 /**
613 * The valid pseudo-selectors that can be used for blocks.
614 *
615 * @since 7.0.0
616 * @var array
617 */
618 const VALID_BLOCK_PSEUDO_SELECTORS = array(
619 'core/button' => array( ':hover', ':focus', ':focus-visible', ':active' ),
620 'core/navigation-link' => array( ':hover', ':focus', ':focus-visible', ':active' ),
621 );
622
623 /**
624 * Responsive breakpoint state keys and their corresponding CSS media queries.
625 * These are available for all blocks and wrap their styles in the given media query.
626 * Keep in sync with RESPONSIVE_BREAKPOINTS in packages/global-styles-engine/src/core/render.tsx.
627 *
628 * @since 7.1.0
629 * @var array
630 */
631 const RESPONSIVE_BREAKPOINTS = array(
632 'mobile' => '@media (width <= 480px)',
633 'tablet' => '@media (480px < width <= 782px)',
634 );
635
636 /**
637 * Custom states for blocks that map to CSS class selectors rather than
638 * CSS pseudo-selectors. Values use the '@' prefix (e.g. '@current') to
639 * distinguish them from real CSS pseudo-selectors.
640 *
641 * The CSS selector for each state is defined in the block's block.json
642 * under `selectors.states`, e.g.:
643 *
644 * "selectors": { "states": { "@current": ".some-css-selector" } }
645 *
646 * This constant controls which states are valid in theme.json for a given
647 * block. Blocks listed here also inherit their VALID_BLOCK_PSEUDO_SELECTORS
648 * as valid sub-states, producing compound selectors such as
649 * `.wp-block-navigation-item.current-menu-item:hover`.
650 *
651 * @var array
652 */
653 const VALID_BLOCK_CUSTOM_STATES = array(
654 'core/navigation-link' => array( '@current' ),
655 );
656
657 /**
658 * The valid elements that can be found under styles.
659 *
660 * @since 5.8.0
661 * @since 6.1.0 Added `heading`, `button`, and `caption` elements.
662 * @var string[]
663 */
664 const ELEMENTS = array(
665 'link' => 'a:where(:not(.wp-element-button))', // The `where` is needed to lower the specificity.
666 'heading' => 'h1, h2, h3, h4, h5, h6',
667 'h1' => 'h1',
668 'h2' => 'h2',
669 'h3' => 'h3',
670 'h4' => 'h4',
671 'h5' => 'h5',
672 'h6' => 'h6',
673 // We have the .wp-block-button__link class so that this will target older buttons that have been serialized.
674 'button' => '.wp-element-button, .wp-block-button__link',
675 // The block classes are necessary to target older content that won't use the new class names.
676 'caption' => '.wp-element-caption, .wp-block-audio figcaption, .wp-block-embed figcaption, .wp-block-gallery figcaption, .wp-block-image figcaption, .wp-block-table figcaption, .wp-block-video figcaption',
677 'cite' => 'cite',
678 'select' => 'select',
679 'textInput' => 'textarea, input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url])',
680 );
681
682 const __EXPERIMENTAL_ELEMENT_CLASS_NAMES = array(
683 'button' => 'wp-element-button',
684 'caption' => 'wp-element-caption',
685 );
686
687 /**
688 * List of block support features that can have their related styles
689 * generated under their own feature level selector rather than the block's.
690 *
691 * @since 6.1.0
692 * @var string[]
693 */
694 const BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS = array(
695 '__experimentalBorder' => 'border',
696 'color' => 'color',
697 'dimensions' => 'dimensions',
698 'spacing' => 'spacing',
699 'typography' => 'typography',
700 );
701
702 /**
703 * Return the input schema at the root and per origin.
704 *
705 * @since 6.5.0
706 *
707 * @param array $schema The base schema.
708 * @return array The schema at the root and per origin.
709 *
710 * Example:
711 * schema_in_root_and_per_origin(
712 * array(
713 * 'fontFamily' => null,
714 * 'slug' => null,
715 * )
716 * )
717 *
718 * Returns:
719 * array(
720 * 'fontFamily' => null,
721 * 'slug' => null,
722 * 'default' => array(
723 * 'fontFamily' => null,
724 * 'slug' => null,
725 * ),
726 * 'blocks' => array(
727 * 'fontFamily' => null,
728 * 'slug' => null,
729 * ),
730 * 'theme' => array(
731 * 'fontFamily' => null,
732 * 'slug' => null,
733 * ),
734 * 'custom' => array(
735 * 'fontFamily' => null,
736 * 'slug' => null,
737 * ),
738 * )
739 */
740 protected static function schema_in_root_and_per_origin( $schema ) {
741 $schema_in_root_and_per_origin = $schema;
742 foreach ( static::VALID_ORIGINS as $origin ) {
743 $schema_in_root_and_per_origin[ $origin ] = $schema;
744 }
745 return $schema_in_root_and_per_origin;
746 }
747
748 /**
749 * Processes pseudo-selectors for any node (block or variation).
750 *
751 * @param array $node The node data (block or variation).
752 * @param string $base_selector The base selector.
753 * @param array $settings The theme settings.
754 * @param string $block_name The block name.
755 * @return array Array of pseudo-selector declarations.
756 */
757 private static function process_pseudo_selectors( $node, $base_selector, $settings, $block_name ) {
758 $pseudo_declarations = array();
759
760 if ( ! isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) {
761 return $pseudo_declarations;
762 }
763
764 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) {
765 if ( isset( $node[ $pseudo_selector ] ) ) {
766 $combined_selector = static::append_to_selector( $base_selector, $pseudo_selector );
767 $declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, null );
768 $pseudo_declarations[ $combined_selector ] = $declarations;
769 }
770 }
771
772 return $pseudo_declarations;
773 }
774
775 /**
776 * Returns a class name by an element name.
777 *
778 * @since 6.1.0
779 *
780 * @param string $element The name of the element.
781 * @return string The name of the class.
782 */
783 public static function get_element_class_name( $element ) {
784 $class_name = '';
785
786 if ( isset( static::__EXPERIMENTAL_ELEMENT_CLASS_NAMES[ $element ] ) ) {
787 $class_name = static::__EXPERIMENTAL_ELEMENT_CLASS_NAMES[ $element ];
788 }
789
790 return $class_name;
791 }
792
793 /**
794 * Options that settings.appearanceTools enables.
795 *
796 * @since 6.0.0
797 * @since 6.2.0 Added `dimensions.minHeight` and `position.sticky`.
798 * @since 7.0.0 Added `dimensions.width` and `dimensions.height`.
799 * @var array
800 */
801 const APPEARANCE_TOOLS_OPT_INS = array(
802 array( 'background', 'backgroundImage' ),
803 array( 'background', 'backgroundSize' ),
804 array( 'background', 'gradient' ),
805 array( 'border', 'color' ),
806 array( 'border', 'radius' ),
807 array( 'border', 'style' ),
808 array( 'border', 'width' ),
809 array( 'color', 'link' ),
810 array( 'color', 'heading' ),
811 array( 'color', 'button' ),
812 array( 'color', 'caption' ),
813 array( 'dimensions', 'aspectRatio' ),
814 array( 'dimensions', 'height' ),
815 array( 'dimensions', 'minHeight' ),
816 array( 'dimensions', 'minWidth' ),
817 array( 'dimensions', 'width' ),
818 // BEGIN EXPERIMENTAL.
819 // Allow `position.fixed` to be opted-in by default.
820 // Sticky position support was backported to WordPress 6.2 in https://core.trac.wordpress.org/ticket/57618.
821 // While `fixed` was included as a valid setting, exposing it by default is still experimental.
822 array( 'position', 'fixed' ),
823 // END EXPERIMENTAL.
824 array( 'position', 'sticky' ),
825 array( 'spacing', 'blockGap' ),
826 array( 'spacing', 'margin' ),
827 array( 'spacing', 'padding' ),
828 array( 'typography', 'lineHeight' ),
829 array( 'typography', 'textColumns' ),
830 );
831
832 /**
833 * The latest version of the schema in use.
834 *
835 * @since 5.8.0
836 * @since 5.9.0 Changed value from 1 to 2.
837 * @since 6.5.0 Changed value from 2 to 3.
838 * @var int
839 */
840 const LATEST_SCHEMA = 3;
841
842 /**
843 * Constructor.
844 *
845 * @since 5.8.0
846 * @since 6.6.0 Key spacingScale by origin, and pre-generate the spacingSizes from spacingScale.
847 * Added unwrapping of shared block style variations into block type variations if registered.
848 *
849 * @param array $theme_json A structure that follows the theme.json schema.
850 * @param string $origin Optional. What source of data this object represents.
851 * One of 'blocks', 'default', 'theme', or 'custom'. Default 'theme'.
852 */
853 public function __construct( $theme_json = array( 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA ), $origin = 'theme' ) {
854 if ( ! in_array( $origin, static::VALID_ORIGINS, true ) ) {
855 $origin = 'theme';
856 }
857
858 $this->theme_json = WP_Theme_JSON_Schema_Gutenberg::migrate( $theme_json, $origin );
859 $blocks_metadata = static::get_blocks_metadata();
860 $valid_block_names = array_keys( $blocks_metadata );
861 $valid_element_names = array_keys( static::ELEMENTS );
862 $valid_variations = static::get_valid_block_style_variations( $blocks_metadata );
863 $this->theme_json = static::unwrap_shared_block_style_variations( $this->theme_json, $valid_variations );
864 $this->theme_json = static::sanitize( $this->theme_json, $valid_block_names, $valid_element_names, $valid_variations );
865 $this->theme_json = static::maybe_opt_in_into_settings( $this->theme_json );
866
867 // Internally, presets are keyed by origin.
868 $nodes = static::get_setting_nodes( $this->theme_json );
869 foreach ( $nodes as $node ) {
870 foreach ( static::PRESETS_METADATA as $preset_metadata ) {
871 $path = $node['path'];
872 foreach ( $preset_metadata['path'] as $subpath ) {
873 $path[] = $subpath;
874 }
875 $preset = _wp_array_get( $this->theme_json, $path, null );
876 if ( null !== $preset ) {
877 // If the preset is not already keyed by origin.
878 if ( isset( $preset[0] ) || empty( $preset ) ) {
879 _wp_array_set( $this->theme_json, $path, array( $origin => $preset ) );
880 }
881 }
882 }
883 }
884
885 // In addition to presets, spacingScale (which generates presets) is also keyed by origin.
886 $scale_path = array( 'settings', 'spacing', 'spacingScale' );
887 $spacing_scale = _wp_array_get( $this->theme_json, $scale_path, null );
888 if ( null !== $spacing_scale ) {
889 // If the spacingScale is not already keyed by origin.
890 if ( empty( array_intersect( array_keys( $spacing_scale ), static::VALID_ORIGINS ) ) ) {
891 _wp_array_set( $this->theme_json, $scale_path, array( $origin => $spacing_scale ) );
892 }
893 }
894
895 // Pre-generate the spacingSizes from spacingScale.
896 $scale_path = array( 'settings', 'spacing', 'spacingScale', $origin );
897 $spacing_scale = _wp_array_get( $this->theme_json, $scale_path, null );
898 if ( isset( $spacing_scale ) ) {
899 $sizes_path = array( 'settings', 'spacing', 'spacingSizes', $origin );
900 $spacing_sizes = _wp_array_get( $this->theme_json, $sizes_path, array() );
901 $spacing_scale_sizes = static::compute_spacing_sizes( $spacing_scale );
902 $merged_spacing_sizes = static::merge_spacing_sizes( $spacing_scale_sizes, $spacing_sizes );
903 _wp_array_set( $this->theme_json, $sizes_path, $merged_spacing_sizes );
904 }
905 }
906
907 /**
908 * Unwraps shared block style variations.
909 *
910 * It takes the shared variations (styles.variations.variationName) and
911 * applies them to all the blocks that have the given variation registered
912 * (styles.blocks.blockType.variations.variationName).
913 *
914 * For example, given the `core/paragraph` and `core/group` blocks have
915 * registered the `section-a` style variation, and given the following input:
916 *
917 * {
918 * "styles": {
919 * "variations": {
920 * "section-a": { "color": { "background": "backgroundColor" } }
921 * }
922 * }
923 * }
924 *
925 * It returns the following output:
926 *
927 * {
928 * "styles": {
929 * "blocks": {
930 * "core/paragraph": {
931 * "variations": {
932 * "section-a": { "color": { "background": "backgroundColor" } }
933 * },
934 * },
935 * "core/group": {
936 * "variations": {
937 * "section-a": { "color": { "background": "backgroundColor" } }
938 * }
939 * }
940 * }
941 * }
942 * }
943 *
944 * @since 6.6.0
945 *
946 * @param array $theme_json A structure that follows the theme.json schema.
947 * @param array $valid_variations Valid block style variations.
948 *
949 * @return array Theme json data with shared variation definitions unwrapped under appropriate block types.
950 */
951 private static function unwrap_shared_block_style_variations( $theme_json, $valid_variations ) {
952 if ( empty( $theme_json['styles']['variations'] ) || empty( $valid_variations ) ) {
953 return $theme_json;
954 }
955
956 $new_theme_json = $theme_json;
957 $variations = $new_theme_json['styles']['variations'];
958
959 foreach ( $valid_variations as $block_type => $registered_variations ) {
960 foreach ( $registered_variations as $variation_name ) {
961 $block_level_data = $new_theme_json['styles']['blocks'][ $block_type ]['variations'][ $variation_name ] ?? array();
962 $top_level_data = $variations[ $variation_name ] ?? array();
963 $merged_data = array_replace_recursive( $top_level_data, $block_level_data );
964 if ( ! empty( $merged_data ) ) {
965 _wp_array_set( $new_theme_json, array( 'styles', 'blocks', $block_type, 'variations', $variation_name ), $merged_data );
966 }
967 }
968 }
969
970 unset( $new_theme_json['styles']['variations'] );
971
972 return $new_theme_json;
973 }
974
975 /**
976 * Enables some opt-in settings if theme declared support.
977 *
978 * @since 5.9.0
979 *
980 * @param array $theme_json A theme.json structure to modify.
981 * @return array The modified theme.json structure.
982 */
983 protected static function maybe_opt_in_into_settings( $theme_json ) {
984 $new_theme_json = $theme_json;
985
986 if (
987 isset( $new_theme_json['settings']['appearanceTools'] ) &&
988 true === $new_theme_json['settings']['appearanceTools']
989 ) {
990 static::do_opt_in_into_settings( $new_theme_json['settings'] );
991 }
992
993 if ( isset( $new_theme_json['settings']['blocks'] ) && is_array( $new_theme_json['settings']['blocks'] ) ) {
994 foreach ( $new_theme_json['settings']['blocks'] as &$block ) {
995 if ( isset( $block['appearanceTools'] ) && ( true === $block['appearanceTools'] ) ) {
996 static::do_opt_in_into_settings( $block );
997 }
998 }
999 }
1000
1001 return $new_theme_json;
1002 }
1003
1004 /**
1005 * Enables some settings.
1006 *
1007 * @since 5.9.0
1008 *
1009 * @param array $context The context to which the settings belong.
1010 */
1011 protected static function do_opt_in_into_settings( &$context ) {
1012 foreach ( static::APPEARANCE_TOOLS_OPT_INS as $path ) {
1013 // Use "unset prop" as a marker instead of "null" because
1014 // "null" can be a valid value for some props (e.g. blockGap).
1015 if ( 'unset prop' === _wp_array_get( $context, $path, 'unset prop' ) ) {
1016 _wp_array_set( $context, $path, true );
1017 }
1018 }
1019
1020 unset( $context['appearanceTools'] );
1021 }
1022
1023 /**
1024 * Sanitizes the input according to the schemas.
1025 *
1026 * @since 5.8.0
1027 * @since 5.9.0 Added the `$valid_block_names` and `$valid_element_name` parameters.
1028 * @since 6.6.0 Extended schema definition to allow enhanced block style variations.
1029 *
1030 * @param array $input Structure to sanitize.
1031 * @param array $valid_block_names List of valid block names.
1032 * @param array $valid_element_names List of valid element names.
1033 * @param array $valid_variations List of valid variations per block.
1034 * @return array The sanitized output.
1035 */
1036 protected static function sanitize( $input, $valid_block_names, $valid_element_names, $valid_variations ) {
1037
1038 $output = array();
1039
1040 if ( ! is_array( $input ) ) {
1041 return $output;
1042 }
1043
1044 // Preserve only the top most level keys.
1045 $output = array_intersect_key( $input, array_flip( static::VALID_TOP_LEVEL_KEYS ) );
1046
1047 /*
1048 * Remove any rules that are annotated as "top" in VALID_STYLES constant.
1049 * Some styles are only meant to be available at the top-level (e.g.: blockGap),
1050 * hence, the schema for blocks & elements should not have them.
1051 */
1052 $styles_non_top_level = static::VALID_STYLES;
1053 foreach ( array_keys( $styles_non_top_level ) as $section ) {
1054 // array_key_exists() needs to be used instead of isset() because the value can be null.
1055 if ( array_key_exists( $section, $styles_non_top_level ) && is_array( $styles_non_top_level[ $section ] ) ) {
1056 foreach ( array_keys( $styles_non_top_level[ $section ] ) as $prop ) {
1057 if ( 'top' === $styles_non_top_level[ $section ][ $prop ] ) {
1058 unset( $styles_non_top_level[ $section ][ $prop ] );
1059 }
1060 }
1061 }
1062 }
1063
1064 // Build the schema based on valid block & element names.
1065 $schema = array();
1066 $schema_styles_elements = array();
1067
1068 /*
1069 * Set allowed element pseudo selectors and responsive breakpoint states.
1070 * Target data structure in schema:
1071 * e.g.
1072 * - top level elements: `$schema['styles']['elements']['link'][':hover']`.
1073 * - block level elements: `$schema['styles']['blocks']['core/button']['elements']['link'][':hover']`.
1074 * - block responsive elements: `$schema['styles']['blocks']['core/button']['tablet']['elements']['link'][':hover']`.
1075 */
1076 foreach ( $valid_element_names as $element ) {
1077 $schema_styles_elements[ $element ] = $styles_non_top_level;
1078
1079 if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] ) ) {
1080 foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] as $pseudo_selector ) {
1081 $schema_styles_elements[ $element ][ $pseudo_selector ] = $styles_non_top_level;
1082 }
1083 }
1084
1085 // Add responsive breakpoint states for elements.
1086 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) {
1087 $schema_styles_elements[ $element ][ $breakpoint_state ] = $styles_non_top_level;
1088 }
1089 }
1090
1091 $schema_styles_blocks = array();
1092 $schema_settings_blocks = array();
1093
1094 /*
1095 * Generate a schema for blocks.
1096 * - Block styles can contain `elements`, `variations`, and responsive breakpoint state definitions.
1097 * - Variations definitions cannot be nested.
1098 * - Variations can contain styles for inner `blocks`, `elements`, and responsive breakpoint states.
1099 * - Variation inner `blocks` styles can contain `elements` and responsive breakpoint states.
1100 *
1101 * As each variation needs both a `blocks` schema and responsive `blocks` schemas
1102 * for further nested inner `blocks`, the overall schema is generated in multiple passes.
1103 */
1104 foreach ( $valid_block_names as $block ) {
1105 $schema_settings_blocks[ $block ] = static::VALID_SETTINGS;
1106 $schema_styles_blocks[ $block ] = $styles_non_top_level;
1107 $schema_styles_blocks[ $block ]['elements'] = $schema_styles_elements;
1108
1109 // Add responsive breakpoint states for all blocks.
1110 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) {
1111 $schema_styles_blocks[ $block ][ $breakpoint_state ] = $styles_non_top_level;
1112 $schema_styles_blocks[ $block ][ $breakpoint_state ]['elements'] = $schema_styles_elements;
1113
1114 if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
1115 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) {
1116 $schema_styles_blocks[ $block ][ $breakpoint_state ][ $pseudo_selector ] = $styles_non_top_level;
1117 }
1118 }
1119 }
1120
1121 // Add pseudo-selectors for blocks that support them.
1122 if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
1123 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) {
1124 $schema_styles_blocks[ $block ][ $pseudo_selector ] = $styles_non_top_level;
1125 }
1126 }
1127
1128 // Add custom states for blocks that support them (e.g. '@current' for navigation).
1129 if ( isset( static::VALID_BLOCK_CUSTOM_STATES[ $block ] ) ) {
1130 foreach ( static::VALID_BLOCK_CUSTOM_STATES[ $block ] as $custom_state ) {
1131 $custom_state_schema = $styles_non_top_level;
1132 // The same pseudo-selectors valid for the block at the top level
1133 // are also valid within each custom state.
1134 if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
1135 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo ) {
1136 $custom_state_schema[ $pseudo ] = $styles_non_top_level;
1137 }
1138 }
1139 $schema_styles_blocks[ $block ][ $custom_state ] = $custom_state_schema;
1140 }
1141 }
1142 }
1143
1144 $block_style_variation_styles = static::VALID_STYLES;
1145 $block_style_variation_styles['blocks'] = $schema_styles_blocks;
1146 $block_style_variation_styles['elements'] = $schema_styles_elements;
1147
1148 foreach ( $valid_block_names as $block ) {
1149 // Build the schema for each block style variation.
1150 $style_variation_names = array();
1151 if (
1152 ! empty( $input['styles']['blocks'][ $block ]['variations'] ) &&
1153 is_array( $input['styles']['blocks'][ $block ]['variations'] ) &&
1154 isset( $valid_variations[ $block ] )
1155 ) {
1156 $style_variation_names = array_intersect(
1157 array_keys( $input['styles']['blocks'][ $block ]['variations'] ),
1158 $valid_variations[ $block ]
1159 );
1160 }
1161
1162 $schema_styles_variations = array();
1163 if ( ! empty( $style_variation_names ) ) {
1164 foreach ( $style_variation_names as $variation_name ) {
1165 $variation_schema = $block_style_variation_styles;
1166
1167 // Add responsive breakpoint states to block style variations.
1168 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) {
1169 $variation_schema[ $breakpoint_state ] = $styles_non_top_level;
1170 $variation_schema[ $breakpoint_state ]['elements'] = $schema_styles_elements;
1171 $variation_schema[ $breakpoint_state ]['blocks'] = $schema_styles_blocks;
1172
1173 if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
1174 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) {
1175 $variation_schema[ $breakpoint_state ][ $pseudo_selector ] = $styles_non_top_level;
1176 }
1177 }
1178 }
1179
1180 // Add pseudo-selectors to variations for blocks that support them.
1181 if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
1182 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) {
1183 $variation_schema[ $pseudo_selector ] = $styles_non_top_level;
1184 }
1185 }
1186
1187 $schema_styles_variations[ $variation_name ] = $variation_schema;
1188 }
1189 }
1190
1191 $schema_styles_blocks[ $block ]['variations'] = $schema_styles_variations;
1192 }
1193
1194 $schema['styles'] = static::VALID_STYLES;
1195 $schema['styles']['blocks'] = $schema_styles_blocks;
1196 $schema['styles']['elements'] = $schema_styles_elements;
1197 $schema['settings'] = static::VALID_SETTINGS;
1198 $schema['settings']['blocks'] = $schema_settings_blocks;
1199 $schema['settings']['typography']['fontFamilies'] = static::schema_in_root_and_per_origin( static::FONT_FAMILY_SCHEMA );
1200
1201 // Remove anything that's not present in the schema.
1202 foreach ( array( 'styles', 'settings' ) as $subtree ) {
1203 if ( ! isset( $input[ $subtree ] ) ) {
1204 continue;
1205 }
1206
1207 if ( ! is_array( $input[ $subtree ] ) ) {
1208 unset( $output[ $subtree ] );
1209 continue;
1210 }
1211
1212 $result = static::remove_keys_not_in_schema( $input[ $subtree ], $schema[ $subtree ] );
1213
1214 if ( empty( $result ) ) {
1215 unset( $output[ $subtree ] );
1216 } else {
1217 $output[ $subtree ] = static::resolve_custom_css_format( $result );
1218 }
1219 }
1220
1221 return $output;
1222 }
1223
1224 /**
1225 * Appends a sub-selector to an existing one.
1226 *
1227 * Given the compounded $selector "h1, h2, h3"
1228 * and the $to_append selector ".some-class" the result will be
1229 * "h1.some-class, h2.some-class, h3.some-class".
1230 *
1231 * @since 5.8.0
1232 * @since 6.1.0 Added append position.
1233 * @since 6.3.0 Removed append position parameter.
1234 *
1235 * @param string $selector Original selector.
1236 * @param string $to_append Selector to append.
1237 * @return string The new selector.
1238 */
1239 protected static function append_to_selector( $selector, $to_append ) {
1240 if ( ! str_contains( $selector, ',' ) ) {
1241 return $selector . $to_append;
1242 }
1243 $new_selectors = array();
1244 $selectors = explode( ',', $selector );
1245 foreach ( $selectors as $sel ) {
1246 $new_selectors[] = $sel . $to_append;
1247 }
1248 return implode( ',', $new_selectors );
1249 }
1250
1251 /**
1252 * Prepends a sub-selector to an existing one.
1253 *
1254 * Given the compounded $selector "h1, h2, h3"
1255 * and the $to_prepend selector ".some-class " the result will be
1256 * ".some-class h1, .some-class h2, .some-class h3".
1257 *
1258 * @since 6.3.0
1259 *
1260 * @param string $selector Original selector.
1261 * @param string $to_prepend Selector to prepend.
1262 * @return string The new selector.
1263 */
1264 protected static function prepend_to_selector( $selector, $to_prepend ) {
1265 if ( ! str_contains( $selector, ',' ) ) {
1266 return $to_prepend . $selector;
1267 }
1268 $new_selectors = array();
1269 $selectors = explode( ',', $selector );
1270 foreach ( $selectors as $sel ) {
1271 $new_selectors[] = $to_prepend . $sel;
1272 }
1273 return implode( ',', $new_selectors );
1274 }
1275
1276 /**
1277 * Returns the metadata for each block.
1278 *
1279 * Example:
1280 *
1281 * {
1282 * 'core/paragraph': {
1283 * 'selector': 'p',
1284 * 'elements': {
1285 * 'link' => 'link selector',
1286 * 'etc' => 'element selector'
1287 * }
1288 * },
1289 * 'core/heading': {
1290 * 'selector': 'h1',
1291 * 'elements': {}
1292 * },
1293 * 'core/image': {
1294 * 'selector': '.wp-block-image',
1295 * 'duotone': 'img',
1296 * 'elements': {}
1297 * }
1298 * }
1299 *
1300 * @since 5.8.0
1301 * @since 5.9.0 Added `duotone` key with CSS selector.
1302 * @since 6.1.0 Added `features` key with block support feature level selectors.
1303 * @since 6.6.0 Added non-core block style variations to generated metadata.
1304 *
1305 * @return array Block metadata.
1306 */
1307 protected static function get_blocks_metadata() {
1308 // NOTE: the compat/6.1 version of this method in Gutenberg did not have these changes.
1309 $registry = WP_Block_Type_Registry::get_instance();
1310 $blocks = $registry->get_all_registered();
1311 $style_registry = WP_Block_Styles_Registry::get_instance();
1312
1313 // Is there metadata for all currently registered blocks?
1314 $blocks = array_diff_key( $blocks, static::$blocks_metadata );
1315 if ( empty( $blocks ) ) {
1316 /*
1317 * New block styles may have been registered within WP_Block_Styles_Registry.
1318 * Update block metadata for any new block style variations.
1319 */
1320 $registered_styles = $style_registry->get_all_registered();
1321 foreach ( static::$blocks_metadata as $block_name => $block_metadata ) {
1322 if ( ! empty( $registered_styles[ $block_name ] ) ) {
1323 $style_selectors = $block_metadata['styleVariations'] ?? array();
1324
1325 foreach ( $registered_styles[ $block_name ] as $block_style ) {
1326 if ( ! isset( $style_selectors[ $block_style['name'] ] ) ) {
1327 $style_selectors[ $block_style['name'] ] = static::get_block_style_variation_selector( $block_style['name'], $block_metadata['selector'] );
1328 }
1329 }
1330
1331 static::$blocks_metadata[ $block_name ]['styleVariations'] = $style_selectors;
1332 }
1333 }
1334 return static::$blocks_metadata;
1335 }
1336
1337 foreach ( $blocks as $block_name => $block_type ) {
1338 $root_selector = wp_get_block_css_selector( $block_type );
1339
1340 static::$blocks_metadata[ $block_name ]['selector'] = $root_selector;
1341 static::$blocks_metadata[ $block_name ]['selectors'] = static::get_block_selectors( $block_type, $root_selector );
1342
1343 $elements = static::get_block_element_selectors( $root_selector );
1344 if ( ! empty( $elements ) ) {
1345 static::$blocks_metadata[ $block_name ]['elements'] = $elements;
1346 }
1347
1348 // The block may or may not have a duotone selector.
1349 $duotone_selector = wp_get_block_css_selector( $block_type, 'filter.duotone' );
1350
1351 // Keep backwards compatibility for support.color.__experimentalDuotone.
1352 if ( null === $duotone_selector ) {
1353 $duotone_support = $block_type->supports['color']['__experimentalDuotone'] ?? null;
1354
1355 if ( $duotone_support ) {
1356 $root_selector = wp_get_block_css_selector( $block_type );
1357 $duotone_selector = static::scope_selector( $root_selector, $duotone_support );
1358 }
1359 }
1360
1361 if ( null !== $duotone_selector ) {
1362 static::$blocks_metadata[ $block_name ]['duotone'] = $duotone_selector;
1363 }
1364
1365 // If the block has style variations, append their selectors to the block metadata.
1366 $style_selectors = array();
1367 if ( ! empty( $block_type->styles ) ) {
1368 foreach ( $block_type->styles as $style ) {
1369 $style_selectors[ $style['name'] ] = static::get_block_style_variation_selector( $style['name'], static::$blocks_metadata[ $block_name ]['selector'] );
1370 }
1371 }
1372
1373 // Block style variations can be registered through the WP_Block_Styles_Registry as well as block.json.
1374 $registered_styles = $style_registry->get_registered_styles_for_block( $block_name );
1375 foreach ( $registered_styles as $style ) {
1376 $style_selectors[ $style['name'] ] = static::get_block_style_variation_selector( $style['name'], static::$blocks_metadata[ $block_name ]['selector'] );
1377 }
1378
1379 if ( ! empty( $style_selectors ) ) {
1380 static::$blocks_metadata[ $block_name ]['styleVariations'] = $style_selectors;
1381 }
1382
1383 // If the block has custom states defined in block.json, store their selectors.
1384 if ( ! empty( $block_type->selectors['states'] ) && is_array( $block_type->selectors['states'] ) ) {
1385 static::$blocks_metadata[ $block_name ]['states'] = $block_type->selectors['states'];
1386 }
1387 }
1388
1389 return static::$blocks_metadata;
1390 }
1391
1392 /**
1393 * Given a tree, removes the keys that are not present in the schema.
1394 *
1395 * It is recursive and modifies the input in-place.
1396 *
1397 * @since 5.8.0
1398 * @since 7.0.0 Added type validation for boolean values.
1399 *
1400 * @param array $tree Input to process.
1401 * @param array $schema Schema to adhere to.
1402 * @return array The modified $tree.
1403 */
1404 protected static function remove_keys_not_in_schema( $tree, $schema ) {
1405 if ( ! is_array( $tree ) ) {
1406 return $tree;
1407 }
1408
1409 foreach ( $tree as $key => $value ) {
1410 // Remove keys not in the schema or with null/empty values.
1411 if ( ! array_key_exists( $key, $schema ) ) {
1412 unset( $tree[ $key ] );
1413 continue;
1414 }
1415
1416 // Validate type if schema specifies a boolean marker.
1417 if ( is_bool( $schema[ $key ] ) ) {
1418 // Schema expects a boolean value - validate the input matches.
1419 if ( ! is_bool( $value ) ) {
1420 unset( $tree[ $key ] );
1421 continue;
1422 }
1423 // Type matches, keep the value and continue to next key.
1424 continue;
1425 }
1426
1427 if ( is_array( $schema[ $key ] ) ) {
1428 if ( ! is_array( $value ) ) {
1429 unset( $tree[ $key ] );
1430 } elseif ( wp_is_numeric_array( $value ) ) {
1431 // If indexed, process each item in the array.
1432 foreach ( $value as $item_key => $item_value ) {
1433 if ( isset( $schema[ $key ][0] ) && is_array( $schema[ $key ][0] ) ) {
1434 $tree[ $key ][ $item_key ] = self::remove_keys_not_in_schema( $item_value, $schema[ $key ][0] );
1435 } else {
1436 // If the schema does not define a further structure, keep the value as is.
1437 $tree[ $key ][ $item_key ] = $item_value;
1438 }
1439 }
1440 } else {
1441 // If associative, process as a single object.
1442 $tree[ $key ] = self::remove_keys_not_in_schema( $value, $schema[ $key ] );
1443
1444 if ( empty( $tree[ $key ] ) ) {
1445 unset( $tree[ $key ] );
1446 }
1447 }
1448 }
1449 }
1450
1451 return $tree;
1452 }
1453
1454 /**
1455 * Returns the existing settings for each block.
1456 *
1457 * Example:
1458 *
1459 * {
1460 * 'root': {
1461 * 'color': {
1462 * 'custom': true
1463 * }
1464 * },
1465 * 'core/paragraph': {
1466 * 'spacing': {
1467 * 'customPadding': true
1468 * }
1469 * }
1470 * }
1471 *
1472 * @since 5.8.0
1473 *
1474 * @return array Settings per block.
1475 */
1476 public function get_settings() {
1477 if ( ! isset( $this->theme_json['settings'] ) ) {
1478 return array();
1479 } else {
1480 return $this->theme_json['settings'];
1481 }
1482 }
1483
1484 /**
1485 * Returns the stylesheet that results of processing
1486 * the theme.json structure this object represents.
1487 *
1488 * @since 5.8.0
1489 * @since 5.9.0 Removed the `$type` parameter`, added the `$types` and `$origins` parameters.
1490 * @since 6.6.0 Added option to skip root layout or block style variation styles.
1491 *
1492 * @param array $types Types of styles to load. Will load all by default. It accepts:
1493 * - `variables`: only the CSS Custom Properties for presets & custom ones.
1494 * - `styles`: only the styles section in theme.json.
1495 * - `presets`: only the classes for the presets.
1496 * - `custom-css`: only the custom CSS.
1497 * @param array $origins A list of origins to include. By default it includes VALID_ORIGINS.
1498 * @param array $options An array of options for now used for internal purposes only (may change without notice).
1499 * The options currently supported are:
1500 * - 'scope' that makes sure all style are scoped to a given selector
1501 * - `root_selector` which overwrites and forces a given selector to be used on the root node
1502 * - `skip_root_layout_styles` which omits root layout styles from the generated stylesheet.
1503 * - `base_layout_styles` which when true generates only base layout styles without alignment rules. Defaults to false.
1504 * - `include_block_style_variations` which includes CSS for block style variations.
1505 * @return string The resulting stylesheet.
1506 */
1507 public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' ), $origins = null, $options = array() ) {
1508 if ( null === $origins ) {
1509 $origins = static::VALID_ORIGINS;
1510 }
1511
1512 if ( is_string( $types ) ) {
1513 // Dispatch error and map old arguments to new ones.
1514 _deprecated_argument( __FUNCTION__, '5.9.0' );
1515 if ( 'block_styles' === $types ) {
1516 $types = array( 'styles', 'presets' );
1517 } elseif ( 'css_variables' === $types ) {
1518 $types = array( 'variables' );
1519 } else {
1520 $types = array( 'variables', 'styles', 'presets' );
1521 }
1522 }
1523
1524 $blocks_metadata = static::get_blocks_metadata();
1525 $style_nodes = static::get_style_nodes( $this->theme_json, $blocks_metadata, $options );
1526 $setting_nodes = static::get_setting_nodes( $this->theme_json, $blocks_metadata );
1527
1528 $root_style_key = array_search( static::ROOT_BLOCK_SELECTOR, array_column( $style_nodes, 'selector' ), true );
1529 $root_settings_key = array_search( static::ROOT_BLOCK_SELECTOR, array_column( $setting_nodes, 'selector' ), true );
1530
1531 if ( ! empty( $options['scope'] ) ) {
1532 foreach ( $setting_nodes as &$node ) {
1533 $node['selector'] = static::scope_selector( $options['scope'], $node['selector'] );
1534 }
1535 foreach ( $style_nodes as &$node ) {
1536 $node = static::scope_style_node_selectors( $options['scope'], $node );
1537 }
1538 unset( $node );
1539 }
1540
1541 if ( ! empty( $options['root_selector'] ) ) {
1542 if ( false !== $root_settings_key ) {
1543 $setting_nodes[ $root_settings_key ]['selector'] = $options['root_selector'];
1544 }
1545 if ( false !== $root_style_key ) {
1546 $style_nodes[ $root_style_key ]['selector'] = $options['root_selector'];
1547 }
1548 }
1549
1550 $stylesheet = '';
1551
1552 if ( in_array( 'variables', $types, true ) ) {
1553 $stylesheet .= $this->get_css_variables( $setting_nodes, $origins );
1554 }
1555
1556 if ( in_array( 'styles', $types, true ) ) {
1557 if ( false !== $root_style_key && empty( $options['skip_root_layout_styles'] ) ) {
1558 $stylesheet .= $this->get_root_layout_rules( $style_nodes[ $root_style_key ]['selector'], $style_nodes[ $root_style_key ], $options );
1559 }
1560 $stylesheet .= $this->get_block_classes( $style_nodes );
1561 }
1562
1563 if ( in_array( 'presets', $types, true ) ) {
1564 $stylesheet .= $this->get_preset_classes( $setting_nodes, $origins );
1565 }
1566
1567 // Load the custom CSS last so it has the highest specificity.
1568 if ( in_array( 'custom-css', $types, true ) ) {
1569 // Add the global styles root CSS.
1570 $stylesheet .= _wp_array_get( $this->theme_json, array( 'styles', 'css' ) );
1571 }
1572
1573 return $stylesheet;
1574 }
1575
1576 /**
1577 * Processes the CSS, to apply nesting.
1578 *
1579 * @since 6.2.0
1580 *
1581 * @param string $css The CSS to process.
1582 * @param string $selector The selector to nest.
1583 * @return string The processed CSS.
1584 */
1585 public static function process_blocks_custom_css( $css, $selector ) {
1586 $processed_css = '';
1587
1588 if ( empty( $css ) ) {
1589 return $processed_css;
1590 }
1591
1592 // Split CSS nested rules.
1593 $parts = explode( '&', $css );
1594 foreach ( $parts as $part ) {
1595 if ( empty( $part ) ) {
1596 continue;
1597 }
1598 $is_root_css = ( ! str_contains( $part, '{' ) );
1599 if ( $is_root_css ) {
1600 // If the part doesn't contain braces, it applies to the root level.
1601 $processed_css .= ':root :where(' . trim( $selector ) . '){' . trim( $part ) . '}';
1602 } else {
1603 // If the part contains braces, it's a nested CSS rule.
1604 $part = explode( '{', str_replace( '}', '', $part ) );
1605 if ( count( $part ) !== 2 ) {
1606 continue;
1607 }
1608 $nested_selector = $part[0];
1609 $css_value = $part[1];
1610
1611 /*
1612 * Handle pseudo elements such as ::before, ::after etc. Regex will also
1613 * capture any leading combinator such as >, +, or ~, as well as spaces.
1614 * This allows pseudo elements as descendants e.g. `.parent ::before`.
1615 */
1616 $matches = array();
1617 $has_pseudo_element = preg_match( '/([>+~\s]*::[a-zA-Z-]+)/', $nested_selector, $matches );
1618 $pseudo_part = $has_pseudo_element ? $matches[1] : '';
1619 $nested_selector = $has_pseudo_element ? str_replace( $pseudo_part, '', $nested_selector ) : $nested_selector;
1620
1621 // Finalize selector and re-append pseudo element if required.
1622 $part_selector = str_starts_with( $nested_selector, ' ' )
1623 ? static::scope_selector( $selector, $nested_selector )
1624 : static::append_to_selector( $selector, $nested_selector );
1625 $final_selector = ":root :where($part_selector)$pseudo_part";
1626
1627 $processed_css .= $final_selector . '{' . trim( $css_value ) . '}';
1628 }
1629 }
1630 return $processed_css;
1631 }
1632
1633 /**
1634 * Returns the global styles custom css.
1635 *
1636 * @since 6.2.0
1637 * @deprecated 6.7.0 Use {@see 'get_stylesheet'} instead.
1638 *
1639 * @return string The global styles custom CSS.
1640 */
1641 public function get_custom_css() {
1642 _deprecated_function( __METHOD__, '6.7.0', 'get_stylesheet' );
1643 $block_custom_css = '';
1644 $block_nodes = $this->get_block_custom_css_nodes();
1645 foreach ( $block_nodes as $node ) {
1646 // The node selector will have its specificity set to 0-1-0 within process_blocks_custom_css.
1647 $block_custom_css .= $this->get_block_custom_css( $node['css'], $node['selector'] );
1648 }
1649
1650 return $this->get_base_custom_css() . $block_custom_css;
1651 }
1652
1653 /**
1654 * Returns the global styles base custom CSS.
1655 * This function is deprecated; please do not sync to core.
1656 *
1657 * @return string The global styles base custom CSS.
1658 */
1659 public function get_base_custom_css() {
1660 _deprecated_function( __METHOD__, 'Gutenberg 18.6.0', 'get_stylesheet' );
1661 return $this->theme_json['styles']['css'] ?? '';
1662 }
1663
1664 /**
1665 * Returns the block nodes with custom CSS.
1666 * This function is deprecated; please do not sync to core.
1667 *
1668 * @return array The block nodes.
1669 */
1670 public function get_block_custom_css_nodes() {
1671 _deprecated_function( __METHOD__, 'Gutenberg 18.6.0', 'get_block_nodes' );
1672 $block_nodes = array();
1673
1674 // Add the global styles block CSS.
1675 if ( isset( $this->theme_json['styles']['blocks'] ) ) {
1676 foreach ( $this->theme_json['styles']['blocks'] as $name => $node ) {
1677 $custom_block_css = $this->theme_json['styles']['blocks'][ $name ]['css'] ?? null;
1678 if ( $custom_block_css ) {
1679 $block_nodes[] = array(
1680 'name' => $name,
1681 'selector' => static::$blocks_metadata[ $name ]['selector'],
1682 'css' => $custom_block_css,
1683 );
1684 }
1685 }
1686 }
1687
1688 return $block_nodes;
1689 }
1690
1691 /**
1692 * Returns the global styles custom CSS for a single block.
1693 * This function is deprecated; please do not sync to core.
1694 *
1695 * @param array $css The block css node.
1696 * @param string $selector The block selector.
1697 *
1698 * @return string The global styles custom CSS for the block.
1699 */
1700 public function get_block_custom_css( $css, $selector ) {
1701 _deprecated_function( __METHOD__, 'Gutenberg 18.6.0', 'get_styles_for_block' );
1702 return $this->process_blocks_custom_css( $css, $selector );
1703 }
1704
1705 /**
1706 * Returns the page templates of the active theme.
1707 *
1708 * @since 5.9.0
1709 *
1710 * @return array
1711 */
1712 public function get_custom_templates() {
1713 $custom_templates = array();
1714 if ( ! isset( $this->theme_json['customTemplates'] ) || ! is_array( $this->theme_json['customTemplates'] ) ) {
1715 return $custom_templates;
1716 }
1717
1718 foreach ( $this->theme_json['customTemplates'] as $item ) {
1719 if ( isset( $item['name'] ) ) {
1720 $custom_templates[ $item['name'] ] = array(
1721 'title' => $item['title'] ?? '',
1722 'postTypes' => $item['postTypes'] ?? array( 'page' ),
1723 );
1724 }
1725 }
1726 return $custom_templates;
1727 }
1728
1729 /**
1730 * Returns the template part data of active theme.
1731 *
1732 * @since 5.9.0
1733 *
1734 * @return array
1735 */
1736 public function get_template_parts() {
1737 $template_parts = array();
1738 if ( ! isset( $this->theme_json['templateParts'] ) || ! is_array( $this->theme_json['templateParts'] ) ) {
1739 return $template_parts;
1740 }
1741
1742 foreach ( $this->theme_json['templateParts'] as $item ) {
1743 if ( isset( $item['name'] ) ) {
1744 $template_parts[ $item['name'] ] = array(
1745 'title' => $item['title'] ?? '',
1746 'area' => $item['area'] ?? '',
1747 );
1748 }
1749 }
1750 return $template_parts;
1751 }
1752
1753 /**
1754 * Converts each style section into a list of rulesets
1755 * containing the block styles to be appended to the stylesheet.
1756 *
1757 * See glossary at https://developer.mozilla.org/en-US/docs/Web/CSS/Syntax
1758 *
1759 * For each section this creates a new ruleset such as:
1760 *
1761 * block-selector {
1762 * style-property-one: value;
1763 * }
1764 *
1765 * @since 5.8.0 As `get_block_styles()`.
1766 * @since 5.9.0 Renamed from `get_block_styles()` to `get_block_classes()`
1767 * and no longer returns preset classes.
1768 * Removed the `$setting_nodes` parameter.
1769 * @since 6.1.0 Moved most internal logic to `get_styles_for_block()`.
1770 *
1771 * @param array $style_nodes Nodes with styles.
1772 * @return string The new stylesheet.
1773 */
1774 protected function get_block_classes( $style_nodes ) {
1775 $block_rules = '';
1776
1777 foreach ( $style_nodes as $metadata ) {
1778 if ( null === $metadata['selector'] ) {
1779 continue;
1780 }
1781 $block_rules .= static::get_styles_for_block( $metadata );
1782 }
1783
1784 return $block_rules;
1785 }
1786
1787 /**
1788 * Gets the CSS layout rules for a particular block from theme.json layout definitions.
1789 *
1790 * @since 6.1.0
1791 *
1792 * @param array $block_metadata Metadata about the block to get styles for.
1793 * @param array $options Optional. An array of options for now used for internal purposes only.
1794 * @return string Layout styles for the block.
1795 */
1796 protected function get_layout_styles( $block_metadata, $options = array() ) {
1797 $block_rules = '';
1798 $block_type = null;
1799
1800 // Skip outputting layout styles if explicitly disabled.
1801 if ( current_theme_supports( 'disable-layout-styles' ) ) {
1802 return $block_rules;
1803 }
1804
1805 if ( isset( $block_metadata['name'] ) ) {
1806 $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_metadata['name'] );
1807 if ( ! block_has_support( $block_type, array( 'layout' ), false ) && ! block_has_support( $block_type, array( '__experimentalLayout' ), false ) ) {
1808 return $block_rules;
1809 }
1810 }
1811
1812 $selector = $block_metadata['selector'] ?? '';
1813 $has_block_gap_support = isset( $this->theme_json['settings']['spacing']['blockGap'] );
1814 $has_fallback_gap_support = ! $has_block_gap_support; // This setting isn't useful yet: it exists as a placeholder for a future explicit fallback gap styles support.
1815 $node = $options['node'] ?? _wp_array_get( $this->theme_json, $block_metadata['path'], array() );
1816 $layout_definitions = gutenberg_get_layout_definitions();
1817 $layout_selector_pattern = '/^[a-zA-Z0-9\-\.\,\ *+>:\(\)]*$/'; // Allow alphanumeric classnames, spaces, wildcard, sibling, child combinator and pseudo class selectors.
1818
1819 // Gap styles will only be output if the theme has block gap support, or supports a fallback gap.
1820 // Default layout gap styles will be skipped for themes that do not explicitly opt-in to blockGap with a `true` or `false` value.
1821 if ( $has_block_gap_support || $has_fallback_gap_support ) {
1822 $block_gap_value = null;
1823 // Use a fallback gap value if block gap support is not available.
1824 if ( ! $has_block_gap_support ) {
1825 $block_gap_value = static::ROOT_BLOCK_SELECTOR === $selector ? '0.5em' : null;
1826 if ( ! empty( $block_type ) ) {
1827 $block_gap_value = $block_type->supports['spacing']['blockGap']['__experimentalDefault'] ?? null;
1828 }
1829 } else {
1830 $block_gap_value = static::get_property_value( $node, array( 'spacing', 'blockGap' ) );
1831 }
1832
1833 // Support split row / column values and concatenate to a shorthand value.
1834 if ( is_array( $block_gap_value ) ) {
1835 if ( isset( $block_gap_value['top'] ) && isset( $block_gap_value['left'] ) ) {
1836 $gap_row = static::get_property_value( $node, array( 'spacing', 'blockGap', 'top' ) );
1837 $gap_column = static::get_property_value( $node, array( 'spacing', 'blockGap', 'left' ) );
1838 $block_gap_value = $gap_row === $gap_column ? $gap_row : $gap_row . ' ' . $gap_column;
1839 } else {
1840 // Skip outputting gap value if not all sides are provided.
1841 $block_gap_value = null;
1842 }
1843 }
1844
1845 // If the block should have custom gap, add the gap styles.
1846 if ( null !== $block_gap_value && false !== $block_gap_value && '' !== $block_gap_value ) {
1847 foreach ( $layout_definitions as $layout_definition_key => $layout_definition ) {
1848 // Allow outputting fallback gap styles for flex layout type when block gap support isn't available.
1849 if ( ! $has_block_gap_support && 'flex' !== $layout_definition_key && 'grid' !== $layout_definition_key ) {
1850 continue;
1851 }
1852
1853 $class_name = $layout_definition['className'] ?? false;
1854 $spacing_rules = $layout_definition['spacingStyles'] ?? array();
1855
1856 if (
1857 ! empty( $class_name ) &&
1858 ! empty( $spacing_rules )
1859 ) {
1860 foreach ( $spacing_rules as $spacing_rule ) {
1861 $declarations = array();
1862 if (
1863 isset( $spacing_rule['selector'] ) &&
1864 preg_match( $layout_selector_pattern, $spacing_rule['selector'] ) &&
1865 ! empty( $spacing_rule['rules'] )
1866 ) {
1867 // Iterate over each of the styling rules and substitute non-string values such as `null` with the real `blockGap` value.
1868 foreach ( $spacing_rule['rules'] as $css_property => $css_value ) {
1869 $current_css_value = is_string( $css_value ) ? $css_value : $block_gap_value;
1870 if ( static::is_safe_css_declaration( $css_property, $current_css_value ) ) {
1871 $declarations[] = array(
1872 'name' => $css_property,
1873 'value' => $current_css_value,
1874 );
1875 }
1876 }
1877
1878 if ( ! $has_block_gap_support ) {
1879 // For fallback gap styles, use lower specificity, to ensure styles do not unintentionally override theme styles.
1880 $format = static::ROOT_BLOCK_SELECTOR === $selector ? ':where(.%2$s%3$s)' : ':where(%1$s.%2$s%3$s)';
1881 $layout_selector = sprintf(
1882 $format,
1883 $selector,
1884 $class_name,
1885 $spacing_rule['selector']
1886 );
1887 } else {
1888 $format = static::ROOT_BLOCK_SELECTOR === $selector ? ':root :where(.%2$s)%3$s' : ':root :where(%1$s-%2$s)%3$s';
1889 $layout_selector = sprintf(
1890 $format,
1891 $selector,
1892 $class_name,
1893 $spacing_rule['selector']
1894 );
1895 }
1896 $block_rules .= static::to_ruleset( $layout_selector, $declarations );
1897 }
1898 }
1899 }
1900 }
1901 }
1902 }
1903
1904 // Output base styles.
1905 if (
1906 static::ROOT_BLOCK_SELECTOR === $selector
1907 ) {
1908 $valid_display_modes = array( 'block', 'flex', 'grid' );
1909 foreach ( $layout_definitions as $layout_definition ) {
1910 $class_name = $layout_definition['className'] ?? false;
1911 $base_style_rules = $layout_definition['baseStyles'] ?? array();
1912
1913 if (
1914 ! empty( $class_name ) &&
1915 is_array( $base_style_rules )
1916 ) {
1917 // Output display mode. This requires special handling as `display` is not exposed in `safe_style_css_filter`.
1918 if (
1919 ! empty( $layout_definition['displayMode'] ) &&
1920 is_string( $layout_definition['displayMode'] ) &&
1921 in_array( $layout_definition['displayMode'], $valid_display_modes, true )
1922 ) {
1923 $layout_selector = sprintf(
1924 '%s .%s',
1925 $selector,
1926 $class_name
1927 );
1928 $block_rules .= static::to_ruleset(
1929 $layout_selector,
1930 array(
1931 array(
1932 'name' => 'display',
1933 'value' => $layout_definition['displayMode'],
1934 ),
1935 )
1936 );
1937 }
1938
1939 foreach ( $base_style_rules as $base_style_rule ) {
1940 $declarations = array();
1941
1942 // Skip outputting base styles for flow and constrained layout types when base_layout_styles is enabled.
1943 // These themes don't use .wp-site-blocks wrapper, so these layout-specific alignment styles aren't needed.
1944 if ( ! empty( $options['base_layout_styles'] ) && ( 'default' === $layout_definition['name'] || 'constrained' === $layout_definition['name'] ) ) {
1945 continue;
1946 }
1947
1948 if (
1949 isset( $base_style_rule['selector'] ) &&
1950 preg_match( $layout_selector_pattern, $base_style_rule['selector'] ) &&
1951 ! empty( $base_style_rule['rules'] )
1952 ) {
1953 foreach ( $base_style_rule['rules'] as $css_property => $css_value ) {
1954 // Skip rules that reference content size or wide size if they are not defined in the theme.json.
1955 if (
1956 is_string( $css_value ) &&
1957 ( str_contains( $css_value, '--global--content-size' ) || str_contains( $css_value, '--global--wide-size' ) ) &&
1958 ! isset( $this->theme_json['settings']['layout']['contentSize'] ) &&
1959 ! isset( $this->theme_json['settings']['layout']['wideSize'] )
1960 ) {
1961 continue;
1962 }
1963
1964 if ( static::is_safe_css_declaration( $css_property, $css_value ) ) {
1965 $declarations[] = array(
1966 'name' => $css_property,
1967 'value' => $css_value,
1968 );
1969 }
1970 }
1971
1972 $layout_selector = sprintf(
1973 '.%s%s',
1974 $class_name,
1975 $base_style_rule['selector']
1976 );
1977 $block_rules .= static::to_ruleset( $layout_selector, $declarations );
1978 }
1979 }
1980 }
1981 }
1982 }
1983
1984 if ( ! empty( $options['media_query'] ) && ! empty( $block_rules ) ) {
1985 $block_rules = $options['media_query'] . '{' . $block_rules . '}';
1986 }
1987
1988 return $block_rules;
1989 }
1990
1991 /**
1992 * Creates new rulesets as classes for each preset value such as:
1993 *
1994 * .has-value-color {
1995 * color: value;
1996 * }
1997 *
1998 * .has-value-background-color {
1999 * background-color: value;
2000 * }
2001 *
2002 * .has-value-font-size {
2003 * font-size: value;
2004 * }
2005 *
2006 * .has-value-gradient-background {
2007 * background: value;
2008 * }
2009 *
2010 * p.has-value-gradient-background {
2011 * background: value;
2012 * }
2013 *
2014 * @since 5.9.0
2015 *
2016 * @param array $setting_nodes Nodes with settings.
2017 * @param array $origins List of origins to process presets from.
2018 * @return string The new stylesheet.
2019 */
2020 protected function get_preset_classes( $setting_nodes, $origins ) {
2021 $preset_rules = '';
2022
2023 foreach ( $setting_nodes as $metadata ) {
2024 if ( null === $metadata['selector'] ) {
2025 continue;
2026 }
2027
2028 $selector = $metadata['selector'];
2029 $node = _wp_array_get( $this->theme_json, $metadata['path'], array() );
2030 $preset_rules .= static::compute_preset_classes( $node, $selector, $origins );
2031 }
2032
2033 return $preset_rules;
2034 }
2035
2036 /**
2037 * Converts each styles section into a list of rulesets
2038 * to be appended to the stylesheet.
2039 * These rulesets contain all the css variables (custom variables and preset variables).
2040 *
2041 * See glossary at https://developer.mozilla.org/en-US/docs/Web/CSS/Syntax
2042 *
2043 * For each section this creates a new ruleset such as:
2044 *
2045 * block-selector {
2046 * --wp--preset--category--slug: value;
2047 * --wp--custom--variable: value;
2048 * }
2049 *
2050 * @since 5.8.0
2051 * @since 5.9.0 Added the `$origins` parameter.
2052 *
2053 * @param array $nodes Nodes with settings.
2054 * @param array $origins List of origins to process.
2055 * @return string The new stylesheet.
2056 */
2057 protected function get_css_variables( $nodes, $origins ) {
2058 $stylesheet = '';
2059 foreach ( $nodes as $metadata ) {
2060 if ( null === $metadata['selector'] ) {
2061 continue;
2062 }
2063
2064 $selector = $metadata['selector'];
2065 $feature_selectors = $metadata['selectors'] ?? array();
2066 $node = _wp_array_get( $this->theme_json, $metadata['path'], array() );
2067
2068 /*
2069 * Group preset declarations by selector. Blocks that define
2070 * feature-level selectors need their preset CSS variables
2071 * output under that feature selector instead of the block's
2072 * root selector.
2073 */
2074 $vars_by_selector = array();
2075 $vars_by_selector[ $selector ] = array();
2076
2077 foreach ( static::PRESETS_METADATA as $preset_metadata ) {
2078 if ( empty( $preset_metadata['css_vars'] ) ) {
2079 continue;
2080 }
2081
2082 $values_by_slug = static::get_settings_values_by_slug( $node, $preset_metadata, $origins );
2083 if ( empty( $values_by_slug ) ) {
2084 continue;
2085 }
2086
2087 $target = static::get_feature_selector( $feature_selectors, $preset_metadata['path'][0], $selector );
2088
2089 if ( ! isset( $vars_by_selector[ $target ] ) ) {
2090 $vars_by_selector[ $target ] = array();
2091 }
2092
2093 foreach ( $values_by_slug as $slug => $value ) {
2094 $vars_by_selector[ $target ][] = array(
2095 'name' => static::replace_slug_in_string( $preset_metadata['css_vars'], $slug ),
2096 'value' => $value,
2097 );
2098 }
2099 }
2100
2101 // Theme vars always use the block's default selector.
2102 foreach ( static::compute_theme_vars( $node ) as $theme_var ) {
2103 $vars_by_selector[ $selector ][] = $theme_var;
2104 }
2105
2106 foreach ( $vars_by_selector as $rule_selector => $declarations ) {
2107 $stylesheet .= static::to_ruleset( $rule_selector, $declarations );
2108 }
2109 }
2110
2111 return $stylesheet;
2112 }
2113
2114 /**
2115 * Returns the appropriate selector for a block support feature's
2116 * preset CSS variables.
2117 *
2118 * If the block defines a feature-level selector (as a string or an
2119 * object with a `root` key), that selector is returned. Otherwise,
2120 * the block's default selector is used.
2121 *
2122 * @param array<string, string|array<string, string>> $feature_selectors The block's feature selectors map.
2123 * @param string $feature_key The feature to look up (e.g. 'dimensions').
2124 * @param string $default_selector Fallback selector.
2125 * @return string The resolved selector.
2126 */
2127 private static function get_feature_selector( array $feature_selectors, string $feature_key, string $default_selector ): string {
2128 if ( ! isset( $feature_selectors[ $feature_key ] ) ) {
2129 return $default_selector;
2130 }
2131
2132 $feature = $feature_selectors[ $feature_key ];
2133
2134 if ( is_string( $feature ) ) {
2135 return $feature;
2136 }
2137
2138 if ( isset( $feature['root'] ) && is_string( $feature['root'] ) ) {
2139 return $feature['root'];
2140 }
2141
2142 return $default_selector;
2143 }
2144
2145 /**
2146 * Given a selector and a declaration list,
2147 * creates the corresponding ruleset.
2148 *
2149 * @since 5.8.0
2150 * @since 7.1.0 Skip declarations whose value is not a plain string (booleans, arrays, objects, etc.).
2151 *
2152 * @param string $selector CSS selector.
2153 * @param array $declarations List of declarations.
2154 * @return string The resulting CSS ruleset.
2155 */
2156 protected static function to_ruleset( $selector, $declarations ) {
2157 if ( empty( $declarations ) ) {
2158 return '';
2159 }
2160
2161 $declaration_block = array_reduce(
2162 $declarations,
2163 static function ( $carry, $element ) {
2164 $value = $element['value'];
2165
2166 if ( is_numeric( $value ) ) {
2167 $value = (string) $value;
2168 }
2169
2170 if ( ! is_string( $value ) ) {
2171 return $carry;
2172 }
2173
2174 return $carry .= $element['name'] . ': ' . $value . ';';
2175 },
2176 ''
2177 );
2178
2179 return $selector . '{' . $declaration_block . '}';
2180 }
2181
2182 /**
2183 * Given a settings array, returns the generated rulesets
2184 * for the preset classes.
2185 *
2186 * @since 5.8.0
2187 * @since 5.9.0 Added the `$origins` parameter.
2188 *
2189 * @param array $settings Settings to process.
2190 * @param string $selector Selector wrapping the classes.
2191 * @param array $origins List of origins to process.
2192 * @return string The result of processing the presets.
2193 */
2194 protected static function compute_preset_classes( $settings, $selector, $origins ) {
2195 if ( static::ROOT_BLOCK_SELECTOR === $selector || static::ROOT_CSS_PROPERTIES_SELECTOR === $selector ) {
2196 // Classes at the global level do not need any CSS prefixed,
2197 // and we don't want to increase its specificity.
2198 $selector = '';
2199 }
2200
2201 $stylesheet = '';
2202 foreach ( static::PRESETS_METADATA as $preset_metadata ) {
2203 if ( empty( $preset_metadata['classes'] ) ) {
2204 continue;
2205 }
2206
2207 $slugs = static::get_settings_slugs( $settings, $preset_metadata, $origins );
2208 foreach ( $preset_metadata['classes'] as $class => $property ) {
2209 foreach ( $slugs as $slug ) {
2210 $css_var = static::replace_slug_in_string( $preset_metadata['css_vars'], $slug );
2211 $class_name = static::replace_slug_in_string( $class, $slug );
2212
2213 // $selector is often empty, so we can save ourselves the `append_to_selector()` call then.
2214 $new_selector = '' === $selector ? $class_name : static::append_to_selector( $selector, $class_name );
2215 $stylesheet .= static::to_ruleset(
2216 $new_selector,
2217 array(
2218 array(
2219 'name' => $property,
2220 'value' => 'var(' . $css_var . ') !important',
2221 ),
2222 )
2223 );
2224 }
2225 }
2226 }
2227
2228 return $stylesheet;
2229 }
2230
2231 /**
2232 * Function that scopes a selector with another one. This works a bit like
2233 * SCSS nesting except the `&` operator isn't supported.
2234 *
2235 * <code>
2236 * $scope = '.a, .b .c';
2237 * $selector = '> .x, .y';
2238 * $merged = scope_selector( $scope, $selector );
2239 * // $merged is '.a > .x, .a .y, .b .c > .x, .b .c .y'
2240 * </code>
2241 *
2242 * @since 5.9.0
2243 *
2244 * @param string $scope Selector to scope to.
2245 * @param string $selector Original selector.
2246 * @return string Scoped selector.
2247 */
2248 public static function scope_selector( $scope, $selector ) {
2249 if ( ! $scope || ! $selector ) {
2250 return $selector;
2251 }
2252
2253 $scopes = explode( ',', $scope );
2254 $selectors = explode( ',', $selector );
2255
2256 $selectors_scoped = array();
2257 foreach ( $scopes as $outer ) {
2258 foreach ( $selectors as $inner ) {
2259 $outer = trim( $outer );
2260 $inner = trim( $inner );
2261 if ( ! empty( $outer ) && ! empty( $inner ) ) {
2262 $selectors_scoped[] = $outer . ' ' . $inner;
2263 } elseif ( empty( $outer ) ) {
2264 $selectors_scoped[] = $inner;
2265 } elseif ( empty( $inner ) ) {
2266 $selectors_scoped[] = $outer;
2267 }
2268 }
2269 }
2270
2271 $result = implode( ', ', $selectors_scoped );
2272 return $result;
2273 }
2274
2275 /**
2276 * Scopes the selectors for a given style node. This includes the primary
2277 * selector, i.e. `$node['selector']`, as well as any custom selectors for
2278 * features and subfeatures, e.g. `$node['selectors']['border']` etc.
2279 *
2280 * @since 6.6.0
2281 *
2282 * @param string $scope Selector to scope to.
2283 * @param array $node Style node with selectors to scope.
2284 *
2285 * @return array Node with updated selectors.
2286 */
2287 protected static function scope_style_node_selectors( $scope, $node ) {
2288 $node['selector'] = static::scope_selector( $scope, $node['selector'] );
2289
2290 if ( empty( $node['selectors'] ) ) {
2291 return $node;
2292 }
2293
2294 foreach ( $node['selectors'] as $feature => $selector ) {
2295 if ( is_string( $selector ) ) {
2296 $node['selectors'][ $feature ] = static::scope_selector( $scope, $selector );
2297 }
2298 if ( is_array( $selector ) ) {
2299 foreach ( $selector as $subfeature => $subfeature_selector ) {
2300 $node['selectors'][ $feature ][ $subfeature ] = static::scope_selector( $scope, $subfeature_selector );
2301 }
2302 }
2303 }
2304
2305 return $node;
2306 }
2307
2308 /**
2309 * Gets preset values keyed by slugs based on settings and metadata.
2310 *
2311 * <code>
2312 * $settings = array(
2313 * 'typography' => array(
2314 * 'fontFamilies' => array(
2315 * array(
2316 * 'slug' => 'sansSerif',
2317 * 'fontFamily' => '"Helvetica Neue", sans-serif',
2318 * ),
2319 * array(
2320 * 'slug' => 'serif',
2321 * 'colors' => 'Georgia, serif',
2322 * )
2323 * ),
2324 * ),
2325 * );
2326 * $meta = array(
2327 * 'path' => array( 'typography', 'fontFamilies' ),
2328 * 'value_key' => 'fontFamily',
2329 * );
2330 * $values_by_slug = get_settings_values_by_slug();
2331 * // $values_by_slug === array(
2332 * // 'sans-serif' => '"Helvetica Neue", sans-serif',
2333 * // 'serif' => 'Georgia, serif',
2334 * // );
2335 * </code>
2336 *
2337 * @since 5.9.0
2338 * @since 6.6.0 Passing $settings to the callbacks defined in static::PRESETS_METADATA.
2339 *
2340 * @param array $settings Settings to process.
2341 * @param array $preset_metadata One of the PRESETS_METADATA values.
2342 * @param array $origins List of origins to process.
2343 * @return array Array of presets where each key is a slug and each value is the preset value.
2344 */
2345 protected static function get_settings_values_by_slug( $settings, $preset_metadata, $origins ) {
2346 $preset_per_origin = _wp_array_get( $settings, $preset_metadata['path'], array() );
2347
2348 $result = array();
2349 foreach ( $origins as $origin ) {
2350 if ( ! isset( $preset_per_origin[ $origin ] ) ) {
2351 continue;
2352 }
2353 foreach ( $preset_per_origin[ $origin ] as $preset ) {
2354 $slug = _wp_to_kebab_case( $preset['slug'] );
2355
2356 $value = '';
2357 if ( isset( $preset_metadata['value_key'], $preset[ $preset_metadata['value_key'] ] ) ) {
2358 $value_key = $preset_metadata['value_key'];
2359 $value = $preset[ $value_key ];
2360 } elseif (
2361 isset( $preset_metadata['value_func'] ) &&
2362 is_callable( $preset_metadata['value_func'] )
2363 ) {
2364 $value_func = $preset_metadata['value_func'];
2365 $value = call_user_func( $value_func, $preset, $settings );
2366 } else {
2367 // If we don't have a value, then don't add it to the result.
2368 continue;
2369 }
2370
2371 $result[ $slug ] = $value;
2372 }
2373 }
2374 return $result;
2375 }
2376
2377 /**
2378 * Similar to get_settings_values_by_slug, but doesn't compute the value.
2379 *
2380 * @since 5.9.0
2381 *
2382 * @param array $settings Settings to process.
2383 * @param array $preset_metadata One of the PRESETS_METADATA values.
2384 * @param array $origins List of origins to process.
2385 * @return array Array of presets where the key and value are both the slug.
2386 */
2387 protected static function get_settings_slugs( $settings, $preset_metadata, $origins = null ) {
2388 if ( null === $origins ) {
2389 $origins = static::VALID_ORIGINS;
2390 }
2391
2392 $preset_per_origin = _wp_array_get( $settings, $preset_metadata['path'], array() );
2393
2394 $result = array();
2395 foreach ( $origins as $origin ) {
2396 if ( ! isset( $preset_per_origin[ $origin ] ) ) {
2397 continue;
2398 }
2399 foreach ( $preset_per_origin[ $origin ] as $preset ) {
2400 $slug = _wp_to_kebab_case( $preset['slug'] );
2401
2402 // Use the array as a set so we don't get duplicates.
2403 $result[ $slug ] = $slug;
2404 }
2405 }
2406 return $result;
2407 }
2408
2409 /**
2410 * Transforms a slug into a CSS Custom Property.
2411 *
2412 * @since 5.9.0
2413 *
2414 * @param string $input String to replace.
2415 * @param string $slug The slug value to use to generate the custom property.
2416 * @return string The CSS Custom Property. Something along the lines of `--wp--preset--color--black`.
2417 */
2418 protected static function replace_slug_in_string( $input, $slug ) {
2419 return strtr( $input, array( '$slug' => $slug ) );
2420 }
2421
2422 /**
2423 * Given the block settings, extracts the CSS Custom Properties
2424 * for the presets and adds them to the $declarations array
2425 * following the format:
2426 *
2427 * ```php
2428 * array(
2429 * 'name' => 'property_name',
2430 * 'value' => 'property_value,
2431 * )
2432 * ```
2433 *
2434 * @since 5.8.0
2435 * @since 5.9.0 Added the `$origins` parameter.
2436 *
2437 * @param array $settings Settings to process.
2438 * @param array $origins List of origins to process.
2439 * @return array The modified $declarations.
2440 */
2441 protected static function compute_preset_vars( $settings, $origins ) {
2442 $declarations = array();
2443 foreach ( static::PRESETS_METADATA as $preset_metadata ) {
2444 if ( empty( $preset_metadata['css_vars'] ) ) {
2445 continue;
2446 }
2447
2448 $values_by_slug = static::get_settings_values_by_slug( $settings, $preset_metadata, $origins );
2449 foreach ( $values_by_slug as $slug => $value ) {
2450 $declarations[] = array(
2451 'name' => static::replace_slug_in_string( $preset_metadata['css_vars'], $slug ),
2452 'value' => $value,
2453 );
2454 }
2455 }
2456
2457 return $declarations;
2458 }
2459
2460 /**
2461 * Given an array of settings, extracts the CSS Custom Properties
2462 * for the custom values and adds them to the $declarations
2463 * array following the format:
2464 *
2465 * ```php
2466 * array(
2467 * 'name' => 'property_name',
2468 * 'value' => 'property_value,
2469 * )
2470 * ```
2471 *
2472 * @since 5.8.0
2473 *
2474 * @param array $settings Settings to process.
2475 * @return array The modified $declarations.
2476 */
2477 protected static function compute_theme_vars( $settings ) {
2478 $declarations = array();
2479 $custom_values = $settings['custom'] ?? array();
2480 $css_vars = static::flatten_tree( $custom_values );
2481 foreach ( $css_vars as $key => $value ) {
2482 $declarations[] = array(
2483 'name' => '--wp--custom--' . $key,
2484 'value' => $value,
2485 );
2486 }
2487
2488 return $declarations;
2489 }
2490
2491 /**
2492 * Given a tree, it creates a flattened one
2493 * by merging the keys and binding the leaf values
2494 * to the new keys.
2495 *
2496 * It also transforms camelCase names into kebab-case
2497 * and substitutes '/' by '-'.
2498 *
2499 * This is thought to be useful to generate
2500 * CSS Custom Properties from a tree,
2501 * although there's nothing in the implementation
2502 * of this function that requires that format.
2503 *
2504 * For example, assuming the given prefix is '--wp'
2505 * and the token is '--', for this input tree:
2506 *
2507 * {
2508 * 'some/property': 'value',
2509 * 'nestedProperty': {
2510 * 'sub-property': 'value'
2511 * }
2512 * }
2513 *
2514 * it'll return this output:
2515 *
2516 * {
2517 * '--wp--some-property': 'value',
2518 * '--wp--nested-property--sub-property': 'value'
2519 * }
2520 *
2521 * @since 5.8.0
2522 *
2523 * @param array $tree Input tree to process.
2524 * @param string $prefix Optional. Prefix to prepend to each variable. Default empty string.
2525 * @param string $token Optional. Token to use between levels. Default '--'.
2526 * @return array The flattened tree.
2527 */
2528 protected static function flatten_tree( $tree, $prefix = '', $token = '--' ) {
2529 $result = array();
2530 foreach ( $tree as $property => $value ) {
2531 $new_key = $prefix . str_replace(
2532 '/',
2533 '-',
2534 strtolower( _wp_to_kebab_case( $property ) )
2535 );
2536
2537 if ( is_array( $value ) ) {
2538 $new_prefix = $new_key . $token;
2539 $flattened_subtree = static::flatten_tree( $value, $new_prefix, $token );
2540 foreach ( $flattened_subtree as $subtree_key => $subtree_value ) {
2541 $result[ $subtree_key ] = $subtree_value;
2542 }
2543 } else {
2544 $result[ $new_key ] = $value;
2545 }
2546 }
2547 return $result;
2548 }
2549
2550 /**
2551 * Given a styles array, it extracts the style properties
2552 * and adds them to the $declarations array following the format:
2553 *
2554 * ```php
2555 * array(
2556 * 'name' => 'property_name',
2557 * 'value' => 'property_value',
2558 * )
2559 * ```
2560 *
2561 * @since 5.8.0
2562 * @since 5.9.0 Added the `$settings` and `$properties` parameters.
2563 * @since 6.1.0 Added `$theme_json`, `$selector`, and `$use_root_padding` parameters.
2564 * @since 6.5.0 Output a `min-height: unset` rule when `aspect-ratio` is set.
2565 * @since 6.6.0 Passing current theme JSON settings to wp_get_typography_font_size_value(). Using style engine to correctly fetch background CSS values.
2566 * @since 6.7.0 Allow ref resolution of background properties.
2567 *
2568 * @param array $styles Styles to process.
2569 * @param array $settings Theme settings.
2570 * @param array $properties Properties metadata.
2571 * @param array $theme_json Theme JSON array.
2572 * @param string $selector The style block selector.
2573 * @param boolean $use_root_padding Whether to add custom properties at root level.
2574 * @return array Returns the modified $declarations.
2575 */
2576 protected static function compute_style_properties( $styles, $settings = array(), $properties = null, $theme_json = null, $selector = null, $use_root_padding = null ) {
2577 if ( empty( $styles ) ) {
2578 return array();
2579 }
2580
2581 if ( null === $properties ) {
2582 $properties = static::PROPERTIES_METADATA;
2583 }
2584 $declarations = array();
2585 $root_variable_duplicates = array();
2586 $root_style_length = strlen( '--wp--style--root--' );
2587
2588 foreach ( $properties as $css_property => $value_path ) {
2589 if ( ! is_array( $value_path ) ) {
2590 continue;
2591 }
2592
2593 $is_root_style = str_starts_with( $css_property, '--wp--style--root--' );
2594 if ( $is_root_style && ( static::ROOT_BLOCK_SELECTOR !== $selector || ! $use_root_padding ) ) {
2595 continue;
2596 }
2597
2598 $value = static::get_property_value( $styles, $value_path, $theme_json );
2599
2600 // Root-level padding styles don't currently support strings with CSS shorthand values.
2601 // This may change: https://github.com/WordPress/gutenberg/issues/40132.
2602 if ( '--wp--style--root--padding' === $css_property && is_string( $value ) ) {
2603 continue;
2604 }
2605
2606 if ( $is_root_style && $use_root_padding ) {
2607 $root_variable_duplicates[] = substr( $css_property, $root_style_length );
2608 }
2609
2610 /*
2611 * Processes background image styles.
2612 * If the value is a URL, it will be converted to a CSS `url()` value.
2613 * For an uploaded image (images with a database ID), apply size and position
2614 * defaults equal to those applied in block supports in lib/background.php.
2615 */
2616 if ( 'background-image' === $css_property ) {
2617 $background_image_input = array();
2618 if ( ! empty( $value ) ) {
2619 $background_image_input['backgroundImage'] = $value;
2620 }
2621 $gradient_value = $styles['background']['gradient'] ?? null;
2622 if ( ! empty( $gradient_value ) ) {
2623 $background_image_input['gradient'] = $gradient_value;
2624 }
2625 if ( ! empty( $background_image_input ) ) {
2626 $background_styles = gutenberg_style_engine_get_styles(
2627 array( 'background' => $background_image_input )
2628 );
2629 $value = $background_styles['declarations'][ $css_property ] ?? null;
2630 }
2631 }
2632 if ( empty( $value ) && static::ROOT_BLOCK_SELECTOR !== $selector && ! empty( $styles['background']['backgroundImage']['id'] ) ) {
2633 if ( 'background-size' === $css_property ) {
2634 $value = 'cover';
2635 }
2636 // If the background size is set to `contain` and no position is set, set the position to `center`.
2637 if ( 'background-position' === $css_property ) {
2638 $background_size = $styles['background']['backgroundSize'] ?? null;
2639 $value = 'contain' === $background_size ? '50% 50%' : null;
2640 }
2641 }
2642
2643 // Skip if empty and not "0" or value represents array of longhand values.
2644 $has_missing_value = empty( $value ) && ! is_numeric( $value );
2645 if ( $has_missing_value || is_array( $value ) ) {
2646 continue;
2647 }
2648
2649 // Calculates fluid typography rules where available.
2650 if ( 'font-size' === $css_property ) {
2651 /*
2652 * wp_get_typography_font_size_value() will check
2653 * if fluid typography has been activated and also
2654 * whether the incoming value can be converted to a fluid value.
2655 * Values that already have a clamp() function will not pass the test,
2656 * and therefore the original $value will be returned.
2657 * Pass the current theme_json settings to override any global settings.
2658 */
2659 $value = gutenberg_get_typography_font_size_value( array( 'size' => $value ), $settings );
2660 }
2661
2662 if ( 'aspect-ratio' === $css_property ) {
2663 // For aspect ratio to work, other dimensions rules must be unset.
2664 // This ensures that a fixed height does not override the aspect ratio.
2665 $declarations[] = array(
2666 'name' => 'min-height',
2667 'value' => 'unset',
2668 );
2669 }
2670
2671 $declarations[] = array(
2672 'name' => $css_property,
2673 'value' => $value,
2674 );
2675 }
2676
2677 // If a variable value is added to the root, the corresponding property should be removed.
2678 foreach ( $root_variable_duplicates as $duplicate ) {
2679 $discard = array_search( $duplicate, array_column( $declarations, 'name' ), true );
2680 if ( is_numeric( $discard ) ) {
2681 array_splice( $declarations, $discard, 1 );
2682 }
2683 }
2684
2685 return $declarations;
2686 }
2687
2688 /**
2689 * Returns the style property for the given path.
2690 *
2691 * It also converts references to a path to the value
2692 * stored at that location, e.g.
2693 * { "ref": "style.color.background" } => "#fff".
2694 *
2695 * @since 5.8.0
2696 * @since 5.9.0 Added support for values of array type, which are returned as is.
2697 * @since 6.1.0 Added the `$theme_json` parameter.
2698 * @since 6.7.0 Added support for background image refs
2699 *
2700 * @param array $styles Styles subtree.
2701 * @param array $path Which property to process.
2702 * @param array $theme_json Theme JSON array.
2703 * @return string|array Style property value.
2704 */
2705 protected static function get_property_value( $styles, $path, $theme_json = null ) {
2706 $value = _wp_array_get( $styles, $path, '' );
2707
2708 // Gutenberg didn't have this check.
2709 if ( '' === $value || null === $value ) {
2710 // No need to process the value further.
2711 return '';
2712 }
2713
2714 /*
2715 * Where the current value is an array with a 'ref' key pointing
2716 * to a path, this converts that path into the value at that path.
2717 * For example: { "ref": "style.color.background" } => "#fff".
2718 */
2719 if ( is_array( $value ) && isset( $value['ref'] ) ) {
2720 $value_path = explode( '.', $value['ref'] );
2721 $ref_value = _wp_array_get( $theme_json, $value_path, null );
2722 // Background Image refs can refer to a string or an array containing a URL string.
2723 $ref_value_url = $ref_value['url'] ?? null;
2724 // Only use the ref value if we find anything.
2725 if ( ! empty( $ref_value ) && ( is_string( $ref_value ) || is_string( $ref_value_url ) ) ) {
2726 $value = $ref_value;
2727 }
2728
2729 if ( is_array( $ref_value ) && isset( $ref_value['ref'] ) ) {
2730 $path_string = json_encode( $path );
2731 $ref_value_string = json_encode( $ref_value );
2732 _doing_it_wrong(
2733 'get_property_value',
2734 sprintf(
2735 /* translators: 1: theme.json, 2: Value name, 3: Value path, 4: Another value name. */
2736 __( 'Your %1$s file uses a dynamic value (%2$s) for the path at %3$s. However, the value at %3$s is also a dynamic value (pointing to %4$s) and pointing to another dynamic value is not supported. Please update %3$s to point directly to %4$s.', 'gutenberg' ),
2737 'theme.json',
2738 $ref_value_string,
2739 $path_string,
2740 $ref_value['ref']
2741 ),
2742 '6.1.0'
2743 );
2744 }
2745 }
2746
2747 return $value;
2748 }
2749
2750 /**
2751 * Builds metadata for the setting nodes, which returns in the form of:
2752 *
2753 * [
2754 * [
2755 * 'path' => ['path', 'to', 'some', 'node' ],
2756 * 'selector' => 'CSS selector for some node'
2757 * ],
2758 * [
2759 * 'path' => [ 'path', 'to', 'other', 'node' ],
2760 * 'selector' => 'CSS selector for other node'
2761 * ],
2762 * ]
2763 *
2764 * @since 5.8.0
2765 *
2766 * @param array $theme_json The tree to extract setting nodes from.
2767 * @param array $selectors List of selectors per block.
2768 * @return array An array of setting nodes metadata.
2769 */
2770 protected static function get_setting_nodes( $theme_json, $selectors = array() ) {
2771 $nodes = array();
2772 if ( ! isset( $theme_json['settings'] ) ) {
2773 return $nodes;
2774 }
2775
2776 // Top-level.
2777 $nodes[] = array(
2778 'path' => array( 'settings' ),
2779 'selector' => static::ROOT_CSS_PROPERTIES_SELECTOR,
2780 );
2781
2782 // Calculate paths for blocks.
2783 if ( ! isset( $theme_json['settings']['blocks'] ) ) {
2784 return $nodes;
2785 }
2786
2787 foreach ( $theme_json['settings']['blocks'] as $name => $node ) {
2788 $selector = null;
2789 if ( isset( $selectors[ $name ]['selector'] ) ) {
2790 $selector = $selectors[ $name ]['selector'];
2791 }
2792
2793 $nodes[] = array(
2794 'path' => array( 'settings', 'blocks', $name ),
2795 'selector' => $selector,
2796 'selectors' => $selectors[ $name ]['selectors'] ?? array(),
2797 );
2798 }
2799
2800 return $nodes;
2801 }
2802
2803 /**
2804 * Builds metadata for the style nodes, which returns in the form of:
2805 *
2806 * [
2807 * [
2808 * 'path' => [ 'path', 'to', 'some', 'node' ],
2809 * 'selector' => 'CSS selector for some node',
2810 * 'duotone' => 'CSS selector for duotone for some node'
2811 * ],
2812 * [
2813 * 'path' => ['path', 'to', 'other', 'node' ],
2814 * 'selector' => 'CSS selector for other node',
2815 * 'duotone' => null
2816 * ],
2817 * ]
2818 *
2819 * @since 5.8.0
2820 *
2821 * @param array $theme_json The tree to extract style nodes from.
2822 * @param array $selectors List of selectors per block.
2823 * @param array $options An array of options to facilitate filtering style node generation
2824 * The options currently supported are:
2825 * - `include_block_style_variations` which includes CSS for block style variations.
2826 * @return array An array of style nodes metadata.
2827 */
2828 protected static function get_style_nodes( $theme_json, $selectors = array(), $options = array() ) {
2829 $nodes = array();
2830 if ( ! isset( $theme_json['styles'] ) ) {
2831 return $nodes;
2832 }
2833
2834 // Top-level.
2835 $nodes[] = array(
2836 'path' => array( 'styles' ),
2837 'selector' => static::ROOT_BLOCK_SELECTOR,
2838 );
2839
2840 if ( isset( $theme_json['styles']['elements'] ) ) {
2841 foreach ( self::ELEMENTS as $element => $selector ) {
2842 if ( ! isset( $theme_json['styles']['elements'][ $element ] ) || ! array_key_exists( $element, static::ELEMENTS ) ) {
2843 continue;
2844 }
2845
2846 // Handle element defaults.
2847 $nodes[] = array(
2848 'path' => array( 'styles', 'elements', $element ),
2849 'selector' => static::ELEMENTS[ $element ],
2850 );
2851
2852 // Handle any pseudo selectors for the element.
2853 if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] ) ) {
2854 foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] as $pseudo_selector ) {
2855
2856 if ( isset( $theme_json['styles']['elements'][ $element ][ $pseudo_selector ] ) ) {
2857 $nodes[] = array(
2858 'path' => array( 'styles', 'elements', $element ),
2859 'selector' => static::append_to_selector( static::ELEMENTS[ $element ], $pseudo_selector ),
2860 );
2861 }
2862 }
2863 }
2864 }
2865 }
2866
2867 // Blocks.
2868 if ( ! isset( $theme_json['styles']['blocks'] ) ) {
2869 return $nodes;
2870 }
2871
2872 $block_options = $options;
2873 if ( ! isset( $block_options['include_block_style_variations'] ) ) {
2874 $block_options['include_block_style_variations'] = true;
2875 }
2876 $block_nodes = static::get_block_nodes( $theme_json, $selectors, $block_options );
2877 foreach ( $block_nodes as $block_node ) {
2878 $nodes[] = $block_node;
2879 }
2880
2881 /**
2882 * Filters the list of style nodes with metadata.
2883 *
2884 * This allows for things like loading block CSS independently.
2885 *
2886 * @since 6.1.0
2887 *
2888 * @param array $nodes Style nodes with metadata.
2889 */
2890 return apply_filters( 'wp_theme_json_get_style_nodes', $nodes );
2891 }
2892
2893 /**
2894 * A public helper to get the block nodes from a theme.json file.
2895 *
2896 * @since 6.1.0
2897 *
2898 * @return array The block nodes in theme.json.
2899 */
2900 public function get_styles_block_nodes() {
2901 return static::get_block_nodes( $this->theme_json );
2902 }
2903
2904 /**
2905 * Returns a filtered declarations array if there is a separator block with only a background
2906 * style defined in theme.json by adding a color attribute to reflect the changes in the front.
2907 *
2908 * @since 6.1.1
2909 *
2910 * @param array $declarations List of declarations.
2911 * @return array $declarations List of declarations filtered.
2912 */
2913 private static function update_separator_declarations( $declarations ) {
2914 // Gutenberg and core implementation differed.
2915 // https://github.com/WordPress/gutenberg/pull/44943.
2916 $background_color = '';
2917 $border_color_matches = false;
2918 $text_color_matches = false;
2919
2920 foreach ( $declarations as $declaration ) {
2921 if ( 'background-color' === $declaration['name'] && ! $background_color && isset( $declaration['value'] ) ) {
2922 $background_color = $declaration['value'];
2923 } elseif ( 'border-color' === $declaration['name'] ) {
2924 $border_color_matches = true;
2925 } elseif ( 'color' === $declaration['name'] ) {
2926 $text_color_matches = true;
2927 }
2928
2929 if ( $background_color && $border_color_matches && $text_color_matches ) {
2930 break;
2931 }
2932 }
2933
2934 if ( $background_color && ! $border_color_matches && ! $text_color_matches ) {
2935 $declarations[] = array(
2936 'name' => 'color',
2937 'value' => $background_color,
2938 );
2939 }
2940
2941 return $declarations;
2942 }
2943
2944 /**
2945 * Updates button width declarations to use a calc() formula for percentage values.
2946 *
2947 * When a percentage width is set on the Button block via Global Styles, the
2948 * resulting CSS needs to account for block gap spacing so that buttons tile
2949 * correctly on a row (e.g. 4 buttons at 25% width all fit on one row).
2950 *
2951 * This mirrors the dynamic calc() formula applied at the block instance level
2952 * in the button block's stylesheet (style.scss).
2953 *
2954 * @since 7.1.0
2955 *
2956 * @param array $feature_declarations The feature declarations keyed by selector.
2957 * @param array $settings The theme.json settings.
2958 * @return array The updated feature declarations.
2959 */
2960 private static function update_button_width_declarations( $feature_declarations, $settings ) {
2961 if ( ! isset( $feature_declarations['.wp-block-button'] ) ) {
2962 return $feature_declarations;
2963 }
2964
2965 foreach ( $feature_declarations['.wp-block-button'] as &$declaration ) {
2966 if ( 'width' !== $declaration['name'] || ! isset( $declaration['value'] ) ) {
2967 continue;
2968 }
2969
2970 $value = $declaration['value'];
2971 $percentage = null;
2972
2973 // Case 1: Direct percentage value e.g. "25%".
2974 if ( is_string( $value ) && str_ends_with( $value, '%' ) ) {
2975 $percentage = (float) $value;
2976 }
2977
2978 // Case 2: Preset CSS var e.g. "var(--wp--preset--dimension--50)".
2979 if ( null === $percentage && is_string( $value ) && str_starts_with( $value, 'var(--wp--preset--dimension--' ) ) {
2980 // Extract the slug from the var name.
2981 $slug = substr( $value, strlen( 'var(--wp--preset--dimension--' ), -1 );
2982
2983 /*
2984 * Look up the preset size across all origins.
2985 * Check block-level settings first (core/button), then top-level settings.
2986 */
2987 $dimension_sizes = ( $settings['blocks']['core/button']['dimensions']['dimensionSizes'] ?? array() )
2988 + ( $settings['dimensions']['dimensionSizes'] ?? array() );
2989 foreach ( $dimension_sizes as $origin_sizes ) {
2990 if ( ! is_array( $origin_sizes ) ) {
2991 continue;
2992 }
2993 foreach ( $origin_sizes as $preset ) {
2994 if ( isset( $preset['slug'] ) && $slug === $preset['slug'] && isset( $preset['size'] ) ) {
2995 $size = $preset['size'];
2996 if ( is_string( $size ) && str_ends_with( $size, '%' ) ) {
2997 $percentage = (float) $size;
2998 }
2999 break 2;
3000 }
3001 }
3002 }
3003 }
3004
3005 if ( null === $percentage ) {
3006 continue;
3007 }
3008
3009 /*
3010 * Apply the same calc() formula as the block instance level (style.scss).
3011 * The numeric percentage value is used as a unitless number:
3012 * - Multiplied by 1% to get the percentage width.
3013 * - Divided by 100 to calculate the gap adjustment proportion.
3014 */
3015 $declaration['value'] = sprintf(
3016 'calc(%s * 1%% - (var(--wp--style--block-gap, 0.5em) * (1 - %s / 100)))',
3017 $percentage,
3018 $percentage
3019 );
3020 }
3021 unset( $declaration );
3022
3023 return $feature_declarations;
3024 }
3025
3026 /**
3027 * Updates the text indent selector for paragraph blocks based on the textIndent setting.
3028 *
3029 * The textIndent setting can be 'subsequent' (default), 'all', or false.
3030 * When set to 'all', the selector should be '.wp-block-paragraph' instead of
3031 * '.wp-block-paragraph + .wp-block-paragraph' to apply indent to all paragraphs.
3032 *
3033 * @since 7.0.0
3034 *
3035 * @param array $feature_declarations The feature declarations keyed by selector.
3036 * @param array $settings The theme.json settings.
3037 * @param string $block_name The block name being processed.
3038 * @return array The updated feature declarations.
3039 */
3040 private static function update_paragraph_text_indent_selector( $feature_declarations, $settings, $block_name ) {
3041 if ( 'core/paragraph' !== $block_name ) {
3042 return $feature_declarations;
3043 }
3044
3045 // Check block-level settings first, then fall back to global settings.
3046 $block_settings = $settings['blocks']['core/paragraph'] ?? null;
3047 $text_indent_setting = $block_settings['typography']['textIndent']
3048 ?? $settings['typography']['textIndent']
3049 ?? 'subsequent';
3050
3051 if ( 'all' !== $text_indent_setting ) {
3052 return $feature_declarations;
3053 }
3054
3055 // Look for the text indent selector and replace it.
3056 $old_selector = '.wp-block-paragraph + .wp-block-paragraph';
3057 $new_selector = '.wp-block-paragraph';
3058
3059 if ( isset( $feature_declarations[ $old_selector ] ) ) {
3060 $declarations = $feature_declarations[ $old_selector ];
3061 unset( $feature_declarations[ $old_selector ] );
3062 $feature_declarations[ $new_selector ] = $declarations;
3063 }
3064
3065 return $feature_declarations;
3066 }
3067
3068 /**
3069 * An internal method to get the block nodes from a theme.json file.
3070 *
3071 * @since 6.1.0
3072 *
3073 * @param array $theme_json The theme.json converted to an array.
3074 * @param array $selectors Optional list of selectors per block.
3075 * @param array $options {
3076 * Optional. An array of options for now used for internal purposes only (may change without notice).
3077 *
3078 * @type bool $include_block_style_variations Includes nodes for block style variations. Default false.
3079 * @type bool $include_node_paths_only Return only block nodes node paths. Default false.
3080 * }
3081 * @return array The block nodes in theme.json.
3082 */
3083 private static function get_block_nodes( $theme_json, $selectors = array(), $options = array() ) {
3084 $nodes = array();
3085
3086 if ( ! isset( $theme_json['styles']['blocks'] ) ) {
3087 return $nodes;
3088 }
3089
3090 $include_variations = $options['include_block_style_variations'] ?? false;
3091 $include_node_paths_only = $options['include_node_paths_only'] ?? false;
3092
3093 // If only node paths are to be returned, skip selector assignment.
3094 if ( ! $include_node_paths_only ) {
3095 $selectors = empty( $selectors ) ? static::get_blocks_metadata() : $selectors;
3096 }
3097
3098 foreach ( $theme_json['styles']['blocks'] as $name => $node ) {
3099 $node_path = array( 'styles', 'blocks', $name );
3100 if ( $include_node_paths_only ) {
3101 $variation_paths = array();
3102 if ( $include_variations && isset( $node['variations'] ) ) {
3103 foreach ( $node['variations'] as $variation => $variation_node ) {
3104 $variation_paths[] = array(
3105 'path' => array( 'styles', 'blocks', $name, 'variations', $variation ),
3106 );
3107 }
3108 }
3109 $node = array(
3110 'path' => $node_path,
3111 );
3112 if ( ! empty( $variation_paths ) ) {
3113 $node['variations'] = $variation_paths;
3114 }
3115 $nodes[] = $node;
3116 } else {
3117 $selector = null;
3118 if ( isset( $selectors[ $name ]['selector'] ) ) {
3119 $selector = $selectors[ $name ]['selector'];
3120 }
3121
3122 $duotone_selector = null;
3123 if ( isset( $selectors[ $name ]['duotone'] ) ) {
3124 $duotone_selector = $selectors[ $name ]['duotone'];
3125 }
3126
3127 $feature_selectors = null;
3128 if ( isset( $selectors[ $name ]['selectors'] ) ) {
3129 $feature_selectors = $selectors[ $name ]['selectors'];
3130 }
3131
3132 $variation_selectors = array();
3133
3134 if ( $include_variations && isset( $node['variations'] ) ) {
3135 foreach ( $node['variations'] as $variation => $node ) {
3136 $variation_selectors[] = array(
3137 'path' => array( 'styles', 'blocks', $name, 'variations', $variation ),
3138 'selector' => $selectors[ $name ]['styleVariations'][ $variation ],
3139 );
3140 }
3141 }
3142
3143 $nodes[] = array(
3144 'name' => $name,
3145 'path' => $node_path,
3146 'selector' => $selector,
3147 'selectors' => $feature_selectors,
3148 'elements' => $selectors[ $name ]['elements'] ?? array(),
3149 'duotone' => $duotone_selector,
3150 'variations' => $variation_selectors,
3151 'css' => $selector,
3152 );
3153
3154 // Responsive block nodes: emit one node per breakpoint that has styles.
3155 // These are rendered immediately after the base block node so that
3156 // the cascade order is: .block{} → @media{.block{}}
3157 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3158 if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ] ) ) {
3159 $nodes[] = array(
3160 'name' => $name,
3161 'path' => array( 'styles', 'blocks', $name, $breakpoint ),
3162 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
3163 'selector' => $selector,
3164 'selectors' => $feature_selectors,
3165 'elements' => $selectors[ $name ]['elements'] ?? array(),
3166 'variations' => $variation_selectors,
3167 'css' => $selector,
3168 );
3169 }
3170 }
3171
3172 // Handle any pseudo selectors for the block.
3173 if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) {
3174 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo_selector ) {
3175 $has_pseudo = isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] );
3176 $has_responsive_pseudo = false;
3177 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3178 if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) {
3179 $has_responsive_pseudo = true;
3180 break;
3181 }
3182 }
3183
3184 if ( ! $has_pseudo && ! $has_responsive_pseudo ) {
3185 continue;
3186 }
3187
3188 /*
3189 * Append the pseudo-selector to each feature selector so that
3190 * get_feature_declarations_for_node generates CSS scoped to the
3191 * pseudo-state (e.g. '.wp-block-button:hover') rather than the
3192 * default state (e.g. '.wp-block-button').
3193 */
3194 $pseudo_feature_selectors = array();
3195 foreach ( $feature_selectors ?? array() as $feature => $feature_selector ) {
3196 if ( is_array( $feature_selector ) ) {
3197 $pseudo_feature_selectors[ $feature ] = array();
3198 foreach ( $feature_selector as $subfeature => $subfeature_selector ) {
3199 $pseudo_feature_selectors[ $feature ][ $subfeature ] = static::append_to_selector( $subfeature_selector, $pseudo_selector );
3200 }
3201 } else {
3202 $pseudo_feature_selectors[ $feature ] = static::append_to_selector( $feature_selector, $pseudo_selector );
3203 }
3204 }
3205
3206 if ( $has_pseudo ) {
3207 $nodes[] = array(
3208 'name' => $name,
3209 'path' => array( 'styles', 'blocks', $name, $pseudo_selector ),
3210 'selector' => static::append_to_selector( $selector, $pseudo_selector ),
3211 'selectors' => $pseudo_feature_selectors,
3212 'elements' => $selectors[ $name ]['elements'] ?? array(),
3213 'duotone' => $duotone_selector,
3214 'variations' => $variation_selectors,
3215 'css' => static::append_to_selector( $selector, $pseudo_selector ),
3216 );
3217 }
3218
3219 // Responsive pseudo nodes: emit one node per breakpoint that has
3220 // this pseudo state, immediately after the default pseudo node.
3221 // Cascade order: .block:hover{} → @media{.block:hover{}}
3222 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3223 if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) {
3224 $nodes[] = array(
3225 'name' => $name,
3226 'path' => array( 'styles', 'blocks', $name, $breakpoint, $pseudo_selector ),
3227 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
3228 'selector' => static::append_to_selector( $selector, $pseudo_selector ),
3229 'selectors' => $pseudo_feature_selectors,
3230 'elements' => $selectors[ $name ]['elements'] ?? array(),
3231 'variations' => $variation_selectors,
3232 'css' => static::append_to_selector( $selector, $pseudo_selector ),
3233 );
3234 }
3235 }
3236 }
3237 }
3238
3239 // Handle custom states (e.g. '@current' for navigation).
3240 if ( isset( static::VALID_BLOCK_CUSTOM_STATES[ $name ] ) ) {
3241 foreach ( static::VALID_BLOCK_CUSTOM_STATES[ $name ] as $custom_state ) {
3242 if (
3243 isset( $theme_json['styles']['blocks'][ $name ][ $custom_state ] ) &&
3244 isset( $selectors[ $name ]['states'][ $custom_state ] )
3245 ) {
3246 $custom_css_selector = $selectors[ $name ]['states'][ $custom_state ];
3247 $nodes[] = array(
3248 'name' => $name,
3249 'path' => array( 'styles', 'blocks', $name, $custom_state ),
3250 'selector' => $custom_css_selector,
3251 'selectors' => $feature_selectors,
3252 'elements' => $selectors[ $name ]['elements'] ?? array(),
3253 'duotone' => $duotone_selector,
3254 'variations' => $variation_selectors,
3255 'css' => $custom_css_selector,
3256 );
3257
3258 // Sub-pseudo-selectors within the custom state.
3259 if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) {
3260 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo ) {
3261 if ( isset( $theme_json['styles']['blocks'][ $name ][ $custom_state ][ $pseudo ] ) ) {
3262 $compound_css_selector = static::append_to_selector( $custom_css_selector, $pseudo );
3263 $nodes[] = array(
3264 'name' => $name,
3265 'path' => array( 'styles', 'blocks', $name, $custom_state, $pseudo ),
3266 'selector' => $compound_css_selector,
3267 'selectors' => $feature_selectors,
3268 'elements' => $selectors[ $name ]['elements'] ?? array(),
3269 'duotone' => $duotone_selector,
3270 'variations' => $variation_selectors,
3271 'css' => $compound_css_selector,
3272 );
3273 }
3274 }
3275 }
3276 }
3277 }
3278 }
3279 }
3280 if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) {
3281 foreach ( $theme_json['styles']['blocks'][ $name ]['elements'] as $element => $node ) {
3282 $element_path = array( 'styles', 'blocks', $name, 'elements', $element );
3283 if ( $include_node_paths_only ) {
3284 $nodes[] = array(
3285 'path' => $element_path,
3286 );
3287 continue;
3288 }
3289
3290 $element_selector = $selectors[ $name ]['elements'][ $element ];
3291
3292 $nodes[] = array(
3293 'path' => $element_path,
3294 'selector' => $element_selector,
3295 );
3296
3297 // Responsive element nodes: one node per breakpoint that has
3298 // styles for this element. Cascade: a{} → @media{a{}}
3299 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3300 if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ] ) ) {
3301 $nodes[] = array(
3302 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ),
3303 'selector' => $element_selector,
3304 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
3305 );
3306 }
3307 }
3308
3309 // Handle any pseudo selectors for the element.
3310 if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] ) ) {
3311 foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] as $pseudo_selector ) {
3312 // Create element pseudo node if default or any responsive breakpoint has the pseudo.
3313 $has_element_pseudo = isset( $theme_json['styles']['blocks'][ $name ]['elements'][ $element ][ $pseudo_selector ] );
3314 if ( ! $has_element_pseudo ) {
3315 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $bp ) {
3316 if ( isset( $theme_json['styles']['blocks'][ $name ][ $bp ]['elements'][ $element ][ $pseudo_selector ] ) ) {
3317 $has_element_pseudo = true;
3318 break;
3319 }
3320 }
3321 }
3322
3323 if ( $has_element_pseudo ) {
3324 $element_pseudo_path = array( 'styles', 'blocks', $name, 'elements', $element );
3325 if ( $include_node_paths_only ) {
3326 $nodes[] = array(
3327 'path' => $element_pseudo_path,
3328 );
3329 continue;
3330 }
3331
3332 $nodes[] = array(
3333 'path' => $element_pseudo_path,
3334 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ),
3335 );
3336
3337 // Responsive element pseudo nodes: one node per breakpoint
3338 // that has this pseudo state for this element.
3339 // Cascade: a:hover{} → @media{a:hover{}}
3340 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3341 if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ][ $pseudo_selector ] ) ) {
3342 $nodes[] = array(
3343 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ),
3344 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ),
3345 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
3346 );
3347 }
3348 }
3349 }
3350 }
3351 }
3352 }
3353 }
3354 }
3355
3356 return $nodes;
3357 }
3358
3359 /**
3360 * Gets the CSS rules for a particular block from theme.json.
3361 *
3362 * @since 6.1.0
3363 * @since 6.6.0 Setting a min-height of HTML when root styles have a background gradient or image.
3364 *
3365 * @param array $block_metadata Metadata about the block to get styles for.
3366 *
3367 * @return string Styles for the block.
3368 */
3369 public function get_styles_for_block( $block_metadata ) {
3370 $node = _wp_array_get( $this->theme_json, $block_metadata['path'], array() );
3371 $use_root_padding = isset( $this->theme_json['settings']['useRootPaddingAwareAlignments'] ) && true === $this->theme_json['settings']['useRootPaddingAwareAlignments'];
3372 $selector = $block_metadata['selector'];
3373 $settings = $this->theme_json['settings'] ?? null;
3374 $is_root_selector = static::ROOT_BLOCK_SELECTOR === $selector;
3375 $media_query = $block_metadata['media_query'] ?? null;
3376
3377 $feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $node );
3378
3379 // Update text indent selector for paragraph blocks based on the textIndent setting.
3380 $block_name = $block_metadata['name'] ?? null;
3381 $feature_declarations = static::update_paragraph_text_indent_selector( $feature_declarations, $settings, $block_name );
3382 $block_elements = $block_metadata['elements'] ?? array();
3383
3384 // Update button width declarations for percentage values to use calc() with block gap.
3385 $feature_declarations = static::update_button_width_declarations( $feature_declarations, $settings );
3386
3387 // If there are style variations, generate the declarations for them, including any feature selectors the block may have.
3388 // Responsive nodes (those with a media_query) do not process variations — variation responsive
3389 // CSS is handled by the variation's own responsive nodes or the existing variation loop.
3390 $style_variation_declarations = array();
3391 $style_variation_custom_css = array();
3392 $style_variation_responsive_css = array();
3393 $style_variation_layout_metadata = array();
3394 if ( ! $media_query && ! empty( $block_metadata['variations'] ) ) {
3395 foreach ( $block_metadata['variations'] as $style_variation ) {
3396 $style_variation_node = _wp_array_get( $this->theme_json, $style_variation['path'], array() );
3397 $clean_style_variation_selector = trim( $style_variation['selector'] );
3398
3399 // Generate any feature/subfeature style declarations for the current style variation.
3400 $variation_declarations = static::get_feature_declarations_for_node( $block_metadata, $style_variation_node );
3401
3402 // Update text indent selector for paragraph blocks based on the textIndent setting.
3403 $variation_declarations = static::update_paragraph_text_indent_selector( $variation_declarations, $settings, $block_name );
3404
3405 // Update button width declarations for percentage values to use calc() with block gap.
3406 $variation_declarations = static::update_button_width_declarations( $variation_declarations, $settings );
3407
3408 // Combine selectors with style variation's selector and add to overall style variation declarations.
3409 foreach ( $variation_declarations as $current_selector => $new_declarations ) {
3410 /*
3411 * Clean up any whitespace between comma separated selectors.
3412 * This prevents these spaces breaking compound selectors such as:
3413 * - `.wp-block-list:not(.wp-block-list .wp-block-list)`
3414 * - `.wp-block-image img, .wp-block-image.my-class img`
3415 */
3416 $clean_current_selector = preg_replace( '/,\s+/', ',', $current_selector );
3417 $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_current_selector );
3418
3419 // Prepend the variation selector to the current selector.
3420 $split_selectors = explode( ',', $shortened_selector );
3421 $updated_selectors = array_map(
3422 static function ( $split_selector ) use ( $clean_style_variation_selector ) {
3423 return $clean_style_variation_selector . $split_selector;
3424 },
3425 $split_selectors
3426 );
3427 $combined_selectors = implode( ',', $updated_selectors );
3428
3429 // Add the new declarations to the overall results under the modified selector.
3430 $style_variation_declarations[ $combined_selectors ] = $new_declarations;
3431 }
3432 // Compute declarations for remaining styles not covered by feature level selectors.
3433 $style_variation_declarations[ $style_variation['selector'] ] = static::compute_style_properties( $style_variation_node, $settings, null, $this->theme_json );
3434
3435 // Process pseudo-selectors for this variation (e.g., :hover, :focus).
3436 $block_name = $block_metadata['name'] ?? ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 3 ? static::get_block_name_from_metadata_path( $block_metadata ) : null );
3437 $variation_pseudo_declarations = static::process_pseudo_selectors( $style_variation_node, $style_variation['selector'], $settings, $block_name );
3438 $style_variation_declarations = array_merge( $style_variation_declarations, $variation_pseudo_declarations );
3439
3440 // Store custom CSS for the style variation.
3441 if ( isset( $style_variation_node['css'] ) ) {
3442 $style_variation_custom_css[ $style_variation['selector'] ] = $this->process_blocks_custom_css( $style_variation_node['css'], $style_variation['selector'] );
3443 }
3444
3445 // Store variation metadata and node for layout styles generation.
3446 // Only store if the variation has blockGap defined.
3447 if ( isset( $style_variation_node['spacing']['blockGap'] ) ) {
3448 // Append block selector to the variation selector for proper targeting.
3449 $variation_metadata_with_selector = $style_variation;
3450 $variation_metadata_with_selector['selector'] = $style_variation['selector'] . $block_metadata['css'];
3451 $style_variation_layout_metadata[ $style_variation['selector'] ] = array(
3452 'metadata' => $variation_metadata_with_selector,
3453 'node' => $style_variation_node,
3454 );
3455 }
3456
3457 // Store responsive breakpoint CSS for the style variation.
3458 // This includes both base properties and feature-level selectors.
3459 $variation_responsive_css = '';
3460
3461 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
3462 if ( ! isset( $style_variation_node[ $breakpoint ] ) ) {
3463 continue;
3464 }
3465
3466 $breakpoint_node = $style_variation_node[ $breakpoint ];
3467 $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ];
3468 // Process feature-level declarations for this breakpoint.
3469 $breakpoint_feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $breakpoint_node );
3470 $breakpoint_feature_declarations = static::update_paragraph_text_indent_selector( $breakpoint_feature_declarations, $settings, $block_name );
3471 $breakpoint_feature_declarations = static::update_button_width_declarations( $breakpoint_feature_declarations, $settings );
3472 foreach ( $breakpoint_feature_declarations as $feature_selector => $feature_decl ) {
3473 $clean_feature_selector = preg_replace( '/,\s+/', ',', $feature_selector );
3474 $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_feature_selector );
3475
3476 if ( $block_metadata['selector'] && ! str_contains( $clean_feature_selector, $block_metadata['selector'] ) ) {
3477 /*
3478 * Feature selector is block-level (e.g. `.wp-block-button` for
3479 * dimensions/width) — apply the variation class directly to it.
3480 */
3481 $feature_element_selector = str_replace( $shortened_selector, '', $clean_style_variation_selector );
3482 $combined_selectors = str_replace( $feature_element_selector, '', $clean_style_variation_selector );
3483 } else {
3484 // Prepend the variation selector to the current selector.
3485 $split_selectors = explode( ',', $shortened_selector );
3486 $updated_selectors = array_map(
3487 static function ( $split_selector ) use ( $clean_style_variation_selector ) {
3488 return $clean_style_variation_selector . $split_selector;
3489 },
3490 $split_selectors
3491 );
3492 $combined_selectors = implode( ',', $updated_selectors );
3493 }
3494
3495 $feature_ruleset = static::to_ruleset( ':root :where(' . $combined_selectors . ')', $feature_decl );
3496 $variation_responsive_css .= $breakpoint_media . '{' . $feature_ruleset . '}';
3497 }
3498
3499 // Process base properties for this breakpoint.
3500 $breakpoint_declarations = static::compute_style_properties( $breakpoint_node, $settings, null, $this->theme_json );
3501 if ( ! empty( $breakpoint_declarations ) ) {
3502 $base_ruleset = static::to_ruleset( ':root :where(' . $style_variation['selector'] . ')', $breakpoint_declarations );
3503 $variation_responsive_css .= $breakpoint_media . '{' . $base_ruleset . '}';
3504 }
3505
3506 $breakpoint_pseudo_declarations = static::process_pseudo_selectors( $breakpoint_node, $style_variation['selector'], $settings, $block_name );
3507 foreach ( $breakpoint_pseudo_declarations as $pseudo_selector => $pseudo_declarations ) {
3508 if ( empty( $pseudo_declarations ) ) {
3509 continue;
3510 }
3511 $pseudo_ruleset = static::to_ruleset( ':root :where(' . $pseudo_selector . ')', $pseudo_declarations );
3512 $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_ruleset . '}';
3513 }
3514
3515 // Process custom CSS for this breakpoint.
3516 if ( isset( $breakpoint_node['css'] ) ) {
3517 $breakpoint_custom_css = static::process_blocks_custom_css( $breakpoint_node['css'], $style_variation['selector'] );
3518 $variation_responsive_css .= $breakpoint_media . '{' . $breakpoint_custom_css . '}';
3519 }
3520
3521 // Process blockGap responsive layout styles for this variation.
3522 if ( isset( $breakpoint_node['spacing']['blockGap'] ) ) {
3523 $variation_layout_metadata = $style_variation;
3524 $variation_layout_metadata['selector'] = $style_variation['selector'] . $block_metadata['css'];
3525 $variation_responsive_css .= $this->get_layout_styles(
3526 $variation_layout_metadata,
3527 array(
3528 'node' => $breakpoint_node,
3529 'media_query' => $breakpoint_media,
3530 )
3531 );
3532 }
3533
3534 // Process nested element styles for this breakpoint state.
3535 if ( isset( $breakpoint_node['elements'] ) && ! empty( $block_elements ) ) {
3536 foreach ( $breakpoint_node['elements'] as $element_name => $element_node ) {
3537 if ( ! isset( $block_elements[ $element_name ] ) ) {
3538 continue;
3539 }
3540
3541 $clean_element_selector = preg_replace( '/,\s+/', ',', $block_elements[ $element_name ] );
3542 $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_element_selector );
3543 $split_selectors = explode( ',', $shortened_selector );
3544 $updated_selectors = array_map(
3545 static function ( $split_selector ) use ( $clean_style_variation_selector ) {
3546 return $clean_style_variation_selector . $split_selector;
3547 },
3548 $split_selectors
3549 );
3550 $variation_element_selector = implode( ',', $updated_selectors );
3551
3552 $element_declarations = static::compute_style_properties( $element_node, $settings, null, $this->theme_json );
3553 if ( ! empty( $element_declarations ) ) {
3554 $element_ruleset = static::to_ruleset( ':root :where(' . $variation_element_selector . ')', $element_declarations );
3555 $variation_responsive_css .= $breakpoint_media . '{' . $element_ruleset . '}';
3556 }
3557
3558 if ( isset( $element_node['css'] ) ) {
3559 $element_custom_css = static::process_blocks_custom_css( $element_node['css'], $variation_element_selector );
3560 $variation_responsive_css .= $breakpoint_media . '{' . $element_custom_css . '}';
3561 }
3562
3563 if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) {
3564 foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) {
3565 if ( ! isset( $element_node[ $pseudo_selector ] ) ) {
3566 continue;
3567 }
3568
3569 $pseudo_declarations = static::compute_style_properties( $element_node[ $pseudo_selector ], $settings, null, $this->theme_json );
3570 if ( empty( $pseudo_declarations ) ) {
3571 continue;
3572 }
3573
3574 $pseudo_selector_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $variation_element_selector, $pseudo_selector ) . ')', $pseudo_declarations );
3575 $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_selector_ruleset . '}';
3576 }
3577 }
3578 }
3579 }
3580 }
3581
3582 if ( ! empty( $variation_responsive_css ) ) {
3583 $style_variation_responsive_css[ $style_variation['selector'] ] = $variation_responsive_css;
3584 }
3585 }
3586 }
3587 /*
3588 * Get a reference to element name from path.
3589 * $block_metadata['path'] = array( 'styles','elements','link' );
3590 * Make sure that $block_metadata['path'] describes an element node, like [ 'styles', 'element', 'link' ].
3591 * Skip non-element paths like just ['styles'].
3592 */
3593 $is_processing_element = in_array( 'elements', $block_metadata['path'], true );
3594
3595 $current_element = $is_processing_element ? $block_metadata['path'][ count( $block_metadata['path'] ) - 1 ] : null;
3596
3597 $element_pseudo_allowed = array();
3598
3599 if ( isset( $current_element, static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] ) ) {
3600 $element_pseudo_allowed = static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ];
3601 }
3602
3603 /*
3604 * Check for allowed pseudo classes (e.g. ":hover") from the $selector ("a:hover").
3605 * This also resets the array keys.
3606 */
3607 $pseudo_matches = array_values(
3608 array_filter(
3609 $element_pseudo_allowed,
3610 static function ( $pseudo_selector ) use ( $selector ) {
3611 /*
3612 * Check if the pseudo selector is in the current selector,
3613 * ensuring it is not followed by a dash (e.g., :focus should not match :focus-visible).
3614 */
3615 return preg_match( '/' . preg_quote( $pseudo_selector, '/' ) . '(?!-)/', $selector ) === 1;
3616 }
3617 )
3618 );
3619
3620 $pseudo_selector = $pseudo_matches[0] ?? null;
3621
3622 /*
3623 * If the current selector is a pseudo selector that's defined in the allow list for the current
3624 * element then compute the style properties for it.
3625 * Otherwise just compute the styles for the default selector as normal.
3626 */
3627 if ( $pseudo_selector && isset( $node[ $pseudo_selector ] ) &&
3628 isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] )
3629 && in_array( $pseudo_selector, static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ], true )
3630 ) {
3631 $declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, $this->theme_json, $selector, $use_root_padding );
3632 } else {
3633 /*
3634 * For block pseudo-selector nodes (e.g. ':hover'), $node has already had any
3635 * feature-selector properties (e.g. writingMode) removed by get_feature_declarations_for_node,
3636 * so those properties are not output twice.
3637 */
3638 $declarations = static::compute_style_properties( $node, $settings, null, $this->theme_json, $selector, $use_root_padding );
3639 }
3640
3641 $block_rules = '';
3642
3643 /*
3644 * 1. Bespoke declaration modifiers:
3645 * - 'filter': Separate the declarations that use the general selector
3646 * from the ones using the duotone selector.
3647 * - 'background|background-image': set the html min-height to 100%
3648 * to ensure the background covers the entire viewport.
3649 *
3650 */
3651 $declarations_duotone = array();
3652 $should_set_root_min_height = false;
3653
3654 foreach ( $declarations as $index => $declaration ) {
3655 if ( 'filter' === $declaration['name'] ) {
3656 /*
3657 * 'unset' filters happen when a filter is unset
3658 * in the site-editor UI. Because the 'unset' value
3659 * in the user origin overrides the value in the
3660 * theme origin, we can skip rendering anything
3661 * here as no filter needs to be applied anymore.
3662 * So only add declarations to with values other
3663 * than 'unset'.
3664 */
3665 if ( 'unset' !== $declaration['value'] ) {
3666 $declarations_duotone[] = $declaration;
3667 }
3668 unset( $declarations[ $index ] );
3669 }
3670
3671 if ( $is_root_selector && ( 'background-image' === $declaration['name'] || 'background' === $declaration['name'] ) ) {
3672 $should_set_root_min_height = true;
3673 }
3674 }
3675
3676 /*
3677 * If root styles has a background-image or a background (gradient) set,
3678 * set the min-height to '100%'. Minus `--wp-admin--admin-bar--height` for logged-in view.
3679 * Setting the CSS rule on the HTML tag ensures background gradients and images behave similarly,
3680 * and matches the behavior of the site editor.
3681 */
3682 if ( $should_set_root_min_height ) {
3683 $block_rules .= static::to_ruleset(
3684 'html',
3685 array(
3686 array(
3687 'name' => 'min-height',
3688 'value' => 'calc(100% - var(--wp-admin--admin-bar--height, 0px))',
3689 ),
3690 )
3691 );
3692 }
3693
3694 // Update declarations if there are separators with only background color defined.
3695 if ( '.wp-block-separator' === $selector ) {
3696 $declarations = static::update_separator_declarations( $declarations );
3697 }
3698
3699 /*
3700 * Root selector (body) styles should not be wrapped in `:root where()` to keep
3701 * specificity at (0,0,1) and maintain backwards compatibility.
3702 *
3703 * Top-level element styles using element-only specificity selectors should
3704 * not get wrapped in `:root :where()` to maintain backwards compatibility.
3705 *
3706 * Pseudo classes, e.g. :hover, :focus etc., are a class-level selector so
3707 * still need to be wrapped in `:root :where` to cap specificity for nested
3708 * variations etc. Pseudo selectors won't match the ELEMENTS selector exactly.
3709 */
3710 $element_only_selector = $is_root_selector || (
3711 $current_element &&
3712 isset( static::ELEMENTS[ $current_element ] ) &&
3713 // buttons, captions etc. still need `:root :where()` as they are class based selectors.
3714 ! isset( static::__EXPERIMENTAL_ELEMENT_CLASS_NAMES[ $current_element ] ) &&
3715 static::ELEMENTS[ $current_element ] === $selector
3716 );
3717
3718 // 2. Generate and append the rules that use the general selector.
3719 $general_selector = $element_only_selector ? $selector : ":root :where($selector)";
3720 $block_rules .= static::to_ruleset( $general_selector, $declarations );
3721
3722 // 3. Generate and append the rules that use the duotone selector.
3723 if ( isset( $block_metadata['duotone'] ) && ! empty( $declarations_duotone ) ) {
3724 $block_rules .= static::to_ruleset( $block_metadata['duotone'], $declarations_duotone );
3725 }
3726
3727 // 4. Generate Layout block gap styles.
3728 if (
3729 ! $is_root_selector &&
3730 ! empty( $block_metadata['name'] )
3731 ) {
3732 $block_rules .= $this->get_layout_styles( $block_metadata );
3733 }
3734
3735 // 5. Generate and append the feature level rulesets.
3736 foreach ( $feature_declarations as $feature_selector => $individual_feature_declarations ) {
3737 $block_rules .= static::to_ruleset( ":root :where($feature_selector)", $individual_feature_declarations );
3738 }
3739
3740 // 6. Generate and append the style variation rulesets.
3741 foreach ( $style_variation_declarations as $style_variation_selector => $individual_style_variation_declarations ) {
3742 $block_rules .= static::to_ruleset( ":root :where($style_variation_selector)", $individual_style_variation_declarations );
3743 if ( isset( $style_variation_layout_metadata[ $style_variation_selector ] ) ) {
3744 $variation_data = $style_variation_layout_metadata[ $style_variation_selector ];
3745 $block_rules .= $this->get_layout_styles( $variation_data['metadata'], array( 'node' => $variation_data['node'] ) );
3746 }
3747 if ( isset( $style_variation_custom_css[ $style_variation_selector ] ) ) {
3748 $block_rules .= $style_variation_custom_css[ $style_variation_selector ];
3749 }
3750 if ( isset( $style_variation_responsive_css[ $style_variation_selector ] ) ) {
3751 $block_rules .= $style_variation_responsive_css[ $style_variation_selector ];
3752 }
3753 }
3754
3755 // Compute selector for block custom CSS.
3756 $css_feature_selector = $block_metadata['selectors']['css'] ?? null;
3757 if ( is_array( $css_feature_selector ) ) {
3758 $css_feature_selector = $css_feature_selector['root'] ?? null;
3759 }
3760 $css_selector = is_string( $css_feature_selector ) ? $css_feature_selector : $selector;
3761
3762 // 7. Generate and append any custom CSS rules.
3763 if ( isset( $node['css'] ) && ! $is_root_selector ) {
3764 $block_rules .= $this->process_blocks_custom_css( $node['css'], $css_selector );
3765 }
3766
3767 // 8. Wrap the entire block output in a media query if this is a responsive node.
3768 // Responsive nodes are created by get_block_nodes() for each breakpoint and carry
3769 // a 'media_query' key.
3770 if ( $media_query && ! empty( $block_rules ) ) {
3771 $block_rules = $media_query . '{' . $block_rules . '}';
3772 }
3773
3774 return $block_rules;
3775 }
3776
3777 /**
3778 * Outputs the CSS for layout rules on the root.
3779 *
3780 * @since 6.1.0
3781 * @since 6.6.0 Use `ROOT_CSS_PROPERTIES_SELECTOR` for CSS custom properties.
3782 *
3783 * @param string $selector The root node selector.
3784 * @param array $block_metadata The metadata for the root block.
3785 * @param array $options Optional. An array of options. Default empty array.
3786 * @return string The additional root rules CSS.
3787 */
3788 public function get_root_layout_rules( $selector, $block_metadata, $options = array() ) {
3789 $css = '';
3790 $settings = $this->theme_json['settings'] ?? array();
3791 $use_root_padding = isset( $this->theme_json['settings']['useRootPaddingAwareAlignments'] ) && true === $this->theme_json['settings']['useRootPaddingAwareAlignments'];
3792
3793 /*
3794 * If there are content and wide widths in theme.json, output them
3795 * as custom properties on the body element so all blocks can use them.
3796 */
3797 if ( isset( $settings['layout']['contentSize'] ) || isset( $settings['layout']['wideSize'] ) ) {
3798 $content_size = $settings['layout']['contentSize'] ?? $settings['layout']['wideSize'];
3799 $content_size = static::is_safe_css_declaration( 'max-width', $content_size ) ? $content_size : 'initial';
3800 $wide_size = $settings['layout']['wideSize'] ?? $settings['layout']['contentSize'];
3801 $wide_size = static::is_safe_css_declaration( 'max-width', $wide_size ) ? $wide_size : 'initial';
3802 $css .= static::ROOT_CSS_PROPERTIES_SELECTOR . ' { --wp--style--global--content-size: ' . $content_size . ';';
3803 $css .= '--wp--style--global--wide-size: ' . $wide_size . '; }';
3804 }
3805
3806 /*
3807 * Reset default browser margin on the body element.
3808 * This is set on the body selector **before** generating the ruleset
3809 * from the `theme.json`. This is to ensure that if the `theme.json` declares
3810 * `margin` in its `spacing` declaration for the `body` element then these
3811 * user-generated values take precedence in the CSS cascade.
3812 * @link https://github.com/WordPress/gutenberg/issues/36147.
3813 */
3814 $css .= ':where(body) { margin: 0; }';
3815
3816 if ( $use_root_padding ) {
3817 // Top and bottom padding are applied to the outer block container.
3818 $css .= '.wp-site-blocks { padding-top: var(--wp--style--root--padding-top); padding-bottom: var(--wp--style--root--padding-bottom); }';
3819 // Right and left padding are applied to the first container with `.has-global-padding` class.
3820 $css .= '.has-global-padding { padding-right: var(--wp--style--root--padding-right); padding-left: var(--wp--style--root--padding-left); }';
3821 // Alignfull children of the container with left and right padding have negative margins so they can still be full width.
3822 $css .= '.has-global-padding > .alignfull { margin-right: calc(var(--wp--style--root--padding-right) * -1); margin-left: calc(var(--wp--style--root--padding-left) * -1); }';
3823 // Nested children of the container with left and right padding that are not full aligned do not get padding, unless they are direct children of an alignfull flow container.
3824 $css .= '.has-global-padding :where(:not(.alignfull.is-layout-flow) > .has-global-padding:not(.wp-block-block, .alignfull)) { padding-right: 0; padding-left: 0; }';
3825 // Alignfull direct children of the containers that are targeted by the rule above do not need negative margins.
3826 $css .= '.has-global-padding :where(:not(.alignfull.is-layout-flow) > .has-global-padding:not(.wp-block-block, .alignfull)) > .alignfull { margin-left: 0; margin-right: 0; }';
3827 }
3828
3829 // Skip outputting alignment styles when base_layout_styles is enabled.
3830 // These styles target .wp-site-blocks which is only used by block themes.
3831 if ( empty( $options['base_layout_styles'] ) ) {
3832 $css .= '.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }';
3833 $css .= '.wp-site-blocks > .alignright { float: right; margin-left: 2em; }';
3834 $css .= '.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }';
3835 }
3836
3837 // Block gap styles will be output unless explicitly set to `null`.
3838 if ( isset( $this->theme_json['settings']['spacing']['blockGap'] ) ) {
3839 $block_gap_value = static::get_property_value( $this->theme_json, array( 'styles', 'spacing', 'blockGap' ) );
3840 $css .= ":where(.wp-site-blocks) > * { margin-block-start: $block_gap_value; margin-block-end: 0; }";
3841 $css .= ':where(.wp-site-blocks) > :first-child { margin-block-start: 0; }';
3842 $css .= ':where(.wp-site-blocks) > :last-child { margin-block-end: 0; }';
3843
3844 // For backwards compatibility, ensure the legacy block gap CSS variable is still available.
3845 $css .= static::ROOT_CSS_PROPERTIES_SELECTOR . " { --wp--style--block-gap: $block_gap_value; }";
3846 }
3847 $css .= $this->get_layout_styles( $block_metadata, $options );
3848
3849 return $css;
3850 }
3851
3852 /**
3853 * For metadata values that can either be booleans or paths to booleans, gets the value.
3854 *
3855 * ```php
3856 * $data = array(
3857 * 'color' => array(
3858 * 'defaultPalette' => true
3859 * )
3860 * );
3861 *
3862 * static::get_metadata_boolean( $data, false );
3863 * // => false
3864 *
3865 * static::get_metadata_boolean( $data, array( 'color', 'defaultPalette' ) );
3866 * // => true
3867 * ```
3868 *
3869 * @since 6.0.0
3870 *
3871 * @param array $data The data to inspect.
3872 * @param bool|array $path Boolean or path to a boolean.
3873 * @param bool $default_value Default value if the referenced path is missing.
3874 * Default false.
3875 * @return bool Value of boolean metadata.
3876 */
3877 protected static function get_metadata_boolean( $data, $path, $default_value = false ) {
3878 if ( is_bool( $path ) ) {
3879 return $path;
3880 }
3881
3882 if ( is_array( $path ) ) {
3883 $value = _wp_array_get( $data, $path );
3884 if ( null !== $value ) {
3885 return $value;
3886 }
3887 }
3888
3889 return $default_value;
3890 }
3891
3892 /**
3893 * Merges new incoming data.
3894 *
3895 * @since 5.8.0
3896 * @since 5.9.0 Duotone preset also has origins.
3897 * @since 6.6.0 Use the spacingScale keyed by origin, and re-generate the
3898 * spacingSizes from spacingScale.
3899 *
3900 * @param WP_Theme_JSON_Gutenberg $incoming Data to merge.
3901 */
3902 public function merge( $incoming ) {
3903 $incoming_data = $incoming->get_raw_data();
3904 $this->theme_json = array_replace_recursive( $this->theme_json, $incoming_data );
3905
3906 /*
3907 * Recompute all the spacing sizes based on the new hierarchy of data. In the constructor
3908 * spacingScale and spacingSizes are both keyed by origin and VALID_ORIGINS is ordered, so
3909 * we can allow partial spacingScale data to inherit missing data from earlier layers when
3910 * computing the spacing sizes.
3911 *
3912 * This happens before the presets are merged to ensure that default spacing sizes can be
3913 * removed from the theme origin if $prevent_override is true.
3914 */
3915 $flattened_spacing_scale = array();
3916 foreach ( static::VALID_ORIGINS as $origin ) {
3917 $scale_path = array( 'settings', 'spacing', 'spacingScale', $origin );
3918
3919 // Apply the base spacing scale to the current layer.
3920 $base_spacing_scale = _wp_array_get( $this->theme_json, $scale_path, array() );
3921 $flattened_spacing_scale = array_replace( $flattened_spacing_scale, $base_spacing_scale );
3922
3923 $spacing_scale = _wp_array_get( $incoming_data, $scale_path, null );
3924 if ( ! isset( $spacing_scale ) ) {
3925 continue;
3926 }
3927
3928 // Allow partial scale settings by merging with lower layers.
3929 $flattened_spacing_scale = array_replace( $flattened_spacing_scale, $spacing_scale );
3930
3931 // Generate and merge the scales for this layer.
3932 $sizes_path = array( 'settings', 'spacing', 'spacingSizes', $origin );
3933 $spacing_sizes = _wp_array_get( $incoming_data, $sizes_path, array() );
3934 $spacing_scale_sizes = static::compute_spacing_sizes( $flattened_spacing_scale );
3935 $merged_spacing_sizes = static::merge_spacing_sizes( $spacing_scale_sizes, $spacing_sizes );
3936
3937 _wp_array_set( $incoming_data, $sizes_path, $merged_spacing_sizes );
3938 }
3939
3940 /*
3941 * The array_replace_recursive algorithm merges at the leaf level,
3942 * but we don't want leaf arrays to be merged, so we overwrite it.
3943 *
3944 * For leaf values that are sequential arrays it will use the numeric indexes for replacement.
3945 * We rather replace the existing with the incoming value, if it exists.
3946 * This is the case of spacing.units.
3947 *
3948 * For leaf values that are associative arrays it will merge them as expected.
3949 * This is also not the behavior we want for the current associative arrays (presets).
3950 * We rather replace the existing with the incoming value, if it exists.
3951 * This happens, for example, when we merge data from theme.json upon existing
3952 * theme supports or when we merge anything coming from the same source twice.
3953 * This is the case of color.palette, color.gradients, color.duotone,
3954 * typography.fontSizes, or typography.fontFamilies.
3955 *
3956 * Additionally, for some preset types, we also want to make sure the
3957 * values they introduce don't conflict with default values. We do so
3958 * by checking the incoming slugs for theme presets and compare them
3959 * with the equivalent default presets: if a slug is present as a default
3960 * we remove it from the theme presets.
3961 */
3962 $nodes = static::get_setting_nodes( $incoming_data );
3963 $slugs_global = static::get_default_slugs( $this->theme_json, array( 'settings' ) );
3964 foreach ( $nodes as $node ) {
3965 // Replace the spacing.units.
3966 $path = $node['path'];
3967 $path[] = 'spacing';
3968 $path[] = 'units';
3969
3970 $content = _wp_array_get( $incoming_data, $path, null );
3971 if ( isset( $content ) ) {
3972 _wp_array_set( $this->theme_json, $path, $content );
3973 }
3974
3975 // Replace the presets.
3976 foreach ( static::PRESETS_METADATA as $preset_metadata ) {
3977 $prevent_override = $preset_metadata['prevent_override'];
3978 if ( is_array( $prevent_override ) ) {
3979 $global_path = array_merge( array( 'settings' ), $prevent_override );
3980 $global_value = _wp_array_get( $this->theme_json, $global_path, null );
3981
3982 $node_level_path = array_merge( $node['path'], $prevent_override );
3983 $prevent_override = _wp_array_get( $this->theme_json, $node_level_path, $global_value );
3984 }
3985
3986 foreach ( static::VALID_ORIGINS as $origin ) {
3987 $base_path = $node['path'];
3988 foreach ( $preset_metadata['path'] as $leaf ) {
3989 $base_path[] = $leaf;
3990 }
3991
3992 $path = $base_path;
3993 $path[] = $origin;
3994
3995 $content = _wp_array_get( $incoming_data, $path, null );
3996 if ( ! isset( $content ) ) {
3997 continue;
3998 }
3999
4000 // Set names for theme presets based on the slug if they are not set and can use default names.
4001 if ( 'theme' === $origin && $preset_metadata['use_default_names'] ) {
4002 foreach ( $content as $key => $item ) {
4003 if ( ! isset( $item['name'] ) ) {
4004 $name = static::get_name_from_defaults( $item['slug'], $base_path );
4005 if ( null !== $name ) {
4006 $content[ $key ]['name'] = $name;
4007 }
4008 }
4009 }
4010 }
4011
4012 // Filter out default slugs from theme presets when defaults should not be overridden.
4013 if ( 'theme' === $origin && $prevent_override ) {
4014 $slugs_node = static::get_default_slugs( $this->theme_json, $node['path'] );
4015 $preset_global = _wp_array_get( $slugs_global, $preset_metadata['path'], array() );
4016 $preset_node = _wp_array_get( $slugs_node, $preset_metadata['path'], array() );
4017 $preset_slugs = array_merge_recursive( $preset_global, $preset_node );
4018
4019 $content = static::filter_slugs( $content, $preset_slugs );
4020 }
4021
4022 _wp_array_set( $this->theme_json, $path, $content );
4023 }
4024 }
4025 }
4026
4027 /*
4028 * Style values are merged at the leaf level, however
4029 * some values provide exceptions, namely style values that are
4030 * objects and represent unique definitions for the style.
4031 */
4032 $style_nodes = static::get_block_nodes(
4033 $this->theme_json,
4034 array(),
4035 array( 'include_node_paths_only' => true )
4036 );
4037
4038 // Add top-level styles.
4039 $style_nodes[] = array( 'path' => array( 'styles' ) );
4040
4041 foreach ( $style_nodes as $style_node ) {
4042 $path = $style_node['path'];
4043 /*
4044 * Background image styles should be replaced, not merged,
4045 * as they themselves are specific object definitions for the style.
4046 */
4047 $background_image_path = array_merge( $path, static::PROPERTIES_METADATA['background-image'] );
4048 $content = _wp_array_get( $incoming_data, $background_image_path, null );
4049 if ( isset( $content ) ) {
4050 _wp_array_set( $this->theme_json, $background_image_path, $content );
4051 }
4052 }
4053 }
4054
4055 /**
4056 * Converts all filter (duotone) presets into SVGs.
4057 *
4058 * @since 5.9.1
4059 *
4060 * @param array $origins List of origins to process.
4061 * @return string SVG filters.
4062 */
4063 public function get_svg_filters( $origins ) {
4064 $blocks_metadata = static::get_blocks_metadata();
4065 $setting_nodes = static::get_setting_nodes( $this->theme_json, $blocks_metadata );
4066
4067 $filters = '';
4068 foreach ( $setting_nodes as $metadata ) {
4069 $node = _wp_array_get( $this->theme_json, $metadata['path'], array() );
4070 if ( empty( $node['color']['duotone'] ) ) {
4071 continue;
4072 }
4073
4074 $duotone_presets = $node['color']['duotone'];
4075
4076 foreach ( $origins as $origin ) {
4077 if ( ! isset( $duotone_presets[ $origin ] ) ) {
4078 continue;
4079 }
4080 foreach ( $duotone_presets[ $origin ] as $duotone_preset ) {
4081 $filters .= wp_get_duotone_filter_svg( $duotone_preset );
4082 }
4083 }
4084 }
4085
4086 return $filters;
4087 }
4088
4089 /**
4090 * Determines whether a presets should be overridden or not.
4091 *
4092 * @since 5.9.0
4093 * @deprecated 6.0.0 Use {@see 'get_metadata_boolean'} instead.
4094 *
4095 * @param array $theme_json The theme.json like structure to inspect.
4096 * @param array $path Path to inspect.
4097 * @param bool|array $override Data to compute whether to override the preset.
4098 * @return boolean
4099 */
4100 protected static function should_override_preset( $theme_json, $path, $override ) {
4101 _deprecated_function( __METHOD__, '6.0.0', 'get_metadata_boolean' );
4102
4103 if ( is_bool( $override ) ) {
4104 return $override;
4105 }
4106
4107 /*
4108 * The relationship between whether to override the defaults
4109 * and whether the defaults are enabled is inverse:
4110 *
4111 * - If defaults are enabled => theme presets should not be overridden
4112 * - If defaults are disabled => theme presets should be overridden
4113 *
4114 * For example, a theme sets defaultPalette to false,
4115 * making the default palette hidden from the user.
4116 * In that case, we want all the theme presets to be present,
4117 * so they should override the defaults.
4118 */
4119 if ( is_array( $override ) ) {
4120 $value = _wp_array_get( $theme_json, array_merge( $path, $override ) );
4121 if ( isset( $value ) ) {
4122 return ! $value;
4123 }
4124
4125 // Search the top-level key if none was found for this node.
4126 $value = _wp_array_get( $theme_json, array_merge( array( 'settings' ), $override ) );
4127 if ( isset( $value ) ) {
4128 return ! $value;
4129 }
4130
4131 return true;
4132 }
4133
4134 return false;
4135 }
4136
4137 /**
4138 * Returns the default slugs for all the presets in an associative array
4139 * whose keys are the preset paths and the leaves is the list of slugs.
4140 *
4141 * For example:
4142 *
4143 * array(
4144 * 'color' => array(
4145 * 'palette' => array( 'slug-1', 'slug-2' ),
4146 * 'gradients' => array( 'slug-3', 'slug-4' ),
4147 * ),
4148 * )
4149 *
4150 * @since 5.9.0
4151 *
4152 * @param array $data A theme.json like structure.
4153 * @param array $node_path The path to inspect. It's 'settings' by default.
4154 * @return array
4155 */
4156 protected static function get_default_slugs( $data, $node_path ) {
4157 $slugs = array();
4158
4159 foreach ( static::PRESETS_METADATA as $metadata ) {
4160 $path = $node_path;
4161 foreach ( $metadata['path'] as $leaf ) {
4162 $path[] = $leaf;
4163 }
4164 $path[] = 'default';
4165
4166 $preset = _wp_array_get( $data, $path, null );
4167 if ( ! isset( $preset ) ) {
4168 continue;
4169 }
4170
4171 $slugs_for_preset = array();
4172 foreach ( $preset as $item ) {
4173 if ( isset( $item['slug'] ) ) {
4174 $slugs_for_preset[] = $item['slug'];
4175 }
4176 }
4177
4178 _wp_array_set( $slugs, $metadata['path'], $slugs_for_preset );
4179 }
4180
4181 return $slugs;
4182 }
4183
4184 /**
4185 * Gets a `default`'s preset name by a provided slug.
4186 *
4187 * @since 5.9.0
4188 *
4189 * @param string $slug The slug we want to find a match from default presets.
4190 * @param array $base_path The path to inspect. It's 'settings' by default.
4191 * @return string|null
4192 */
4193 protected function get_name_from_defaults( $slug, $base_path ) {
4194 $path = $base_path;
4195 $path[] = 'default';
4196 $default_content = _wp_array_get( $this->theme_json, $path, null );
4197 if ( ! $default_content ) {
4198 return null;
4199 }
4200 foreach ( $default_content as $item ) {
4201 if ( $slug === $item['slug'] ) {
4202 return $item['name'];
4203 }
4204 }
4205 return null;
4206 }
4207
4208 /**
4209 * Removes the preset values whose slug is equal to any of given slugs.
4210 *
4211 * @since 5.9.0
4212 *
4213 * @param array $node The node with the presets to validate.
4214 * @param array $slugs The slugs that should not be overridden.
4215 * @return array The new node.
4216 */
4217 protected static function filter_slugs( $node, $slugs ) {
4218 if ( empty( $slugs ) ) {
4219 return $node;
4220 }
4221
4222 $new_node = array();
4223 foreach ( $node as $value ) {
4224 if ( isset( $value['slug'] ) && ! in_array( $value['slug'], $slugs, true ) ) {
4225 $new_node[] = $value;
4226 }
4227 }
4228
4229 return $new_node;
4230 }
4231
4232 /**
4233 * Removes insecure data from theme.json.
4234 *
4235 * @since 5.9.0
4236 * @since 6.6.0 Added support for block style variation element styles and $origin parameter.
4237 *
4238 * @param array $theme_json Structure to sanitize.
4239 * @param string $origin Optional. What source of data this object represents.
4240 * One of 'blocks', 'default', 'theme', or 'custom'. Default 'theme'.
4241 * @return array Sanitized structure.
4242 */
4243 public static function remove_insecure_properties( $theme_json, $origin = 'theme' ) {
4244 if ( ! in_array( $origin, static::VALID_ORIGINS, true ) ) {
4245 $origin = 'theme';
4246 }
4247
4248 $sanitized = array();
4249
4250 $theme_json = WP_Theme_JSON_Schema_Gutenberg::migrate( $theme_json, $origin );
4251
4252 $blocks_metadata = static::get_blocks_metadata();
4253 $valid_block_names = array_keys( $blocks_metadata );
4254 $valid_element_names = array_keys( static::ELEMENTS );
4255 $valid_variations = static::get_valid_block_style_variations( $blocks_metadata );
4256
4257 $theme_json = static::sanitize( $theme_json, $valid_block_names, $valid_element_names, $valid_variations );
4258
4259 $blocks_metadata = static::get_blocks_metadata();
4260 $style_options = array( 'include_block_style_variations' => true ); // Allow variations data.
4261 $style_nodes = static::get_style_nodes( $theme_json, $blocks_metadata, $style_options );
4262
4263 foreach ( $style_nodes as $metadata ) {
4264 $input = _wp_array_get( $theme_json, $metadata['path'], array() );
4265 if ( empty( $input ) ) {
4266 continue;
4267 }
4268
4269 $block_name = in_array( 'blocks', $metadata['path'], true )
4270 ? static::get_block_name_from_metadata_path( $metadata )
4271 : null;
4272
4273 // The global styles custom CSS is not sanitized, but can only be edited by users with 'edit_css' capability.
4274 if ( isset( $input['css'] ) && current_user_can( 'edit_css' ) ) {
4275 $output = $input;
4276 } else {
4277 $output = static::remove_insecure_styles( $input );
4278 }
4279
4280 /*
4281 * Get a reference to element name from path.
4282 * $metadata['path'] = array( 'styles', 'elements', 'link' );
4283 */
4284 $current_element = $metadata['path'][ count( $metadata['path'] ) - 1 ];
4285
4286 /*
4287 * $output is stripped of pseudo selectors. Re-add and process them
4288 * or insecure styles here.
4289 */
4290 if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] ) ) {
4291 foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] as $pseudo_selector ) {
4292 if ( isset( $input[ $pseudo_selector ] ) ) {
4293 $output[ $pseudo_selector ] = static::remove_insecure_styles( $input[ $pseudo_selector ] );
4294 }
4295 }
4296 }
4297
4298 // Re-add and process responsive breakpoint styles.
4299 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
4300 if ( isset( $input[ $breakpoint ] ) ) {
4301 $output[ $breakpoint ] = static::remove_insecure_styles( $input[ $breakpoint ] );
4302
4303 if ( isset( $input[ $breakpoint ]['elements'] ) ) {
4304 $output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $input[ $breakpoint ]['elements'] );
4305 }
4306
4307 if ( isset( $input[ $breakpoint ]['blocks'] ) ) {
4308 $output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $input[ $breakpoint ]['blocks'] );
4309 }
4310
4311 if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) {
4312 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) {
4313 if ( isset( $input[ $breakpoint ][ $pseudo_selector ] ) ) {
4314 $output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $input[ $breakpoint ][ $pseudo_selector ] );
4315 }
4316 }
4317 }
4318
4319 // Responsive custom CSS is allowed for users with 'edit_css' capability.
4320 if ( isset( $input[ $breakpoint ]['css'] ) && current_user_can( 'edit_css' ) ) {
4321 $output[ $breakpoint ]['css'] = $input[ $breakpoint ]['css'];
4322 }
4323 }
4324 }
4325
4326 if ( ! empty( $output ) ) {
4327 _wp_array_set( $sanitized, $metadata['path'], $output );
4328 }
4329
4330 if ( isset( $metadata['variations'] ) ) {
4331 foreach ( $metadata['variations'] as $variation ) {
4332 $variation_input = _wp_array_get( $theme_json, $variation['path'], array() );
4333 if ( empty( $variation_input ) ) {
4334 continue;
4335 }
4336
4337 $variation_output = static::remove_insecure_styles( $variation_input );
4338
4339 if ( isset( $variation_input['blocks'] ) ) {
4340 $variation_output['blocks'] = static::remove_insecure_inner_block_styles( $variation_input['blocks'] );
4341 }
4342
4343 if ( isset( $variation_input['elements'] ) ) {
4344 $variation_output['elements'] = static::remove_insecure_element_styles( $variation_input['elements'] );
4345 }
4346
4347 // Re-add and process responsive breakpoint styles for variations.
4348 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
4349 if ( isset( $variation_input[ $breakpoint ] ) ) {
4350 $variation_output[ $breakpoint ] = static::remove_insecure_styles( $variation_input[ $breakpoint ] );
4351
4352 if ( isset( $variation_input[ $breakpoint ]['elements'] ) ) {
4353 $variation_output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $variation_input[ $breakpoint ]['elements'] );
4354 }
4355
4356 if ( isset( $variation_input[ $breakpoint ]['blocks'] ) ) {
4357 $variation_output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $variation_input[ $breakpoint ]['blocks'] );
4358 }
4359
4360 if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) {
4361 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) {
4362 if ( isset( $variation_input[ $breakpoint ][ $pseudo_selector ] ) ) {
4363 $variation_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $variation_input[ $breakpoint ][ $pseudo_selector ] );
4364 }
4365 }
4366 }
4367
4368 // Responsive custom CSS is allowed for users with 'edit_css' capability.
4369 if ( isset( $variation_input[ $breakpoint ]['css'] ) && current_user_can( 'edit_css' ) ) {
4370 $variation_output[ $breakpoint ]['css'] = $variation_input[ $breakpoint ]['css'];
4371 }
4372 }
4373 }
4374
4375 if ( ! empty( $variation_output ) ) {
4376 _wp_array_set( $sanitized, $variation['path'], $variation_output );
4377 }
4378 }
4379 }
4380 }
4381
4382 $setting_nodes = static::get_setting_nodes( $theme_json );
4383 foreach ( $setting_nodes as $metadata ) {
4384 $input = _wp_array_get( $theme_json, $metadata['path'], array() );
4385 if ( empty( $input ) ) {
4386 continue;
4387 }
4388
4389 $output = static::remove_insecure_settings( $input );
4390 if ( ! empty( $output ) ) {
4391 _wp_array_set( $sanitized, $metadata['path'], $output );
4392 }
4393 }
4394
4395 if ( empty( $sanitized['styles'] ) ) {
4396 unset( $theme_json['styles'] );
4397 } else {
4398 $theme_json['styles'] = $sanitized['styles'];
4399 }
4400
4401 if ( empty( $sanitized['settings'] ) ) {
4402 unset( $theme_json['settings'] );
4403 } else {
4404 $theme_json['settings'] = $sanitized['settings'];
4405 }
4406
4407 return $theme_json;
4408 }
4409
4410 /**
4411 * Remove insecure element styles within a variation or block.
4412 *
4413 * @since 6.8.0
4414 *
4415 * @param array $elements The elements to process.
4416 * @return array The sanitized elements styles.
4417 */
4418 protected static function remove_insecure_element_styles( $elements ) {
4419 $sanitized = array();
4420 $valid_element_names = array_keys( static::ELEMENTS );
4421
4422 foreach ( $valid_element_names as $element_name ) {
4423 $element_input = $elements[ $element_name ] ?? null;
4424 if ( $element_input ) {
4425 $element_output = static::remove_insecure_styles( $element_input );
4426
4427 if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) {
4428 foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) {
4429 if ( isset( $element_input[ $pseudo_selector ] ) ) {
4430 $element_output[ $pseudo_selector ] = static::remove_insecure_styles( $element_input[ $pseudo_selector ] );
4431 }
4432 }
4433 }
4434
4435 // Re-add and process responsive breakpoint styles for elements.
4436 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
4437 if ( isset( $element_input[ $breakpoint ] ) ) {
4438 $element_output[ $breakpoint ] = static::remove_insecure_styles( $element_input[ $breakpoint ] );
4439
4440 if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) {
4441 foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) {
4442 if ( isset( $element_input[ $breakpoint ][ $pseudo_selector ] ) ) {
4443 $element_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $element_input[ $breakpoint ][ $pseudo_selector ] );
4444 }
4445 }
4446 }
4447 }
4448 }
4449
4450 $sanitized[ $element_name ] = $element_output;
4451 }
4452 }
4453 return $sanitized;
4454 }
4455
4456 /**
4457 * Remove insecure styles from inner blocks and their elements.
4458 *
4459 * @since 6.8.0
4460 *
4461 * @param array $blocks The block styles to process.
4462 * @return array Sanitized block type styles.
4463 */
4464 protected static function remove_insecure_inner_block_styles( $blocks ) {
4465 $sanitized = array();
4466 foreach ( $blocks as $block_type => $block_input ) {
4467 $block_output = static::remove_insecure_styles( $block_input );
4468
4469 if ( isset( $block_input['elements'] ) ) {
4470 $block_output['elements'] = static::remove_insecure_element_styles( $block_input['elements'] );
4471 }
4472
4473 // Re-add and process responsive breakpoint styles for inner blocks.
4474 foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
4475 if ( isset( $block_input[ $breakpoint ] ) ) {
4476 $block_output[ $breakpoint ] = static::remove_insecure_styles( $block_input[ $breakpoint ] );
4477
4478 if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] ) ) {
4479 foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] as $pseudo_selector ) {
4480 if ( isset( $block_input[ $breakpoint ][ $pseudo_selector ] ) ) {
4481 $block_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $block_input[ $breakpoint ][ $pseudo_selector ] );
4482 }
4483 }
4484 }
4485 }
4486 }
4487
4488 $sanitized[ $block_type ] = $block_output;
4489 }
4490 return $sanitized;
4491 }
4492
4493 /**
4494 * Preserves valid typed settings from input to output based on type markers in schema.
4495 *
4496 * Recursively iterates through the schema and validates/preserves settings
4497 * that have type markers (e.g., boolean) in VALID_SETTINGS.
4498 *
4499 * @since 7.0.0
4500 *
4501 * @param array $input Input settings to process.
4502 * @param array $output Output settings array (passed by reference).
4503 * @param array $schema Schema to validate against (typically VALID_SETTINGS).
4504 * @param array<string|int> $path Current path in the schema (for recursive calls).
4505 */
4506 private static function preserve_valid_typed_settings( $input, &$output, $schema, $path = array() ) {
4507 foreach ( $schema as $key => $schema_value ) {
4508 $current_path = array_merge( $path, array( $key ) );
4509
4510 // Validate boolean type markers.
4511 if ( is_bool( $schema_value ) ) {
4512 $value = _wp_array_get( $input, $current_path, null );
4513 if ( null !== $value && is_bool( $value ) ) {
4514 _wp_array_set( $output, $current_path, $value ); // Preserve boolean value.
4515 }
4516 } elseif ( is_array( $schema_value ) ) {
4517 self::preserve_valid_typed_settings( $input, $output, $schema_value, $current_path ); // Recurse into nested structure.
4518 }
4519 }
4520 }
4521
4522 /**
4523 * Processes a setting node and returns the same node
4524 * without the insecure settings.
4525 *
4526 * @since 5.9.0
4527 *
4528 * @param array $input Node to process.
4529 * @return array
4530 */
4531 protected static function remove_insecure_settings( $input ) {
4532 $output = array();
4533 foreach ( static::PRESETS_METADATA as $preset_metadata ) {
4534 foreach ( static::VALID_ORIGINS as $origin ) {
4535 $path_with_origin = $preset_metadata['path'];
4536 $path_with_origin[] = $origin;
4537 $presets = _wp_array_get( $input, $path_with_origin, null );
4538 if ( null === $presets ) {
4539 continue;
4540 }
4541
4542 $escaped_preset = array();
4543 foreach ( $presets as $preset ) {
4544 if (
4545 esc_attr( esc_html( $preset['name'] ) ) === $preset['name'] &&
4546 sanitize_html_class( $preset['slug'] ) === $preset['slug']
4547 ) {
4548 $value = null;
4549 if ( isset( $preset_metadata['value_key'], $preset[ $preset_metadata['value_key'] ] ) ) {
4550 $value = $preset[ $preset_metadata['value_key'] ];
4551 } elseif (
4552 isset( $preset_metadata['value_func'] ) &&
4553 is_callable( $preset_metadata['value_func'] )
4554 ) {
4555 $value = call_user_func( $preset_metadata['value_func'], $preset );
4556 }
4557
4558 $preset_is_valid = true;
4559 foreach ( $preset_metadata['properties'] as $property ) {
4560 if ( ! static::is_safe_css_declaration( $property, $value ) ) {
4561 $preset_is_valid = false;
4562 break;
4563 }
4564 }
4565
4566 if ( $preset_is_valid ) {
4567 $escaped_preset[] = $preset;
4568 }
4569 }
4570 }
4571
4572 if ( ! empty( $escaped_preset ) ) {
4573 _wp_array_set( $output, $path_with_origin, $escaped_preset );
4574 }
4575 }
4576 }
4577
4578 // Ensure indirect properties not included in any `PRESETS_METADATA` value are allowed.
4579 static::remove_indirect_properties( $input, $output );
4580
4581 // Preserve all valid settings that have type markers in VALID_SETTINGS.
4582 self::preserve_valid_typed_settings( $input, $output, static::VALID_SETTINGS );
4583
4584 return $output;
4585 }
4586
4587 /**
4588 * Processes a style node and returns the same node
4589 * without the insecure styles.
4590 *
4591 * @since 5.9.0
4592 * @since 6.2.0 Allow indirect properties used outside of `compute_style_properties`.
4593 *
4594 * @param array $input Node to process.
4595 * @return array
4596 */
4597 protected static function remove_insecure_styles( $input ) {
4598 $output = array();
4599 $declarations = static::compute_style_properties( $input );
4600
4601 foreach ( $declarations as $declaration ) {
4602 if ( static::is_safe_css_declaration( $declaration['name'], $declaration['value'] ) ) {
4603 $path = static::PROPERTIES_METADATA[ $declaration['name'] ];
4604
4605 // Check the value isn't an array before adding so as to not
4606 // double up shorthand and longhand styles.
4607 $value = _wp_array_get( $input, $path, array() );
4608 if ( ! is_array( $value ) ) {
4609 _wp_array_set( $output, $path, $value );
4610 }
4611 }
4612 }
4613
4614 // Ensure indirect properties not handled by `compute_style_properties` are allowed.
4615 static::remove_indirect_properties( $input, $output );
4616
4617 return $output;
4618 }
4619
4620 /**
4621 * Checks that a declaration provided by the user is safe.
4622 *
4623 * @since 5.9.0
4624 *
4625 * @param string $property_name Property name in a CSS declaration, i.e. the `color` in `color: red`.
4626 * @param string $property_value Value in a CSS declaration, i.e. the `red` in `color: red`.
4627 * @return bool
4628 */
4629 protected static function is_safe_css_declaration( $property_name, $property_value ) {
4630 $style_to_validate = $property_name . ': ' . $property_value;
4631 $filtered = esc_html( safecss_filter_attr( $style_to_validate ) );
4632 return ! empty( trim( $filtered ) );
4633 }
4634
4635 /**
4636 * Removes indirect properties from the given input node and
4637 * sets in the given output node.
4638 *
4639 * @since 6.2.0
4640 *
4641 * @param array $input Node to process.
4642 * @param array $output The processed node. Passed by reference.
4643 */
4644 private static function remove_indirect_properties( $input, &$output ) {
4645 foreach ( static::INDIRECT_PROPERTIES_METADATA as $property => $paths ) {
4646 foreach ( $paths as $path ) {
4647 $value = _wp_array_get( $input, $path );
4648 if (
4649 is_string( $value ) &&
4650 static::is_safe_css_declaration( $property, $value )
4651 ) {
4652 _wp_array_set( $output, $path, $value );
4653 }
4654 }
4655 }
4656 }
4657
4658 /**
4659 * Returns the raw data.
4660 *
4661 * @since 5.8.0
4662 *
4663 * @return array Raw data.
4664 */
4665 public function get_raw_data() {
4666 return $this->theme_json;
4667 }
4668
4669 /**
4670 * Transforms the given editor settings according the
4671 * add_theme_support format to the theme.json format.
4672 *
4673 * @since 5.8.0
4674 *
4675 * @param array $settings Existing editor settings.
4676 * @return array Config that adheres to the theme.json schema.
4677 */
4678 public static function get_from_editor_settings( $settings ) {
4679 $theme_settings = array(
4680 'version' => static::LATEST_SCHEMA,
4681 'settings' => array(),
4682 );
4683
4684 // Deprecated theme supports.
4685 if ( isset( $settings['disableCustomColors'] ) ) {
4686 $theme_settings['settings']['color']['custom'] = ! $settings['disableCustomColors'];
4687 }
4688
4689 if ( isset( $settings['disableCustomGradients'] ) ) {
4690 $theme_settings['settings']['color']['customGradient'] = ! $settings['disableCustomGradients'];
4691 }
4692
4693 if ( isset( $settings['disableCustomFontSizes'] ) ) {
4694 $theme_settings['settings']['typography']['customFontSize'] = ! $settings['disableCustomFontSizes'];
4695 }
4696
4697 if ( isset( $settings['enableCustomLineHeight'] ) ) {
4698 $theme_settings['settings']['typography']['lineHeight'] = $settings['enableCustomLineHeight'];
4699 }
4700
4701 if ( isset( $settings['enableCustomUnits'] ) ) {
4702 $theme_settings['settings']['spacing']['units'] = ( true === $settings['enableCustomUnits'] ) ?
4703 array( 'px', 'em', 'rem', 'vh', 'vw', '%' ) :
4704 $settings['enableCustomUnits'];
4705 }
4706
4707 if ( isset( $settings['colors'] ) ) {
4708 $theme_settings['settings']['color']['palette'] = $settings['colors'];
4709 }
4710
4711 if ( isset( $settings['gradients'] ) ) {
4712 $theme_settings['settings']['color']['gradients'] = $settings['gradients'];
4713 }
4714
4715 if ( isset( $settings['fontSizes'] ) ) {
4716 $font_sizes = $settings['fontSizes'];
4717 // Back-compatibility for presets without units.
4718 foreach ( $font_sizes as $key => $font_size ) {
4719 if ( is_numeric( $font_size['size'] ) ) {
4720 $font_sizes[ $key ]['size'] = $font_size['size'] . 'px';
4721 }
4722 }
4723 $theme_settings['settings']['typography']['fontSizes'] = $font_sizes;
4724 }
4725
4726 if ( isset( $settings['enableCustomSpacing'] ) ) {
4727 $theme_settings['settings']['spacing']['padding'] = $settings['enableCustomSpacing'];
4728 }
4729
4730 if ( isset( $settings['spacingSizes'] ) ) {
4731 $theme_settings['settings']['spacing']['spacingSizes'] = $settings['spacingSizes'];
4732 }
4733
4734 return $theme_settings;
4735 }
4736
4737 /**
4738 * Returns the current theme's wanted patterns(slugs) to be
4739 * registered from Pattern Directory.
4740 *
4741 * @since 6.0.0
4742 *
4743 * @return string[]
4744 */
4745 public function get_patterns() {
4746 if ( isset( $this->theme_json['patterns'] ) && is_array( $this->theme_json['patterns'] ) ) {
4747 return $this->theme_json['patterns'];
4748 }
4749 return array();
4750 }
4751
4752 /**
4753 * Returns a valid theme.json as provided by a theme.
4754 *
4755 * Unlike get_raw_data() this returns the presets flattened, as provided by a theme.
4756 * This also uses appearanceTools instead of their opt-ins if all of them are true.
4757 *
4758 * @since 6.0.0
4759 *
4760 * @return array
4761 */
4762 public function get_data() {
4763 $output = $this->theme_json;
4764 $nodes = static::get_setting_nodes( $output );
4765
4766 /**
4767 * Flatten the theme & custom origins into a single one.
4768 *
4769 * For example, the following:
4770 *
4771 * {
4772 * "settings": {
4773 * "color": {
4774 * "palette": {
4775 * "theme": [ {} ],
4776 * "custom": [ {} ]
4777 * }
4778 * }
4779 * }
4780 * }
4781 *
4782 * will be converted to:
4783 *
4784 * {
4785 * "settings": {
4786 * "color": {
4787 * "palette": [ {} ]
4788 * }
4789 * }
4790 * }
4791 */
4792 foreach ( $nodes as $node ) {
4793 foreach ( static::PRESETS_METADATA as $preset_metadata ) {
4794 $path = $node['path'];
4795 foreach ( $preset_metadata['path'] as $preset_metadata_path ) {
4796 $path[] = $preset_metadata_path;
4797 }
4798 $preset = _wp_array_get( $output, $path, null );
4799 if ( null === $preset ) {
4800 continue;
4801 }
4802
4803 $items = array();
4804 if ( isset( $preset['theme'] ) ) {
4805 foreach ( $preset['theme'] as $item ) {
4806 $slug = $item['slug'];
4807 unset( $item['slug'] );
4808 $items[ $slug ] = $item;
4809 }
4810 }
4811 if ( isset( $preset['custom'] ) ) {
4812 foreach ( $preset['custom'] as $item ) {
4813 $slug = $item['slug'];
4814 unset( $item['slug'] );
4815 $items[ $slug ] = $item;
4816 }
4817 }
4818 $flattened_preset = array();
4819 foreach ( $items as $slug => $value ) {
4820 $flattened_preset[] = array_merge( array( 'slug' => (string) $slug ), $value );
4821 }
4822 _wp_array_set( $output, $path, $flattened_preset );
4823 }
4824 }
4825
4826 // If all of the static::APPEARANCE_TOOLS_OPT_INS are true,
4827 // this code unsets them and sets 'appearanceTools' instead.
4828 foreach ( $nodes as $node ) {
4829 $all_opt_ins_are_set = true;
4830 foreach ( static::APPEARANCE_TOOLS_OPT_INS as $opt_in_path ) {
4831 $full_path = $node['path'];
4832 foreach ( $opt_in_path as $opt_in_path_item ) {
4833 $full_path[] = $opt_in_path_item;
4834 }
4835 // Use "unset prop" as a marker instead of "null" because
4836 // "null" can be a valid value for some props (e.g. blockGap).
4837 $opt_in_value = _wp_array_get( $output, $full_path, 'unset prop' );
4838 if ( 'unset prop' === $opt_in_value ) {
4839 $all_opt_ins_are_set = false;
4840 break;
4841 }
4842 }
4843
4844 if ( $all_opt_ins_are_set ) {
4845 $node_path_with_appearance_tools = $node['path'];
4846 $node_path_with_appearance_tools[] = 'appearanceTools';
4847 _wp_array_set( $output, $node_path_with_appearance_tools, true );
4848 foreach ( static::APPEARANCE_TOOLS_OPT_INS as $opt_in_path ) {
4849 $full_path = $node['path'];
4850 foreach ( $opt_in_path as $opt_in_path_item ) {
4851 $full_path[] = $opt_in_path_item;
4852 }
4853 // Use "unset prop" as a marker instead of "null" because
4854 // "null" can be a valid value for some props (e.g. blockGap).
4855 $opt_in_value = _wp_array_get( $output, $full_path, 'unset prop' );
4856 if ( true !== $opt_in_value ) {
4857 continue;
4858 }
4859
4860 // The following could be improved to be path independent.
4861 // At the moment it relies on a couple of assumptions:
4862 //
4863 // - all opt-ins having a path of size 2.
4864 // - there's two sources of settings: the top-level and the block-level.
4865 if (
4866 ( 1 === count( $node['path'] ) ) &&
4867 ( 'settings' === $node['path'][0] )
4868 ) {
4869 // Top-level settings.
4870 unset( $output['settings'][ $opt_in_path[0] ][ $opt_in_path[1] ] );
4871 if ( empty( $output['settings'][ $opt_in_path[0] ] ) ) {
4872 unset( $output['settings'][ $opt_in_path[0] ] );
4873 }
4874 } elseif (
4875 ( 3 === count( $node['path'] ) ) &&
4876 ( 'settings' === $node['path'][0] ) &&
4877 ( 'blocks' === $node['path'][1] )
4878 ) {
4879 // Block-level settings.
4880 $block_name = $node['path'][2];
4881 unset( $output['settings']['blocks'][ $block_name ][ $opt_in_path[0] ][ $opt_in_path[1] ] );
4882 if ( empty( $output['settings']['blocks'][ $block_name ][ $opt_in_path[0] ] ) ) {
4883 unset( $output['settings']['blocks'][ $block_name ][ $opt_in_path[0] ] );
4884 }
4885 }
4886 }
4887 }
4888 }
4889
4890 wp_recursive_ksort( $output );
4891
4892 return $output;
4893 }
4894
4895 /**
4896 * Sets the spacingSizes array based on the spacingScale values from theme.json.
4897 *
4898 * @since 6.1.0
4899 * @deprecated 6.6.0 No longer used as the spacingSizes are automatically
4900 * generated in the constructor and merge methods instead
4901 * of manually after instantiation.
4902 *
4903 * @return null|void
4904 */
4905 public function set_spacing_sizes() {
4906 _deprecated_function( __METHOD__, '6.6.0' );
4907
4908 $spacing_scale = $this->theme_json['settings']['spacing']['spacingScale']['default'] ?? array();
4909
4910 // Gutenberg didn't have the 1st isset check.
4911 if ( ! isset( $spacing_scale['steps'] )
4912 || ! is_numeric( $spacing_scale['steps'] )
4913 || ! isset( $spacing_scale['mediumStep'] )
4914 || ! isset( $spacing_scale['unit'] )
4915 || ! isset( $spacing_scale['operator'] )
4916 || ! isset( $spacing_scale['increment'] )
4917 || ! isset( $spacing_scale['steps'] )
4918 || ! is_numeric( $spacing_scale['increment'] )
4919 || ! is_numeric( $spacing_scale['mediumStep'] )
4920 || ( '+' !== $spacing_scale['operator'] && '*' !== $spacing_scale['operator'] ) ) {
4921 if ( ! empty( $spacing_scale ) ) {
4922 trigger_error( __( 'Some of the theme.json settings.spacing.spacingScale values are invalid', 'gutenberg' ), E_USER_NOTICE );
4923 }
4924 return null;
4925 }
4926
4927 // If theme authors want to prevent the generation of the core spacing scale they can set their theme.json spacingScale.steps to 0.
4928 if ( 0 === $spacing_scale['steps'] ) {
4929 return null;
4930 }
4931
4932 $spacing_sizes = static::compute_spacing_sizes( $spacing_scale );
4933
4934 // If there are 7 or less steps in the scale revert to numbers for labels instead of t-shirt sizes.
4935 if ( $spacing_scale['steps'] <= 7 ) {
4936 for ( $spacing_sizes_count = 0; $spacing_sizes_count < count( $spacing_sizes ); $spacing_sizes_count++ ) {
4937 $spacing_sizes[ $spacing_sizes_count ]['name'] = (string) ( $spacing_sizes_count + 1 );
4938 }
4939 }
4940
4941 _wp_array_set( $this->theme_json, array( 'settings', 'spacing', 'spacingSizes', 'default' ), $spacing_sizes );
4942 }
4943
4944 /**
4945 * Merges two sets of spacing size presets.
4946 *
4947 * @since 6.6.0
4948 *
4949 * @param array $base The base set of spacing sizes.
4950 * @param array $incoming The set of spacing sizes to merge with the base. Duplicate slugs will override the base values.
4951 * @return array The merged set of spacing sizes.
4952 */
4953 private static function merge_spacing_sizes( $base, $incoming ) {
4954 // Preserve the order if there are no base (spacingScale) values.
4955 if ( empty( $base ) ) {
4956 return $incoming;
4957 }
4958 $merged = array();
4959 foreach ( $base as $item ) {
4960 $merged[ $item['slug'] ] = $item;
4961 }
4962 foreach ( $incoming as $item ) {
4963 $merged[ $item['slug'] ] = $item;
4964 }
4965 ksort( $merged, SORT_NUMERIC );
4966 return array_values( $merged );
4967 }
4968
4969 /**
4970 * Generates a set of spacing sizes by starting with a medium size and
4971 * applying an operator with an increment value to generate the rest of the
4972 * sizes outward from the medium size. The medium slug is '50' with the rest
4973 * of the slugs being 10 apart. The generated names use t-shirt sizing.
4974 *
4975 * Example:
4976 *
4977 * $spacing_scale = array(
4978 * 'steps' => 4,
4979 * 'mediumStep' => 16,
4980 * 'unit' => 'px',
4981 * 'operator' => '+',
4982 * 'increment' => 2,
4983 * );
4984 * $spacing_sizes = static::compute_spacing_sizes( $spacing_scale );
4985 * // -> array(
4986 * // array( 'name' => 'Small', 'slug' => '40', 'size' => '14px' ),
4987 * // array( 'name' => 'Medium', 'slug' => '50', 'size' => '16px' ),
4988 * // array( 'name' => 'Large', 'slug' => '60', 'size' => '18px' ),
4989 * // array( 'name' => 'X-Large', 'slug' => '70', 'size' => '20px' ),
4990 * // )
4991 *
4992 * @since 6.6.0
4993 *
4994 * @param array $spacing_scale {
4995 * The spacing scale values. All are required.
4996 *
4997 * @type int $steps The number of steps in the scale. (up to 10 steps are supported.)
4998 * @type float $mediumStep The middle value that gets the slug '50'. (For even number of steps, this becomes the first middle value.)
4999 * @type string $unit The CSS unit to use for the sizes.
5000 * @type string $operator The mathematical operator to apply to generate the other sizes. Either '+' or '*'.
5001 * @type float $increment The value used with the operator to generate the other sizes.
5002 * }
5003 * @return array The spacing sizes presets or an empty array if some spacing scale values are missing or invalid.
5004 */
5005 private static function compute_spacing_sizes( $spacing_scale ) {
5006 /*
5007 * This condition is intentionally missing some checks on ranges for the values in order to
5008 * keep backwards compatibility with the previous implementation.
5009 */
5010 if (
5011 ! isset( $spacing_scale['steps'] ) ||
5012 ! is_numeric( $spacing_scale['steps'] ) ||
5013 0 === $spacing_scale['steps'] ||
5014 ! isset( $spacing_scale['mediumStep'] ) ||
5015 ! is_numeric( $spacing_scale['mediumStep'] ) ||
5016 ! isset( $spacing_scale['unit'] ) ||
5017 ! isset( $spacing_scale['operator'] ) ||
5018 ( '+' !== $spacing_scale['operator'] && '*' !== $spacing_scale['operator'] ) ||
5019 ! isset( $spacing_scale['increment'] ) ||
5020 ! is_numeric( $spacing_scale['increment'] )
5021 ) {
5022 return array();
5023 }
5024
5025 $unit = '%' === $spacing_scale['unit'] ? '%' : sanitize_title( $spacing_scale['unit'] );
5026 $current_step = $spacing_scale['mediumStep'];
5027 $steps_mid_point = round( $spacing_scale['steps'] / 2, 0 );
5028 $x_small_count = null;
5029 $below_sizes = array();
5030 $slug = 40;
5031 $remainder = 0;
5032
5033 for ( $below_midpoint_count = $steps_mid_point - 1; $spacing_scale['steps'] > 1 && $slug > 0 && $below_midpoint_count > 0; $below_midpoint_count-- ) {
5034 if ( '+' === $spacing_scale['operator'] ) {
5035 $current_step -= $spacing_scale['increment'];
5036 } elseif ( $spacing_scale['increment'] > 1 ) {
5037 $current_step /= $spacing_scale['increment'];
5038 } else {
5039 $current_step *= $spacing_scale['increment'];
5040 }
5041
5042 if ( $current_step <= 0 ) {
5043 $remainder = $below_midpoint_count;
5044 break;
5045 }
5046
5047 $below_sizes[] = array(
5048 /* translators: %s: Digit to indicate multiple of sizing, eg. 2X-Small. */
5049 'name' => $below_midpoint_count === $steps_mid_point - 1 ? __( 'Small', 'gutenberg' ) : sprintf( __( '%sX-Small', 'gutenberg' ), (string) $x_small_count ),
5050 'slug' => (string) $slug,
5051 'size' => round( $current_step, 2 ) . $unit,
5052 );
5053
5054 if ( $below_midpoint_count === $steps_mid_point - 2 ) {
5055 $x_small_count = 2;
5056 }
5057
5058 if ( $below_midpoint_count < $steps_mid_point - 2 ) {
5059 ++$x_small_count;
5060 }
5061
5062 $slug -= 10;
5063 }
5064
5065 $below_sizes = array_reverse( $below_sizes );
5066
5067 $below_sizes[] = array(
5068 'name' => __( 'Medium', 'gutenberg' ),
5069 'slug' => '50',
5070 'size' => $spacing_scale['mediumStep'] . $unit,
5071 );
5072
5073 $current_step = $spacing_scale['mediumStep'];
5074 $x_large_count = null;
5075 $above_sizes = array();
5076 $slug = 60;
5077 $steps_above = ( $spacing_scale['steps'] - $steps_mid_point ) + $remainder;
5078
5079 for ( $above_midpoint_count = 0; $above_midpoint_count < $steps_above; $above_midpoint_count++ ) {
5080 $current_step = '+' === $spacing_scale['operator']
5081 ? $current_step + $spacing_scale['increment']
5082 : ( $spacing_scale['increment'] >= 1 ? $current_step * $spacing_scale['increment'] : $current_step / $spacing_scale['increment'] );
5083
5084 $above_sizes[] = array(
5085 /* translators: %s: Digit to indicate multiple of sizing, eg. 2X-Large. */
5086 'name' => 0 === $above_midpoint_count ? __( 'Large', 'gutenberg' ) : sprintf( __( '%sX-Large', 'gutenberg' ), (string) $x_large_count ),
5087 'slug' => (string) $slug,
5088 'size' => round( $current_step, 2 ) . $unit,
5089 );
5090
5091 if ( 1 === $above_midpoint_count ) {
5092 $x_large_count = 2;
5093 }
5094
5095 if ( $above_midpoint_count > 1 ) {
5096 ++$x_large_count;
5097 }
5098
5099 $slug += 10;
5100 }
5101
5102 $spacing_sizes = $below_sizes;
5103 foreach ( $above_sizes as $above_sizes_item ) {
5104 $spacing_sizes[] = $above_sizes_item;
5105 }
5106
5107 return $spacing_sizes;
5108 }
5109
5110 /**
5111 * Returns the selectors metadata for a block.
5112 *
5113 * @param object $block_type The block type.
5114 * @param string $root_selector The block's root selector.
5115 *
5116 * @return object The custom selectors set by the block.
5117 */
5118 protected static function get_block_selectors( $block_type, $root_selector ) {
5119 if ( ! empty( $block_type->selectors ) ) {
5120 return $block_type->selectors;
5121 }
5122
5123 $selectors = array( 'root' => $root_selector );
5124 foreach ( static::BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS as $key => $feature ) {
5125 $feature_selector = wp_get_block_css_selector( $block_type, $key );
5126 if ( null !== $feature_selector ) {
5127 $selectors[ $feature ] = array( 'root' => $feature_selector );
5128 }
5129 }
5130
5131 return $selectors;
5132 }
5133
5134 /**
5135 * Generates all the element selectors for a block.
5136 *
5137 * @param string $root_selector The block's root CSS selector.
5138 * @return array The block's element selectors.
5139 */
5140 protected static function get_block_element_selectors( $root_selector ) {
5141 // Assign defaults, then override those that the block sets by itself.
5142 // If the block selector is compounded, will append the element to each
5143 // individual block selector.
5144 $block_selectors = explode( ',', $root_selector );
5145 $element_selectors = array();
5146
5147 foreach ( static::ELEMENTS as $el_name => $el_selector ) {
5148 $element_selector = array();
5149 foreach ( $block_selectors as $selector ) {
5150 if ( $selector === $el_selector ) {
5151 $element_selector = array( $el_selector );
5152 break;
5153 }
5154 $element_selector[] = static::prepend_to_selector( $el_selector, $selector . ' ' );
5155 }
5156 $element_selectors[ $el_name ] = implode( ',', $element_selector );
5157 }
5158
5159 return $element_selectors;
5160 }
5161
5162 /**
5163 * Generates style declarations for a node's features e.g. color, border,
5164 * typography etc, that have custom selectors in their related block's
5165 * metadata.
5166 *
5167 * @param object $metadata The related block metadata containing selectors.
5168 * @param object $node A merged theme.json node for block or variation.
5169 *
5170 * @return array The style declarations for the node's features with custom
5171 * selectors.
5172 */
5173 protected function get_feature_declarations_for_node( $metadata, &$node ) {
5174 $declarations = array();
5175
5176 if ( ! isset( $metadata['selectors'] ) ) {
5177 return $declarations;
5178 }
5179
5180 $settings = $this->theme_json['settings'] ?? null;
5181
5182 foreach ( $metadata['selectors'] as $feature => $feature_selectors ) {
5183 // Skip if this is the block's root selector, the custom CSS
5184 // selector, or the block doesn't have any styles for the feature.
5185 if ( 'root' === $feature || 'css' === $feature || empty( $node[ $feature ] ) ) {
5186 continue;
5187 }
5188
5189 if ( is_array( $feature_selectors ) ) {
5190 foreach ( $feature_selectors as $subfeature => $subfeature_selector ) {
5191 if ( 'root' === $subfeature || empty( $node[ $feature ][ $subfeature ] ) ) {
5192 continue;
5193 }
5194
5195 // Create temporary node containing only the subfeature data
5196 // to leverage existing `compute_style_properties` function.
5197 $subfeature_node = array(
5198 $feature => array(
5199 $subfeature => $node[ $feature ][ $subfeature ],
5200 ),
5201 );
5202
5203 // Generate style declarations.
5204 $new_declarations = static::compute_style_properties( $subfeature_node, $settings, null, $this->theme_json );
5205
5206 // Merge subfeature declarations into feature declarations.
5207 if ( isset( $declarations[ $subfeature_selector ] ) ) {
5208 foreach ( $new_declarations as $new_declaration ) {
5209 $declarations[ $subfeature_selector ][] = $new_declaration;
5210 }
5211 } else {
5212 $declarations[ $subfeature_selector ] = $new_declarations;
5213 }
5214
5215 // Remove the subfeature from the block's node now its
5216 // styles will be included under its own selector not the
5217 // block's.
5218 unset( $node[ $feature ][ $subfeature ] );
5219 }
5220 }
5221
5222 // Now subfeatures have been processed and removed we can process
5223 // feature root selector or simple string selector.
5224 if (
5225 is_string( $feature_selectors ) ||
5226 ( isset( $feature_selectors['root'] ) && $feature_selectors['root'] )
5227 ) {
5228 $feature_selector = is_string( $feature_selectors ) ? $feature_selectors : $feature_selectors['root'];
5229
5230 // Create temporary node containing only the feature data
5231 // to leverage existing `compute_style_properties` function.
5232 $feature_node = array( $feature => $node[ $feature ] );
5233
5234 // Generate the style declarations.
5235 $new_declarations = static::compute_style_properties( $feature_node, $settings, null, $this->theme_json );
5236
5237 // Merge new declarations with any that already exist for
5238 // the feature selector. This may occur when multiple block
5239 // support features use the same custom selector.
5240 if ( isset( $declarations[ $feature_selector ] ) ) {
5241 foreach ( $new_declarations as $new_declaration ) {
5242 $declarations[ $feature_selector ][] = $new_declaration;
5243 }
5244 } else {
5245 $declarations[ $feature_selector ] = $new_declarations;
5246 }
5247
5248 // Remove the feature from the block's node now its styles
5249 // will be included under its own selector not the block's.
5250 unset( $node[ $feature ] );
5251 }
5252 }
5253
5254 return $declarations;
5255 }
5256
5257 /**
5258 * This is used to convert the internal representation of variables to the CSS representation.
5259 * For example, `var:preset|color|vivid-green-cyan` becomes `var(--wp--preset--color--vivid-green-cyan)`.
5260 *
5261 * @since 6.3.0
5262 * @param string $value The variable such as var:preset|color|vivid-green-cyan to convert.
5263 * @return string The converted variable.
5264 */
5265 private static function convert_custom_properties( $value ) {
5266 $prefix = 'var:';
5267 $prefix_len = strlen( $prefix );
5268 $token_in = '|';
5269 $token_out = '--';
5270 if ( 0 === strpos( $value, $prefix ) ) {
5271 $unwrapped_name = str_replace(
5272 $token_in,
5273 $token_out,
5274 substr( $value, $prefix_len )
5275 );
5276 $value = "var(--wp--$unwrapped_name)";
5277 }
5278
5279 return $value;
5280 }
5281
5282 /**
5283 * Given a tree, converts the internal representation of variables to the CSS representation.
5284 * It is recursive and modifies the input in-place.
5285 *
5286 * @since 6.3.0
5287 * @param array $tree Input to process.
5288 * @return array The modified $tree.
5289 */
5290 private static function resolve_custom_css_format( $tree ) {
5291 $prefix = 'var:';
5292
5293 foreach ( $tree as $key => $data ) {
5294 if ( is_string( $data ) && 0 === strpos( $data, $prefix ) ) {
5295 $tree[ $key ] = self::convert_custom_properties( $data );
5296 } elseif ( is_array( $data ) ) {
5297 $tree[ $key ] = self::resolve_custom_css_format( $data );
5298 }
5299 }
5300
5301 return $tree;
5302 }
5303
5304 /**
5305 * Replaces CSS variables with their values in place.
5306 *
5307 * @since 6.3.0
5308 * @since 6.6.0 Check for empty style before processing.
5309 *
5310 * @param array $styles CSS declarations to convert.
5311 * @param array $values key => value pairs to use for replacement.
5312 * @return array
5313 */
5314 private static function convert_variables_to_value( $styles, $values ) {
5315 foreach ( $styles as $key => $style ) {
5316 if ( empty( $style ) ) {
5317 continue;
5318 }
5319
5320 if ( is_array( $style ) ) {
5321 $styles[ $key ] = self::convert_variables_to_value( $style, $values );
5322 continue;
5323 }
5324
5325 if ( 0 <= strpos( $style, 'var(' ) ) {
5326 // find all the variables in the string in the form of var(--variable-name, fallback), with fallback in the second capture group.
5327
5328 $has_matches = preg_match_all( '/var\(([^),]+)?,?\s?(\S+)?\)/', $style, $var_parts );
5329
5330 if ( $has_matches ) {
5331 $resolved_style = $styles[ $key ];
5332 foreach ( $var_parts[1] as $index => $var_part ) {
5333 $key_in_values = 'var(' . $var_part . ')';
5334 $rule_to_replace = $var_parts[0][ $index ]; // the css rule to replace e.g. var(--wp--preset--color--vivid-green-cyan).
5335 $fallback = $var_parts[2][ $index ]; // the fallback value.
5336 $resolved_style = str_replace(
5337 array(
5338 $rule_to_replace,
5339 $fallback,
5340 ),
5341 array(
5342 $values[ $key_in_values ] ?? $rule_to_replace,
5343 $values[ $fallback ] ?? $fallback,
5344 ),
5345 $resolved_style
5346 );
5347 }
5348 $styles[ $key ] = $resolved_style;
5349 }
5350 }
5351 }
5352
5353 return $styles;
5354 }
5355
5356 /**
5357 * Resolves the values of CSS variables in the given styles.
5358 *
5359 * @since 6.3.0
5360 * @param WP_Theme_JSON_Gutenberg $theme_json The theme json resolver.
5361 *
5362 * @return WP_Theme_JSON_Gutenberg The $theme_json with resolved variables.
5363 */
5364 public static function resolve_variables( $theme_json ) {
5365 $settings = $theme_json->get_settings();
5366 $styles = $theme_json->get_raw_data()['styles'];
5367 $preset_vars = static::compute_preset_vars( $settings, static::VALID_ORIGINS );
5368 $theme_vars = static::compute_theme_vars( $settings );
5369 $vars = array_reduce(
5370 array_merge( $preset_vars, $theme_vars ),
5371 function ( $carry, $item ) {
5372 $name = $item['name'];
5373 $carry[ "var({$name})" ] = $item['value'];
5374 return $carry;
5375 },
5376 array()
5377 );
5378
5379 $theme_json->theme_json['styles'] = self::convert_variables_to_value( $styles, $vars );
5380 return $theme_json;
5381 }
5382
5383 /**
5384 * Generates a selector for a block style variation.
5385 *
5386 * @param string $variation_name Name of the block style variation.
5387 * @param string $block_selector CSS selector for the block.
5388 *
5389 * @return string Block selector with block style variation selector added to it.
5390 */
5391 protected static function get_block_style_variation_selector( $variation_name, $block_selector ) {
5392 $variation_class = ".is-style-$variation_name";
5393
5394 if ( ! $block_selector ) {
5395 return $variation_class;
5396 }
5397
5398 $limit = 1;
5399 $selector_parts = explode( ',', $block_selector );
5400 $result = array();
5401
5402 foreach ( $selector_parts as $part ) {
5403 $result[] = preg_replace_callback(
5404 '/((?::\([^)]+\))?\s*)([^\s:]+)/',
5405 function ( $matches ) use ( $variation_class ) {
5406 return $matches[1] . $matches[2] . $variation_class;
5407 },
5408 $part,
5409 $limit
5410 );
5411 }
5412
5413 return implode( ',', $result );
5414 }
5415
5416 /**
5417 * Collects valid block style variations keyed by block type.
5418 *
5419 * @since 6.6.0
5420 * @since 6.8.0 Added the `$blocks_metadata` parameter.
5421 *
5422 * @param array $blocks_metadata Optional. List of metadata per block. Default is the metadata for all blocks.
5423 * @return array Valid block style variations by block type.
5424 */
5425 protected static function get_valid_block_style_variations( $blocks_metadata = array() ) {
5426 $valid_variations = array();
5427 $blocks_metadata = empty( $blocks_metadata ) ? static::get_blocks_metadata() : $blocks_metadata;
5428 foreach ( $blocks_metadata as $block_name => $block_meta ) {
5429 if ( ! isset( $block_meta['styleVariations'] ) ) {
5430 continue;
5431 }
5432 $valid_variations[ $block_name ] = array_keys( $block_meta['styleVariations'] );
5433 }
5434
5435 return $valid_variations;
5436 }
5437
5438 /**
5439 * Extracts the block name from the block metadata path.
5440 *
5441 * @since 7.0
5442 *
5443 * @param array $block_metadata Block metadata.
5444 * @return string|null The block name or null if not found.
5445 */
5446 private static function get_block_name_from_metadata_path( $block_metadata ) {
5447 if ( isset( $block_metadata['path'] ) ) {
5448 return $block_metadata['path'][2];
5449 }
5450 }
5451 }
5452