PluginProbe
Gutenberg / 24.0.0
Gutenberg v24.0.0
24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 All 403 releases
gutenberg / lib / class-wp-theme-json-gutenberg.php

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

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