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

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

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