diff --git a/docs/manifest.json b/docs/manifest.json index 1a155566a74288..0616aeb2a8e7f8 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1715,6 +1715,12 @@ "markdown_source": "../packages/format-library/README.md", "parent": "packages" }, + { + "title": "@wordpress/global-styles-engine", + "slug": "packages-global-styles-engine", + "markdown_source": "../packages/global-styles-engine/README.md", + "parent": "packages" + }, { "title": "@wordpress/hooks", "slug": "packages-hooks", diff --git a/package-lock.json b/package-lock.json index 328194d0013f62..026d4cdf1d6d8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16843,6 +16843,10 @@ "resolved": "packages/format-library", "link": true }, + "node_modules/@wordpress/global-styles-engine": { + "resolved": "packages/global-styles-engine", + "link": true + }, "node_modules/@wordpress/hooks": { "resolved": "packages/hooks", "link": true @@ -51273,6 +51277,7 @@ "@wordpress/dom": "file:../dom", "@wordpress/element": "file:../element", "@wordpress/escape-html": "file:../escape-html", + "@wordpress/global-styles-engine": "file:../global-styles-engine", "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", @@ -52272,6 +52277,7 @@ "@wordpress/dom": "file:../dom", "@wordpress/editor": "file:../editor", "@wordpress/element": "file:../element", + "@wordpress/global-styles-engine": "file:../global-styles-engine", "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", @@ -52325,6 +52331,7 @@ "@wordpress/element": "file:../element", "@wordpress/escape-html": "file:../escape-html", "@wordpress/fields": "file:../fields", + "@wordpress/global-styles-engine": "file:../global-styles-engine", "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", @@ -52660,6 +52667,24 @@ "react-dom": "^18.0.0" } }, + "packages/global-styles-engine": { + "name": "@wordpress/global-styles-engine", + "version": "1.0.0", + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/blocks": "file:../blocks", + "@wordpress/data": "file:../data", + "@wordpress/i18n": "file:../i18n", + "@wordpress/style-engine": "file:../style-engine", + "colord": "^2.9.2", + "deepmerge": "^4.3.0", + "is-plain-object": "^5.0.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "packages/hooks": { "name": "@wordpress/hooks", "version": "4.33.0", diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 35fbf0eb00b5ac..7a22648e3249f7 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -71,6 +71,7 @@ "@wordpress/dom": "file:../dom", "@wordpress/element": "file:../element", "@wordpress/escape-html": "file:../escape-html", + "@wordpress/global-styles-engine": "file:../global-styles-engine", "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", diff --git a/packages/block-editor/src/components/background-image-control/index.js b/packages/block-editor/src/components/background-image-control/index.js index 2623df90596dde..f73419442721db 100644 --- a/packages/block-editor/src/components/background-image-control/index.js +++ b/packages/block-editor/src/components/background-image-control/index.js @@ -33,11 +33,11 @@ import { useRef, useState, useEffect, useMemo } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { focus } from '@wordpress/dom'; import { isBlobURL } from '@wordpress/blob'; +import { getResolvedValue } from '@wordpress/global-styles-engine'; /** * Internal dependencies */ -import { getResolvedValue } from '../global-styles/utils'; import { hasBackgroundImageValue } from '../global-styles/background-panel'; import { setImmutably } from '../../utils/object'; import MediaReplaceFlow from '../media-replace-flow'; diff --git a/packages/block-editor/src/components/global-styles/border-panel.js b/packages/block-editor/src/components/global-styles/border-panel.js index f0e4cb00991212..83dbe6af79357a 100644 --- a/packages/block-editor/src/components/global-styles/border-panel.js +++ b/packages/block-editor/src/components/global-styles/border-panel.js @@ -11,13 +11,14 @@ import { } from '@wordpress/components'; import { useCallback, useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { getValueFromVariable } from '@wordpress/global-styles-engine'; /** * Internal dependencies */ import BorderRadiusControl from '../border-radius-control'; import { useColorsPerOrigin } from './hooks'; -import { getValueFromVariable, useToolsPanelDropdownMenuProps } from './utils'; +import { useToolsPanelDropdownMenuProps } from './utils'; import { setImmutably } from '../../utils/object'; import { useBorderPanelLabel } from '../../hooks/border'; import { ShadowPopover, useShadowPresets } from './shadow-panel-components'; diff --git a/packages/block-editor/src/components/global-styles/color-panel.js b/packages/block-editor/src/components/global-styles/color-panel.js index 4cfc6079b1955d..a6be029652c03a 100644 --- a/packages/block-editor/src/components/global-styles/color-panel.js +++ b/packages/block-editor/src/components/global-styles/color-panel.js @@ -21,13 +21,14 @@ import { } from '@wordpress/components'; import { useCallback, useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { getValueFromVariable } from '@wordpress/global-styles-engine'; /** * Internal dependencies */ import ColorGradientControl from '../colors-gradients/control'; import { useColorsPerOrigin, useGradientsPerOrigin } from './hooks'; -import { getValueFromVariable, useToolsPanelDropdownMenuProps } from './utils'; +import { useToolsPanelDropdownMenuProps } from './utils'; import { setImmutably } from '../../utils/object'; import { unlock } from '../../lock-unlock'; import { reset as resetIcon } from '@wordpress/icons'; diff --git a/packages/block-editor/src/components/global-styles/color-panel.native.js b/packages/block-editor/src/components/global-styles/color-panel.native.js index 87002b5fa3e22f..c7391dfb77c428 100644 --- a/packages/block-editor/src/components/global-styles/color-panel.native.js +++ b/packages/block-editor/src/components/global-styles/color-panel.native.js @@ -5,13 +5,13 @@ import { useSelect } from '@wordpress/data'; import { useEffect, useState, useMemo, useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as blockEditorStore } from '@wordpress/block-editor'; +import { getValueFromVariable } from '@wordpress/global-styles-engine'; /** * Internal dependencies */ import PanelColorGradientSettings from '../colors-gradients/panel-color-gradient-settings'; import { useColorsPerOrigin, useGradientsPerOrigin } from './hooks'; -import { getValueFromVariable } from './utils'; import { setImmutably } from '../../utils/object'; import ContrastChecker from '../contrast-checker'; import InspectorControls from '../inspector-controls'; diff --git a/packages/block-editor/src/components/global-styles/dimensions-panel.js b/packages/block-editor/src/components/global-styles/dimensions-panel.js index 385f28b668eda2..1e56f5f9490d9f 100644 --- a/packages/block-editor/src/components/global-styles/dimensions-panel.js +++ b/packages/block-editor/src/components/global-styles/dimensions-panel.js @@ -17,11 +17,12 @@ import { } from '@wordpress/components'; import { Icon, alignNone, stretchWide } from '@wordpress/icons'; import { useCallback, useState, Platform } from '@wordpress/element'; +import { getValueFromVariable } from '@wordpress/global-styles-engine'; /** * Internal dependencies */ -import { getValueFromVariable, useToolsPanelDropdownMenuProps } from './utils'; +import { useToolsPanelDropdownMenuProps } from './utils'; import SpacingSizesControl from '../spacing-sizes-control'; import HeightControl from '../height-control'; import ChildLayoutControl from '../child-layout-control'; diff --git a/packages/block-editor/src/components/global-styles/filters-panel.js b/packages/block-editor/src/components/global-styles/filters-panel.js index 2f5208da90b187..be518125df698f 100644 --- a/packages/block-editor/src/components/global-styles/filters-panel.js +++ b/packages/block-editor/src/components/global-styles/filters-panel.js @@ -24,11 +24,12 @@ import { import { __, _x } from '@wordpress/i18n'; import { useCallback, useMemo, useRef } from '@wordpress/element'; import { reset as resetIcon } from '@wordpress/icons'; +import { getValueFromVariable } from '@wordpress/global-styles-engine'; /** * Internal dependencies */ -import { getValueFromVariable, useToolsPanelDropdownMenuProps } from './utils'; +import { useToolsPanelDropdownMenuProps } from './utils'; import { setImmutably } from '../../utils/object'; const EMPTY_ARRAY = []; diff --git a/packages/block-editor/src/components/global-styles/hooks.js b/packages/block-editor/src/components/global-styles/hooks.js index a36d6e1ad002d1..c36a1ecc0bfaf2 100644 --- a/packages/block-editor/src/components/global-styles/hooks.js +++ b/packages/block-editor/src/components/global-styles/hooks.js @@ -10,81 +10,21 @@ import { useContext, useCallback, useMemo } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { store as blocksStore } from '@wordpress/blocks'; import { _x } from '@wordpress/i18n'; +import { + getSetting, + getStyle, + getPresetVariableFromValue, +} from '@wordpress/global-styles-engine'; /** * Internal dependencies */ -import { getValueFromVariable, getPresetVariableFromValue } from './utils'; -import { getValueFromObjectPath, setImmutably } from '../../utils/object'; +import { setImmutably } from '../../utils/object'; import { GlobalStylesContext } from './context'; import { unlock } from '../../lock-unlock'; const EMPTY_CONFIG = { settings: {}, styles: {} }; -const VALID_SETTINGS = [ - 'appearanceTools', - 'useRootPaddingAwareAlignments', - 'background.backgroundImage', - 'background.backgroundRepeat', - 'background.backgroundSize', - 'background.backgroundPosition', - 'border.color', - 'border.radius', - 'border.style', - 'border.width', - 'border.radiusSizes', - 'shadow.presets', - 'shadow.defaultPresets', - 'color.background', - 'color.button', - 'color.caption', - 'color.custom', - 'color.customDuotone', - 'color.customGradient', - 'color.defaultDuotone', - 'color.defaultGradients', - 'color.defaultPalette', - 'color.duotone', - 'color.gradients', - 'color.heading', - 'color.link', - 'color.palette', - 'color.text', - 'custom', - 'dimensions.aspectRatio', - 'dimensions.minHeight', - 'layout.contentSize', - 'layout.definitions', - 'layout.wideSize', - 'lightbox.enabled', - 'lightbox.allowEditing', - 'position.fixed', - 'position.sticky', - 'spacing.customSpacingSize', - 'spacing.defaultSpacingSizes', - 'spacing.spacingSizes', - 'spacing.spacingScale', - 'spacing.blockGap', - 'spacing.margin', - 'spacing.padding', - 'spacing.units', - 'typography.fluid', - 'typography.customFontSize', - 'typography.defaultFontSizes', - 'typography.dropCap', - 'typography.fontFamilies', - 'typography.fontSizes', - 'typography.fontStyle', - 'typography.fontWeight', - 'typography.letterSpacing', - 'typography.lineHeight', - 'typography.textAlign', - 'typography.textColumns', - 'typography.textDecoration', - 'typography.textTransform', - 'typography.writingMode', -]; - export const useGlobalStylesReset = () => { const { user, setUserConfig } = useContext( GlobalStylesContext ); const config = { @@ -103,7 +43,6 @@ export function useGlobalSetting( propertyPath, blockName, source = 'all' ) { const appendedBlockPath = blockName ? '.blocks.' + blockName : ''; const appendedPropertyPath = propertyPath ? '.' + propertyPath : ''; const contextualPath = `settings${ appendedBlockPath }${ appendedPropertyPath }`; - const globalPath = `settings${ appendedPropertyPath }`; const sourceKey = source === 'all' ? 'merged' : source; const settingValue = useMemo( () => { @@ -112,34 +51,9 @@ export function useGlobalSetting( propertyPath, blockName, source = 'all' ) { throw 'Unsupported source'; } - if ( propertyPath ) { - return ( - getValueFromObjectPath( configToUse, contextualPath ) ?? - getValueFromObjectPath( configToUse, globalPath ) - ); - } - - let result = {}; - VALID_SETTINGS.forEach( ( setting ) => { - const value = - getValueFromObjectPath( - configToUse, - `settings${ appendedBlockPath }.${ setting }` - ) ?? - getValueFromObjectPath( configToUse, `settings.${ setting }` ); - if ( value !== undefined ) { - result = setImmutably( result, setting.split( '.' ), value ); - } - } ); - return result; - }, [ - configs, - sourceKey, - propertyPath, - contextualPath, - globalPath, - appendedBlockPath, - ] ); + // Use engine's getSetting instead of duplicating logic + return getSetting( configToUse, propertyPath, blockName ); + }, [ configs, sourceKey, propertyPath, blockName ] ); const setSetting = ( newValue ) => { setUserConfig( ( currentConfig ) => @@ -183,25 +97,32 @@ export function useGlobalStyle( ); }; - let rawResult, result; + let result; + // Use engine's getStyle instead of duplicating logic switch ( source ) { case 'all': - rawResult = getValueFromObjectPath( mergedConfig, finalPath ); - result = shouldDecodeEncode - ? getValueFromVariable( mergedConfig, blockName, rawResult ) - : rawResult; + result = getStyle( + mergedConfig, + path, + blockName, + shouldDecodeEncode + ); break; case 'user': - rawResult = getValueFromObjectPath( userConfig, finalPath ); - result = shouldDecodeEncode - ? getValueFromVariable( mergedConfig, blockName, rawResult ) - : rawResult; + result = getStyle( + userConfig, + path, + blockName, + shouldDecodeEncode + ); break; case 'base': - rawResult = getValueFromObjectPath( baseConfig, finalPath ); - result = shouldDecodeEncode - ? getValueFromVariable( baseConfig, blockName, rawResult ) - : rawResult; + result = getStyle( + baseConfig, + path, + blockName, + shouldDecodeEncode + ); break; default: throw 'Unsupported source'; diff --git a/packages/block-editor/src/components/global-styles/index.js b/packages/block-editor/src/components/global-styles/index.js index 8096a48569f199..eaa1ba29805dad 100644 --- a/packages/block-editor/src/components/global-styles/index.js +++ b/packages/block-editor/src/components/global-styles/index.js @@ -4,14 +4,6 @@ export { useGlobalStyle, useSettingsForBlockElement, } from './hooks'; -export { getBlockCSSSelector } from './get-block-css-selector'; -export { - getLayoutStyles, - getBlockSelectors, - toStyles, - useGlobalStylesOutput, - useGlobalStylesOutputWithConfig, -} from './use-global-styles-output'; export { GlobalStylesContext } from './context'; export { default as TypographyPanel, diff --git a/packages/block-editor/src/components/global-styles/test/typography-utils.js b/packages/block-editor/src/components/global-styles/test/typography-utils.js index a27c3ea1024b1c..7379b07b724d77 100644 --- a/packages/block-editor/src/components/global-styles/test/typography-utils.js +++ b/packages/block-editor/src/components/global-styles/test/typography-utils.js @@ -2,8 +2,6 @@ * Internal dependencies */ import { - getTypographyFontSizeValue, - getFluidTypographyOptionsFromSettings, getMergedFontFamiliesAndFontFamilyFaces, findNearestFontWeight, findNearestFontStyle, @@ -11,717 +9,6 @@ import { } from '../typography-utils'; describe( 'typography utils', () => { - describe( 'getTypographyFontSizeValue', () => { - [ - { - message: - 'should return value when fluid typography is not active', - preset: { - size: '28px', - }, - typographySettings: undefined, - expected: '28px', - }, - - { - message: 'should return value where font size is 0', - preset: { - size: 0, - }, - typographySettings: { - fluid: true, - }, - expected: 0, - }, - - { - message: "should return value where font size is '0'", - preset: { - size: '0', - }, - typographySettings: { - fluid: true, - }, - expected: '0', - }, - - { - message: 'should return value where `size` is `null`.', - preset: { - size: null, - }, - typographySettings: { - fluid: true, - }, - expected: null, - }, - - { - message: 'should return value when fluid is `false`', - preset: { - size: '28px', - fluid: false, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: '28px', - }, - - { - message: 'should return value when fluid config is empty`', - preset: { - size: '28px', - }, - settings: { - typography: { - fluid: {}, - }, - }, - expected: '28px', - }, - - { - message: - 'should return clamp value with `minViewportWidth` override', - preset: { - size: '28px', - }, - settings: { - typography: { - fluid: { - minViewportWidth: '500px', - }, - }, - }, - expected: - 'clamp(17.905px, 1.119rem + ((1vw - 5px) * 0.918), 28px)', - }, - - { - message: - 'should return clamp value with `maxViewportWidth` override', - preset: { - size: '28px', - }, - settings: { - typography: { - fluid: { - maxViewportWidth: '500px', - }, - }, - }, - expected: - 'clamp(17.905px, 1.119rem + ((1vw - 3.2px) * 5.608), 28px)', - }, - - { - message: - 'should return clamp value with `layout.wideSize` override', - preset: { - size: '28px', - }, - settings: { - typography: { - fluid: true, - }, - layout: { - wideSize: '500px', - }, - }, - expected: - 'clamp(17.905px, 1.119rem + ((1vw - 3.2px) * 5.608), 28px)', - }, - - { - message: - 'should return clamp value with `maxViewportWidth` preferred over fallback `layout.wideSize` value', - preset: { - size: '28px', - }, - settings: { - typography: { - fluid: { - maxViewportWidth: '1000px', - }, - }, - layout: { - wideSize: '500px', - }, - }, - expected: - 'clamp(17.905px, 1.119rem + ((1vw - 3.2px) * 1.485), 28px)', - }, - - { - message: 'should return already clamped value', - preset: { - size: 'clamp(21px, 1.313rem + ((1vw - 7.68px) * 2.524), 42px)', - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(21px, 1.313rem + ((1vw - 7.68px) * 2.524), 42px)', - }, - - { - message: 'should return value with unsupported unit', - preset: { - size: '1000%', - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: '1000%', - }, - - { - message: 'should return clamp value with rem min and max units', - preset: { - size: '1.75rem', - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(1.119rem, 1.119rem + ((1vw - 0.2rem) * 0.789), 1.75rem)', - }, - - { - message: - 'should override default max viewport width fluid typography settings', - preset: { - size: '1.75rem', - }, - settings: { - typography: { - fluid: { - maxViewportWidth: '1200px', - }, - }, - }, - expected: - 'clamp(1.119rem, 1.119rem + ((1vw - 0.2rem) * 1.147), 1.75rem)', - }, - - { - message: - 'should override default min viewport width fluid typography settings', - preset: { - size: '1.75rem', - }, - settings: { - typography: { - fluid: { - minViewportWidth: '800px', - }, - }, - }, - expected: - 'clamp(1.119rem, 1.119rem + ((1vw - 0.5rem) * 1.262), 1.75rem)', - }, - - { - message: 'should return clamp value with em min and max units', - preset: { - size: '1.75em', - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(1.119em, 1.119rem + ((1vw - 0.2em) * 0.789), 1.75em)', - }, - - { - message: 'should return clamp value for floats', - preset: { - size: '70.175px', - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(37.897px, 2.369rem + ((1vw - 3.2px) * 2.522), 70.175px)', - }, - - { - message: - 'should coerce integer to `px` and returns clamp value', - preset: { - size: 33, - fluid: true, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(20.515px, 1.282rem + ((1vw - 3.2px) * 0.975), 33px)', - }, - - { - message: 'should coerce float to `px` and returns clamp value', - preset: { - size: 70.175, - fluid: true, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(37.897px, 2.369rem + ((1vw - 3.2px) * 2.522), 70.175px)', - }, - - { - message: - 'should return clamp value when `fluid` is empty array', - preset: { - size: '28px', - fluid: [], - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(17.905px, 1.119rem + ((1vw - 3.2px) * 0.789), 28px)', - }, - - { - message: 'should return clamp value when `fluid` is `null`', - preset: { - size: '28px', - fluid: null, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(17.905px, 1.119rem + ((1vw - 3.2px) * 0.789), 28px)', - }, - - { - message: - 'returns clamp value where min and max fluid values defined', - preset: { - size: '80px', - fluid: { - min: '70px', - max: '125px', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(70px, 4.375rem + ((1vw - 3.2px) * 4.297), 125px)', - }, - - { - message: - 'should apply maxViewportWidth as maximum viewport width', - preset: { - size: '80px', - fluid: { - min: '70px', - max: '125px', - }, - }, - settings: { - typography: { - fluid: { - maxViewportWidth: '1100px', - }, - }, - }, - expected: - 'clamp(70px, 4.375rem + ((1vw - 3.2px) * 7.051), 125px)', - }, - - { - message: 'returns clamp value where max is equal to size', - preset: { - size: '7.8125rem', - fluid: { - min: '4.375rem', - max: '7.8125rem', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(4.375rem, 4.375rem + ((1vw - 0.2rem) * 4.298), 7.8125rem)', - }, - - { - message: - 'should return clamp value if min font size is greater than max', - preset: { - size: '3rem', - fluid: { - min: '5rem', - max: '32px', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: 'clamp(5rem, 5rem + ((1vw - 0.2rem) * -3.75), 32px)', - }, - - { - message: 'should return value with invalid min/max fluid units', - preset: { - size: '10em', - fluid: { - min: '20vw', - max: '50%', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: '10em', - }, - - { - message: - 'should return value when size is < lower bounds and no fluid min/max set', - preset: { - size: '3px', - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: '3px', - }, - - { - message: - 'should return value when size is equal to lower bounds and no fluid min/max set', - preset: { - size: '14px', - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: '14px', - }, - - { - message: - 'should return clamp value with different min max units', - preset: { - size: '28px', - fluid: { - min: '20px', - max: '50rem', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(20px, 1.25rem + ((1vw - 3.2px) * 60.938), 50rem)', - }, - - { - message: - 'should return clamp value where no fluid max size is set', - preset: { - size: '50px', - fluid: { - min: '2.6rem', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(2.6rem, 2.6rem + ((1vw - 0.2rem) * 0.656), 50px)', - }, - - { - message: - 'should return clamp value where no fluid min size is set', - preset: { - size: '28px', - fluid: { - max: '80px', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(17.905px, 1.119rem + ((1vw - 3.2px) * 4.851), 80px)', - }, - - { - message: - 'should not apply lower bound test when fluid values are set', - preset: { - size: '1.5rem', - fluid: { - min: '0.5rem', - max: '5rem', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(0.5rem, 0.5rem + ((1vw - 0.2rem) * 5.625), 5rem)', - }, - - { - message: - 'should not apply lower bound test when only fluid min is set', - preset: { - size: '20px', - fluid: { - min: '12px', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(12px, 0.75rem + ((1vw - 3.2px) * 0.625), 20px)', - }, - - { - message: - 'should not apply lower bound test when only fluid max is set', - preset: { - size: '0.875rem', - fluid: { - max: '20rem', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(0.875rem, 0.875rem + ((1vw - 0.2rem) * 23.906), 20rem)', - }, - { - message: - 'should return clamp value when min and max font sizes are equal', - preset: { - size: '4rem', - fluid: { - min: '30px', - max: '30px', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: 'clamp(30px, 1.875rem + ((1vw - 3.2px) * 1), 30px)', - }, - - { - message: - 'should use default min font size value where min font size unit in fluid config is not supported', - preset: { - size: '15px', - }, - settings: { - typography: { - fluid: { - minFontSize: '16%', - }, - }, - }, - expected: - 'clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.078), 15px)', - }, - - // Equivalent custom config PHP unit tests in `test_should_convert_font_sizes_to_fluid_values()`. - { - message: 'should return clamp value using custom fluid config', - preset: { - size: '17px', - }, - settings: { - typography: { - fluid: { - minFontSize: '16px', - }, - }, - }, - expected: 'clamp(16px, 1rem + ((1vw - 3.2px) * 0.078), 17px)', - }, - - { - message: - 'should return value when font size <= custom min font size bound', - preset: { - size: '15px', - }, - settings: { - typography: { - fluid: { - minFontSize: '16px', - }, - }, - }, - expected: '15px', - }, - - { - message: - 'should apply scaled min font size for em values when custom min font size is not set', - preset: { - size: '12rem', - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(5.174rem, 5.174rem + ((1vw - 0.2rem) * 8.533), 12rem)', - }, - - { - message: - 'should apply scaled min font size for px values when custom min font size is not set', - preset: { - size: '200px', - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(85.342px, 5.334rem + ((1vw - 3.2px) * 8.958), 200px)', - }, - - { - message: - 'should not apply scaled min font size for minimum font size when custom min font size is set', - preset: { - size: '200px', - fluid: { - min: '100px', - }, - }, - settings: { - typography: { - fluid: true, - }, - }, - expected: - 'clamp(100px, 6.25rem + ((1vw - 3.2px) * 7.813), 200px)', - }, - - { - message: 'should apply all custom fluid typography settings', - preset: { - size: '17px', - }, - settings: { - typography: { - fluid: { - minFontSize: '16px', - maxViewportWidth: '1200px', - minViewportWidth: '640px', - }, - }, - }, - expected: 'clamp(16px, 1rem + ((1vw - 6.4px) * 0.179), 17px)', - }, - - // Individual preset settings override global settings. - { - message: - 'should convert individual preset size to fluid if fluid is disabled in global settings', - preset: { - size: '17px', - fluid: true, - }, - settings: { - typography: {}, - }, - expected: - 'clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.234), 17px)', - }, - { - message: - 'should use individual preset settings if fluid is disabled in global settings', - preset: { - size: '17px', - fluid: { - min: '16px', - max: '26px', - }, - }, - settings: { - typography: { - fluid: false, - }, - }, - expected: 'clamp(16px, 1rem + ((1vw - 3.2px) * 0.781), 26px)', - }, - ].forEach( ( { message, preset, settings, expected } ) => { - it( `${ message }`, () => { - expect( getTypographyFontSizeValue( preset, settings ) ).toBe( - expected - ); - } ); - } ); - } ); - describe( 'getMergedFontFamiliesAndFontFamilyFaces', () => { [ { @@ -1366,97 +653,4 @@ describe( 'typography utils', () => { } ); } ); - - describe( 'typography utils', () => { - [ - { - message: - 'should return `undefined` when settings is `undefined`', - settings: undefined, - expected: { fluid: undefined }, - }, - - { - message: - 'should return `undefined` when typography is `undefined`', - settings: {}, - expected: { fluid: undefined }, - }, - - { - message: - 'should return `undefined` when typography.fluid is `undefined`', - settings: { typography: {} }, - expected: { fluid: undefined }, - }, - - { - message: - 'should return `false` when typography.fluid is disabled by `false`', - settings: { typography: { fluid: false } }, - expected: { fluid: false }, - }, - - { - message: 'should return `{}` when typography.fluid is empty', - settings: { typography: { fluid: {} } }, - expected: { fluid: {} }, - }, - - { - message: - 'should return false when typography.fluid is disabled and layout.wideSize is defined', - settings: { - typography: { fluid: false }, - layout: { wideSize: '1000rem' }, - }, - expected: { fluid: false }, - }, - - { - message: - 'should return true when fluid is enabled by a boolean', - settings: { typography: { fluid: true } }, - expected: { fluid: true }, - }, - - { - message: - 'should return fluid settings with merged `layout.wideSize`', - settings: { - typography: { fluid: { minFontSize: '16px' } }, - layout: { wideSize: '1000rem' }, - }, - expected: { - fluid: { maxViewportWidth: '1000rem', minFontSize: '16px' }, - }, - }, - - { - message: - 'should prioritize fluid `maxViewportWidth` over `layout.wideSize`', - settings: { - typography: { fluid: { maxViewportWidth: '10px' } }, - layout: { wideSize: '1000rem' }, - }, - expected: { fluid: { maxViewportWidth: '10px' } }, - }, - { - message: 'should not merge `layout.wideSize` if it is fluid', - settings: { - typography: { fluid: { minFontSize: '16px' } }, - layout: { wideSize: 'clamp(1000px, 85vw, 2000px)' }, - }, - expected: { - fluid: { minFontSize: '16px' }, - }, - }, - ].forEach( ( { message, settings, expected } ) => { - it( `${ message }`, () => { - expect( - getFluidTypographyOptionsFromSettings( settings ) - ).toEqual( expected ); - } ); - } ); - } ); } ); diff --git a/packages/block-editor/src/components/global-styles/test/utils.js b/packages/block-editor/src/components/global-styles/test/utils.js index 57a157662969e1..70fb8fcaa6ad4c 100644 --- a/packages/block-editor/src/components/global-styles/test/utils.js +++ b/packages/block-editor/src/components/global-styles/test/utils.js @@ -1,258 +1,9 @@ /** * Internal dependencies */ -import { - areGlobalStyleConfigsEqual, - getBlockStyleVariationSelector, - getPresetVariableFromValue, - getValueFromVariable, - scopeFeatureSelectors, - getResolvedThemeFilePath, - getResolvedRefValue, - getResolvedValue, -} from '../utils'; +import { areGlobalStyleConfigsEqual } from '../utils'; describe( 'editor utils', () => { - const themeJson = { - version: 1, - settings: { - color: { - palette: { - theme: [ - { - slug: 'primary', - color: '#007cba', - name: 'Primary', - }, - { - slug: 'secondary', - color: '#006ba1', - name: 'Secondary', - }, - ], - custom: [ - { - slug: 'primary', - color: '#007cba', - name: 'Primary', - }, - { - slug: 'secondary', - color: '#a65555', - name: 'Secondary', - }, - ], - }, - custom: true, - customDuotone: true, - customGradient: true, - link: true, - }, - custom: { - color: { - primary: 'var(--wp--preset--color--primary)', - secondary: 'var(--wp--preset--color--secondary)', - }, - }, - }, - styles: { - background: { - backgroundImage: { - url: 'file:./assets/image.jpg', - }, - backgroundAttachment: 'fixed', - backgroundPosition: 'top left', - }, - blocks: { - 'core/group': { - background: { - backgroundImage: { - ref: 'styles.background.backgroundImage', - }, - }, - dimensions: { - minHeight: '100px', - }, - spacing: { - padding: { - top: 0, - }, - }, - }, - }, - }, - _links: { - 'wp:theme-file': [ - { - name: 'file:./assets/image.jpg', - href: 'https://wordpress.org/assets/image.jpg', - target: 'styles.background.backgroundImage.url', - }, - { - name: 'file:./assets/other/image.jpg', - href: 'https://wordpress.org/assets/other/image.jpg', - target: "styles.blocks.['core/group'].background.backgroundImage.url", - }, - ], - }, - isGlobalStylesUserThemeJSON: true, - }; - - describe( 'getPresetVariableFromValue', () => { - const context = 'root'; - const propertyName = 'color.text'; - const value = '#007cba'; - - describe( 'when a provided global style (e.g. fontFamily, color,etc.) does not exist', () => { - it( 'returns the originally provided value', () => { - const actual = getPresetVariableFromValue( - themeJson.settings, - context, - 'fakePropertyName', - value - ); - expect( actual ).toBe( value ); - } ); - } ); - - describe( 'when a global style is cleared by the user', () => { - it( 'returns an undefined preset variable', () => { - const actual = getPresetVariableFromValue( - themeJson.settings, - context, - propertyName, - undefined - ); - expect( actual ).toBe( undefined ); - } ); - } ); - - describe( 'when a global style is selected by the user', () => { - describe( 'and it is not a preset value (e.g. custom color)', () => { - it( 'returns the originally provided value', () => { - const customValue = '#6e4545'; - const actual = getPresetVariableFromValue( - themeJson.settings, - context, - propertyName, - customValue - ); - expect( actual ).toBe( customValue ); - } ); - } ); - - describe( 'and it is a preset value', () => { - it( 'returns the preset variable', () => { - const actual = getPresetVariableFromValue( - themeJson.settings, - context, - propertyName, - value - ); - expect( actual ).toBe( 'var:preset|color|primary' ); - } ); - } ); - } ); - } ); - - describe( 'getValueFromVariable', () => { - describe( 'when provided an invalid variable', () => { - it( 'returns the originally provided value', () => { - const actual = getValueFromVariable( - themeJson, - 'root', - undefined - ); - - expect( actual ).toBe( undefined ); - } ); - } ); - - describe( 'when provided a preset variable', () => { - it( 'retrieves the correct preset value', () => { - const actual = getValueFromVariable( - themeJson, - 'root', - 'var:preset|color|primary' - ); - - expect( actual ).toBe( '#007cba' ); - } ); - } ); - - describe( 'when provided a custom variable', () => { - it( 'retrieves the correct custom value', () => { - const actual = getValueFromVariable( - themeJson, - 'root', - 'var(--wp--custom--color--secondary)' - ); - - expect( actual ).toBe( '#a65555' ); - } ); - } ); - - describe( 'when provided a dynamic reference', () => { - it( 'retrieves the referenced value', () => { - const stylesWithRefs = { - ...themeJson, - styles: { - color: { - background: { - ref: 'styles.color.text', - }, - text: 'purple-rain', - }, - }, - }; - const actual = getValueFromVariable( stylesWithRefs, 'root', { - ref: 'styles.color.text', - } ); - - expect( actual ).toBe( stylesWithRefs.styles.color.text ); - } ); - - it( 'returns the originally provided value where value is dynamic reference and reference does not exist', () => { - const stylesWithRefs = { - ...themeJson, - styles: { - color: { - text: { - ref: 'styles.background.text', - }, - }, - }, - }; - const actual = getValueFromVariable( stylesWithRefs, 'root', { - ref: 'styles.color.text', - } ); - - expect( actual ).toBe( stylesWithRefs.styles.color.text ); - } ); - - it( 'returns the originally provided value where value is dynamic reference', () => { - const stylesWithRefs = { - ...themeJson, - styles: { - color: { - background: { - ref: 'styles.color.text', - }, - text: { - ref: 'styles.background.text', - }, - }, - }, - }; - const actual = getValueFromVariable( stylesWithRefs, 'root', { - ref: 'styles.color.text', - } ); - - expect( actual ).toBe( stylesWithRefs.styles.color.text ); - } ); - } ); - } ); - describe( 'areGlobalStyleConfigsEqual', () => { test.each( [ { original: null, variation: null, expected: true }, @@ -304,196 +55,4 @@ describe( 'editor utils', () => { } ); } ); - - describe( 'getBlockStyleVariationSelector', () => { - test.each( [ - { type: 'empty', selector: '', expected: '.is-style-custom' }, - { - type: 'class', - selector: '.wp-block', - expected: '.wp-block.is-style-custom', - }, - { - type: 'id', - selector: '#wp-block', - expected: '#wp-block.is-style-custom', - }, - { - type: 'element tag', - selector: 'p', - expected: 'p.is-style-custom', - }, - { - type: 'attribute', - selector: '[style*="color"]', - expected: '[style*="color"].is-style-custom', - }, - { - type: 'descendant', - selector: '.wp-block .inner', - expected: '.wp-block.is-style-custom .inner', - }, - { - type: 'comma-separated', - selector: '.wp-block .inner, .wp-block .alternative', - expected: - '.wp-block.is-style-custom .inner, .wp-block.is-style-custom .alternative', - }, - { - type: 'pseudo', - selector: 'div:first-child', - expected: 'div.is-style-custom:first-child', - }, - { - type: ':is', - selector: '.wp-block:is(.outer .inner:first-child)', - expected: - '.wp-block.is-style-custom:is(.outer .inner:first-child)', - }, - { - type: ':not', - selector: '.wp-block:not(.outer .inner:first-child)', - expected: - '.wp-block.is-style-custom:not(.outer .inner:first-child)', - }, - { - type: ':has', - selector: '.wp-block:has(.outer .inner:first-child)', - expected: - '.wp-block.is-style-custom:has(.outer .inner:first-child)', - }, - { - type: ':where', - selector: '.wp-block:where(.outer .inner:first-child)', - expected: - '.wp-block.is-style-custom:where(.outer .inner:first-child)', - }, - { - type: 'wrapping :where', - selector: ':where(.outer .inner:first-child)', - expected: ':where(.outer.is-style-custom .inner:first-child)', - }, - { - type: 'complex', - selector: - '.wp:where(.something):is(.test:not(.nothing p)):has(div[style]) .content, .wp:where(.nothing):not(.test:is(.something div)):has(span[style]) .inner', - expected: - '.wp.is-style-custom:where(.something):is(.test:not(.nothing p)):has(div[style]) .content, .wp.is-style-custom:where(.nothing):not(.test:is(.something div)):has(span[style]) .inner', - }, - ] )( - 'should add variation class to ancestor in $type selector', - ( { selector, expected } ) => { - expect( - getBlockStyleVariationSelector( 'custom', selector ) - ).toBe( expected ); - } - ); - } ); - - describe( 'scopeFeatureSelectors', () => { - it( 'correctly scopes selectors while maintaining selectors object structure', () => { - const actual = scopeFeatureSelectors( '.custom, .secondary', { - color: '.my-block h1', - typography: { - root: '.my-block', - lineHeight: '.my-block h1', - }, - } ); - - expect( actual ).toEqual( { - color: '.custom .my-block h1, .secondary .my-block h1', - typography: { - root: '.custom .my-block, .secondary .my-block', - lineHeight: '.custom .my-block h1, .secondary .my-block h1', - }, - } ); - } ); - } ); - - describe( 'getResolvedThemeFilePath()', () => { - it.each( [ - [ - 'file:./assets/image.jpg', - 'https://wordpress.org/assets/image.jpg', - 'Should return absolute URL if found in themeFileURIs', - ], - [ - 'file:./misc/image.jpg', - 'file:./misc/image.jpg', - 'Should return value if not found in themeFileURIs', - ], - [ - 'https://wordpress.org/assets/image.jpg', - 'https://wordpress.org/assets/image.jpg', - 'Should not match absolute URLs', - ], - ] )( - 'Given file %s and return value %s: %s', - ( file, returnedValue ) => { - expect( - getResolvedThemeFilePath( - file, - themeJson._links[ 'wp:theme-file' ] - ) === returnedValue - ).toBe( true ); - } - ); - } ); - - describe( 'getResolvedRefValue()', () => { - it.each( [ - [ 'blue', 'blue', null ], - [ 0, 0, themeJson ], - [ - { ref: 'styles.background.backgroundImage' }, - { url: 'file:./assets/image.jpg' }, - themeJson, - ], - [ - { ref: 'styles.blocks.core/group.spacing.padding.top' }, - 0, - themeJson, - ], - [ - { - ref: 'styles.blocks.core/group.background.backgroundImage', - }, - undefined, - themeJson, - ], - ] )( - 'Given ruleValue %s return expected value of %s', - ( ruleValue, returnedValue, tree ) => { - expect( getResolvedRefValue( ruleValue, tree ) ).toEqual( - returnedValue - ); - } - ); - } ); - - describe( 'getResolvedValue()', () => { - it.each( [ - [ 'blue', 'blue', null ], - [ 0, 0, themeJson ], - [ - { ref: 'styles.background.backgroundImage' }, - { url: 'https://wordpress.org/assets/image.jpg' }, - themeJson, - ], - [ - { - ref: 'styles.blocks.core/group.background.backgroundImage', - }, - undefined, - themeJson, - ], - ] )( - 'Given ruleValue %s return expected value of %s', - ( ruleValue, returnedValue, tree ) => { - expect( getResolvedValue( ruleValue, tree ) ).toEqual( - returnedValue - ); - } - ); - } ); } ); diff --git a/packages/block-editor/src/components/global-styles/typography-panel.js b/packages/block-editor/src/components/global-styles/typography-panel.js index 9bc875cdc0a308..608742621a3e42 100644 --- a/packages/block-editor/src/components/global-styles/typography-panel.js +++ b/packages/block-editor/src/components/global-styles/typography-panel.js @@ -9,6 +9,7 @@ import { } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useCallback, useMemo, useEffect } from '@wordpress/element'; +import { getValueFromVariable } from '@wordpress/global-styles-engine'; /** * Internal dependencies @@ -21,7 +22,7 @@ import TextAlignmentControl from '../text-alignment-control'; import TextTransformControl from '../text-transform-control'; import TextDecorationControl from '../text-decoration-control'; import WritingModeControl from '../writing-mode-control'; -import { getValueFromVariable, useToolsPanelDropdownMenuProps } from './utils'; +import { useToolsPanelDropdownMenuProps } from './utils'; import { setImmutably } from '../../utils/object'; import { getMergedFontFamiliesAndFontFamilyFaces, diff --git a/packages/block-editor/src/components/global-styles/typography-utils.js b/packages/block-editor/src/components/global-styles/typography-utils.js index 2f4d2b4424a6fb..47e053fedbb996 100644 --- a/packages/block-editor/src/components/global-styles/typography-utils.js +++ b/packages/block-editor/src/components/global-styles/typography-utils.js @@ -1,141 +1,8 @@ -/** - * The fluid utilities must match the backend equivalent. - * See: gutenberg_get_typography_font_size_value() in lib/block-supports/typography.php - * --------------------------------------------------------------- - */ - /** * Internal dependencies */ -import { - getComputedFluidTypographyValue, - getTypographyValueAndUnit, -} from '../font-sizes/fluid-utils'; import { getFontStylesAndWeights } from '../../utils/get-font-styles-and-weights'; -/** - * @typedef {Object} FluidPreset - * @property {string|undefined} max A maximum font size value. - * @property {?string|undefined} min A minimum font size value. - */ - -/** - * @typedef {Object} Preset - * @property {?string|?number} size A default font size. - * @property {string} name A font size name, displayed in the UI. - * @property {string} slug A font size slug - * @property {boolean|FluidPreset|undefined} fluid Specifies the minimum and maximum font size value of a fluid font size. - */ - -/** - * @typedef {Object} TypographySettings - * @property {?string} minViewportWidth Minimum viewport size from which type will have fluidity. Optional if size is specified. - * @property {?string} maxViewportWidth Maximum size up to which type will have fluidity. Optional if size is specified. - * @property {?number} scaleFactor A scale factor to determine how fast a font scales within boundaries. Optional. - * @property {?number} minFontSizeFactor How much to scale defaultFontSize by to derive minimumFontSize. Optional. - * @property {?string} minFontSize The smallest a calculated font size may be. Optional. - */ - -/** - * Returns a font-size value based on a given font-size preset. - * Takes into account fluid typography parameters and attempts to return a css formula depending on available, valid values. - * - * The Core PHP equivalent is wp_get_typography_font_size_value(). - * - * @param {Preset} preset - * @param {Object} settings - * @param {boolean|TypographySettings} settings.typography.fluid Whether fluid typography is enabled, and, optionally, fluid font size options. - * @param {?Object} settings.typography.layout Layout options. - * - * @return {string|*} A font-size value or the value of preset.size. - */ -export function getTypographyFontSizeValue( preset, settings ) { - const { size: defaultSize } = preset; - - /* - * Catch falsy values and 0/'0'. Fluid calculations cannot be performed on `0`. - * Also return early when a preset font size explicitly disables fluid typography with `false`. - */ - if ( ! defaultSize || '0' === defaultSize || false === preset?.fluid ) { - return defaultSize; - } - - /* - * Return early when fluid typography is disabled in the settings, and there - * are no local settings to enable it for the individual preset. - * - * If this condition isn't met, either the settings or individual preset settings - * have enabled fluid typography. - */ - if ( - ! isFluidTypographyEnabled( settings?.typography ) && - ! isFluidTypographyEnabled( preset ) - ) { - return defaultSize; - } - - let fluidTypographySettings = - getFluidTypographyOptionsFromSettings( settings ); - fluidTypographySettings = - typeof fluidTypographySettings?.fluid === 'object' - ? fluidTypographySettings?.fluid - : {}; - - const fluidFontSizeValue = getComputedFluidTypographyValue( { - minimumFontSize: preset?.fluid?.min, - maximumFontSize: preset?.fluid?.max, - fontSize: defaultSize, - minimumFontSizeLimit: fluidTypographySettings?.minFontSize, - maximumViewportWidth: fluidTypographySettings?.maxViewportWidth, - minimumViewportWidth: fluidTypographySettings?.minViewportWidth, - } ); - - if ( !! fluidFontSizeValue ) { - return fluidFontSizeValue; - } - - return defaultSize; -} - -function isFluidTypographyEnabled( typographySettings ) { - const fluidSettings = typographySettings?.fluid; - return ( - true === fluidSettings || - ( fluidSettings && - typeof fluidSettings === 'object' && - Object.keys( fluidSettings ).length > 0 ) - ); -} - -/** - * Returns fluid typography settings from theme.json setting object. - * - * @param {Object} settings Theme.json settings - * @param {Object} settings.typography Theme.json typography settings - * @param {Object} settings.layout Theme.json layout settings - * @return {TypographySettings} Fluid typography settings - */ -export function getFluidTypographyOptionsFromSettings( settings ) { - const typographySettings = settings?.typography; - const layoutSettings = settings?.layout; - const defaultMaxViewportWidth = getTypographyValueAndUnit( - layoutSettings?.wideSize - ) - ? layoutSettings?.wideSize - : null; - return isFluidTypographyEnabled( typographySettings ) && - defaultMaxViewportWidth - ? { - fluid: { - maxViewportWidth: defaultMaxViewportWidth, - ...typographySettings.fluid, - }, - } - : { - fluid: typographySettings?.fluid, - }; -} - /** * Returns an object of merged font families and the font faces from the selected font family * based on the theme.json settings object and the currently selected font family. diff --git a/packages/block-editor/src/components/global-styles/utils.js b/packages/block-editor/src/components/global-styles/utils.js index 4bb5e32610643a..70ff73f1d025b1 100644 --- a/packages/block-editor/src/components/global-styles/utils.js +++ b/packages/block-editor/src/components/global-styles/utils.js @@ -7,145 +7,6 @@ import fastDeepEqual from 'fast-deep-equal/es6'; * WordPress dependencies */ import { useViewportMatch } from '@wordpress/compose'; -import { getCSSValueFromRawStyle } from '@wordpress/style-engine'; - -/** - * Internal dependencies - */ -import { getTypographyFontSizeValue } from './typography-utils'; -import { getValueFromObjectPath } from '../../utils/object'; - -/* Supporting data. */ -export const ROOT_BLOCK_SELECTOR = 'body'; -export const ROOT_CSS_PROPERTIES_SELECTOR = ':root'; - -export const PRESET_METADATA = [ - { - path: [ 'color', 'palette' ], - valueKey: 'color', - cssVarInfix: 'color', - classes: [ - { classSuffix: 'color', propertyName: 'color' }, - { - classSuffix: 'background-color', - propertyName: 'background-color', - }, - { - classSuffix: 'border-color', - propertyName: 'border-color', - }, - ], - }, - { - path: [ 'color', 'gradients' ], - valueKey: 'gradient', - cssVarInfix: 'gradient', - classes: [ - { - classSuffix: 'gradient-background', - propertyName: 'background', - }, - ], - }, - { - path: [ 'color', 'duotone' ], - valueKey: 'colors', - cssVarInfix: 'duotone', - valueFunc: ( { slug } ) => `url( '#wp-duotone-${ slug }' )`, - classes: [], - }, - { - path: [ 'shadow', 'presets' ], - valueKey: 'shadow', - cssVarInfix: 'shadow', - classes: [], - }, - { - path: [ 'typography', 'fontSizes' ], - valueFunc: ( preset, settings ) => - getTypographyFontSizeValue( preset, settings ), - valueKey: 'size', - cssVarInfix: 'font-size', - classes: [ { classSuffix: 'font-size', propertyName: 'font-size' } ], - }, - { - path: [ 'typography', 'fontFamilies' ], - valueKey: 'fontFamily', - cssVarInfix: 'font-family', - classes: [ - { classSuffix: 'font-family', propertyName: 'font-family' }, - ], - }, - { - path: [ 'spacing', 'spacingSizes' ], - valueKey: 'size', - cssVarInfix: 'spacing', - classes: [], - }, - { - path: [ 'border', 'radiusSizes' ], - valueKey: 'size', - cssVarInfix: 'border-radius', - classes: [], - }, -]; - -export const STYLE_PATH_TO_CSS_VAR_INFIX = { - 'color.background': 'color', - 'color.text': 'color', - 'filter.duotone': 'duotone', - 'elements.link.color.text': 'color', - 'elements.link.:hover.color.text': 'color', - 'elements.link.typography.fontFamily': 'font-family', - 'elements.link.typography.fontSize': 'font-size', - 'elements.button.color.text': 'color', - 'elements.button.color.background': 'color', - 'elements.caption.color.text': 'color', - 'elements.button.typography.fontFamily': 'font-family', - 'elements.button.typography.fontSize': 'font-size', - 'elements.heading.color': 'color', - 'elements.heading.color.background': 'color', - 'elements.heading.typography.fontFamily': 'font-family', - 'elements.heading.gradient': 'gradient', - 'elements.heading.color.gradient': 'gradient', - 'elements.h1.color': 'color', - 'elements.h1.color.background': 'color', - 'elements.h1.typography.fontFamily': 'font-family', - 'elements.h1.color.gradient': 'gradient', - 'elements.h2.color': 'color', - 'elements.h2.color.background': 'color', - 'elements.h2.typography.fontFamily': 'font-family', - 'elements.h2.color.gradient': 'gradient', - 'elements.h3.color': 'color', - 'elements.h3.color.background': 'color', - 'elements.h3.typography.fontFamily': 'font-family', - 'elements.h3.color.gradient': 'gradient', - 'elements.h4.color': 'color', - 'elements.h4.color.background': 'color', - 'elements.h4.typography.fontFamily': 'font-family', - 'elements.h4.color.gradient': 'gradient', - 'elements.h5.color': 'color', - 'elements.h5.color.background': 'color', - 'elements.h5.typography.fontFamily': 'font-family', - 'elements.h5.color.gradient': 'gradient', - 'elements.h6.color': 'color', - 'elements.h6.color.background': 'color', - 'elements.h6.typography.fontFamily': 'font-family', - 'elements.h6.color.gradient': 'gradient', - 'color.gradient': 'gradient', - shadow: 'shadow', - 'typography.fontSize': 'font-size', - 'typography.fontFamily': 'font-family', -}; - -// A static list of block attributes that store global style preset slugs. -export const STYLE_PATH_TO_PRESET_BLOCK_ATTRIBUTE = { - 'color.background': 'backgroundColor', - 'color.text': 'textColor', - 'color.gradient': 'gradient', - 'typography.fontSize': 'fontSize', - 'typography.fontFamily': 'fontFamily', -}; export function useToolsPanelDropdownMenuProps() { const isMobile = useViewportMatch( 'medium', '<' ); @@ -160,208 +21,6 @@ export function useToolsPanelDropdownMenuProps() { : {}; } -function findInPresetsBy( - features, - blockName, - presetPath, - presetProperty, - presetValueValue -) { - // Block presets take priority above root level presets. - const orderedPresetsByOrigin = [ - getValueFromObjectPath( features, [ - 'blocks', - blockName, - ...presetPath, - ] ), - getValueFromObjectPath( features, presetPath ), - ]; - - for ( const presetByOrigin of orderedPresetsByOrigin ) { - if ( presetByOrigin ) { - // Preset origins ordered by priority. - const origins = [ 'custom', 'theme', 'default' ]; - for ( const origin of origins ) { - const presets = presetByOrigin[ origin ]; - if ( presets ) { - const presetObject = presets.find( - ( preset ) => - preset[ presetProperty ] === presetValueValue - ); - if ( presetObject ) { - if ( presetProperty === 'slug' ) { - return presetObject; - } - // If there is a highest priority preset with the same slug but different value the preset we found was overwritten and should be ignored. - const highestPresetObjectWithSameSlug = findInPresetsBy( - features, - blockName, - presetPath, - 'slug', - presetObject.slug - ); - if ( - highestPresetObjectWithSameSlug[ - presetProperty - ] === presetObject[ presetProperty ] - ) { - return presetObject; - } - return undefined; - } - } - } - } - } -} - -export function getPresetVariableFromValue( - features, - blockName, - variableStylePath, - presetPropertyValue -) { - if ( ! presetPropertyValue ) { - return presetPropertyValue; - } - - const cssVarInfix = STYLE_PATH_TO_CSS_VAR_INFIX[ variableStylePath ]; - - const metadata = PRESET_METADATA.find( - ( data ) => data.cssVarInfix === cssVarInfix - ); - - if ( ! metadata ) { - // The property doesn't have preset data - // so the value should be returned as it is. - return presetPropertyValue; - } - const { valueKey, path } = metadata; - - const presetObject = findInPresetsBy( - features, - blockName, - path, - valueKey, - presetPropertyValue - ); - - if ( ! presetObject ) { - // Value wasn't found in the presets, - // so it must be a custom value. - return presetPropertyValue; - } - - return `var:preset|${ cssVarInfix }|${ presetObject.slug }`; -} - -function getValueFromPresetVariable( - features, - blockName, - variable, - [ presetType, slug ] -) { - const metadata = PRESET_METADATA.find( - ( data ) => data.cssVarInfix === presetType - ); - if ( ! metadata ) { - return variable; - } - - const presetObject = findInPresetsBy( - features.settings, - blockName, - metadata.path, - 'slug', - slug - ); - - if ( presetObject ) { - const { valueKey } = metadata; - const result = presetObject[ valueKey ]; - return getValueFromVariable( features, blockName, result ); - } - - return variable; -} - -function getValueFromCustomVariable( features, blockName, variable, path ) { - const result = - getValueFromObjectPath( features.settings, [ - 'blocks', - blockName, - 'custom', - ...path, - ] ) ?? - getValueFromObjectPath( features.settings, [ 'custom', ...path ] ); - if ( ! result ) { - return variable; - } - // A variable may reference another variable so we need recursion until we find the value. - return getValueFromVariable( features, blockName, result ); -} - -/** - * Attempts to fetch the value of a theme.json CSS variable. - * - * @param {Object} features GlobalStylesContext config, e.g., user, base or merged. Represents the theme.json tree. - * @param {string} blockName The name of a block as represented in the styles property. E.g., 'root' for root-level, and 'core/${blockName}' for blocks. - * @param {string|*} variable An incoming style value. A CSS var value is expected, but it could be any value. - * @return {string|*|{ref}} The value of the CSS var, if found. If not found, the passed variable argument. - */ -export function getValueFromVariable( features, blockName, variable ) { - if ( ! variable || typeof variable !== 'string' ) { - if ( typeof variable?.ref === 'string' ) { - variable = getValueFromObjectPath( features, variable.ref ); - // Presence of another ref indicates a reference to another dynamic value. - // Pointing to another dynamic value is not supported. - if ( ! variable || !! variable?.ref ) { - return variable; - } - } else { - return variable; - } - } - const USER_VALUE_PREFIX = 'var:'; - const THEME_VALUE_PREFIX = 'var(--wp--'; - const THEME_VALUE_SUFFIX = ')'; - - let parsedVar; - - if ( variable.startsWith( USER_VALUE_PREFIX ) ) { - parsedVar = variable.slice( USER_VALUE_PREFIX.length ).split( '|' ); - } else if ( - variable.startsWith( THEME_VALUE_PREFIX ) && - variable.endsWith( THEME_VALUE_SUFFIX ) - ) { - parsedVar = variable - .slice( THEME_VALUE_PREFIX.length, -THEME_VALUE_SUFFIX.length ) - .split( '--' ); - } else { - // We don't know how to parse the value: either is raw of uses complex CSS such as `calc(1px * var(--wp--variable) )` - return variable; - } - - const [ type, ...path ] = parsedVar; - if ( type === 'preset' ) { - return getValueFromPresetVariable( - features, - blockName, - variable, - path - ); - } - if ( type === 'custom' ) { - return getValueFromCustomVariable( - features, - blockName, - variable, - path - ); - } - return variable; -} - /** * Function that scopes a selector with another one. This works a bit like * SCSS nesting except the `&` operator isn't supported. @@ -397,78 +56,6 @@ export function scopeSelector( scope, selector ) { return selectorsScoped.join( ', ' ); } -/** - * Scopes a collection of selectors for features and subfeatures. - * - * @example - * ```js - * const scope = '.custom-scope'; - * const selectors = { - * color: '.wp-my-block p', - * typography: { fontSize: '.wp-my-block caption' }, - * }; - * const result = scopeFeatureSelector( scope, selectors ); - * // result is { - * // color: '.custom-scope .wp-my-block p', - * // typography: { fonSize: '.custom-scope .wp-my-block caption' }, - * // } - * ``` - * - * @param {string} scope Selector to scope collection of selectors with. - * @param {Object} selectors Collection of feature selectors e.g. - * - * @return {Object|undefined} Scoped collection of feature selectors. - */ -export function scopeFeatureSelectors( scope, selectors ) { - if ( ! scope || ! selectors ) { - return; - } - - const featureSelectors = {}; - - Object.entries( selectors ).forEach( ( [ feature, selector ] ) => { - if ( typeof selector === 'string' ) { - featureSelectors[ feature ] = scopeSelector( scope, selector ); - } - - if ( typeof selector === 'object' ) { - featureSelectors[ feature ] = {}; - - Object.entries( selector ).forEach( - ( [ subfeature, subfeatureSelector ] ) => { - featureSelectors[ feature ][ subfeature ] = scopeSelector( - scope, - subfeatureSelector - ); - } - ); - } - } ); - - return featureSelectors; -} - -/** - * Appends a sub-selector to an existing one. - * - * Given the compounded `selector` "h1, h2, h3" - * and the `toAppend` selector ".some-class" the result will be - * "h1.some-class, h2.some-class, h3.some-class". - * - * @param {string} selector Original selector. - * @param {string} toAppend Selector to append. - * - * @return {string} The new selector. - */ -export function appendToSelector( selector, toAppend ) { - if ( ! selector.includes( ',' ) ) { - return selector + toAppend; - } - const selectors = selector.split( ',' ); - const newSelectors = selectors.map( ( sel ) => sel + toAppend ); - return newSelectors.join( ',' ); -} - /** * Compares global style variations according to their styles and settings properties. * @@ -494,127 +81,3 @@ export function areGlobalStyleConfigsEqual( original, variation ) { fastDeepEqual( original?.settings, variation?.settings ) ); } - -/** - * Generates the selector for a block style variation by creating the - * appropriate CSS class and adding it to the ancestor portion of the block's - * selector. - * - * For example, take the Button block which has a compound selector: - * `.wp-block-button .wp-block-button__link`. With a variation named 'custom', - * the class `.is-style-custom` should be added to the `.wp-block-button` - * ancestor only. - * - * This function will take into account comma separated and complex selectors. - * - * @param {string} variation Name for the variation. - * @param {string} blockSelector CSS selector for the block. - * - * @return {string} CSS selector for the block style variation. - */ -export function getBlockStyleVariationSelector( variation, blockSelector ) { - const variationClass = `.is-style-${ variation }`; - - if ( ! blockSelector ) { - return variationClass; - } - - const ancestorRegex = /((?::\([^)]+\))?\s*)([^\s:]+)/; - const addVariationClass = ( _match, group1, group2 ) => { - return group1 + group2 + variationClass; - }; - - const result = blockSelector - .split( ',' ) - .map( ( part ) => part.replace( ancestorRegex, addVariationClass ) ); - - return result.join( ',' ); -} - -/** - * Looks up a theme file URI based on a relative path. - * - * @param {string} file A relative path. - * @param {Array} themeFileURIs A collection of absolute theme file URIs and their corresponding file paths. - * @return {string} A resolved theme file URI, if one is found in the themeFileURIs collection. - */ -export function getResolvedThemeFilePath( file, themeFileURIs ) { - if ( ! file || ! themeFileURIs || ! Array.isArray( themeFileURIs ) ) { - return file; - } - - const uri = themeFileURIs.find( - ( themeFileUri ) => themeFileUri?.name === file - ); - - if ( ! uri?.href ) { - return file; - } - - return uri?.href; -} - -/** - * Resolves ref values in theme JSON. - * - * @param {Object|string} ruleValue A block style value that may contain a reference to a theme.json value. - * @param {Object} tree A theme.json object. - * @return {*} The resolved value or incoming ruleValue. - */ -export function getResolvedRefValue( ruleValue, tree ) { - if ( ! ruleValue || ! tree ) { - return ruleValue; - } - - /* - * Where the rule value is an object with a 'ref' property pointing - * to a path, this converts that path into the value at that path. - * For example: { "ref": "style.color.background" } => "#fff". - */ - if ( typeof ruleValue !== 'string' && ruleValue?.ref ) { - const resolvedRuleValue = getCSSValueFromRawStyle( - getValueFromObjectPath( tree, ruleValue.ref ) - ); - - /* - * Presence of another ref indicates a reference to another dynamic value. - * Pointing to another dynamic value is not supported. - */ - if ( resolvedRuleValue?.ref ) { - return undefined; - } - - if ( resolvedRuleValue === undefined ) { - return ruleValue; - } - - return resolvedRuleValue; - } - return ruleValue; -} - -/** - * Resolves ref and relative path values in theme JSON. - * - * @param {Object|string} ruleValue A block style value that may contain a reference to a theme.json value. - * @param {Object} tree A theme.json object. - * @return {*} The resolved value or incoming ruleValue. - */ -export function getResolvedValue( ruleValue, tree ) { - if ( ! ruleValue || ! tree ) { - return ruleValue; - } - - // Resolve ref values. - const resolvedValue = getResolvedRefValue( ruleValue, tree ); - - // Resolve relative paths. - if ( resolvedValue?.url ) { - resolvedValue.url = getResolvedThemeFilePath( - resolvedValue.url, - tree?._links?.[ 'wp:theme-file' ] - ); - } - - return resolvedValue; -} diff --git a/packages/block-editor/src/hooks/block-style-variation.js b/packages/block-editor/src/hooks/block-style-variation.js index 65582d0c0cf948..61c841e076da57 100644 --- a/packages/block-editor/src/hooks/block-style-variation.js +++ b/packages/block-editor/src/hooks/block-style-variation.js @@ -4,15 +4,12 @@ import { getBlockTypes, store as blocksStore } from '@wordpress/blocks'; import { useSelect } from '@wordpress/data'; import { useContext, useMemo } from '@wordpress/element'; +import { toStyles, getBlockSelectors } from '@wordpress/global-styles-engine'; /** * Internal dependencies */ -import { - GlobalStylesContext, - toStyles, - getBlockSelectors, -} from '../components/global-styles'; +import { GlobalStylesContext } from '../components/global-styles'; import { usePrivateStyleOverride } from './utils'; import { getValueFromObjectPath } from '../utils/object'; import { store as blockEditorStore } from '../store'; @@ -127,7 +124,6 @@ export function __unstableBlockStyleVariationOverridesWithConfig( { config } ) { }; const blockSelectors = getBlockSelectors( getBlockTypes(), - getBlockStyles, override.clientId ); const hasBlockGapSupport = false; @@ -322,11 +318,7 @@ function useBlockProps( { name, className, clientId } ) { } const variationConfig = { settings, styles }; - const blockSelectors = getBlockSelectors( - getBlockTypes(), - getBlockStyles, - clientId - ); + const blockSelectors = getBlockSelectors( getBlockTypes(), clientId ); const hasBlockGapSupport = false; const hasFallbackGapSupport = true; const disableLayoutStyles = true; @@ -349,7 +341,7 @@ function useBlockProps( { name, className, clientId } ) { variationStyles: true, } ); - }, [ variation, settings, styles, getBlockStyles, clientId ] ); + }, [ variation, settings, styles, clientId ] ); usePrivateStyleOverride( { id: `variation-${ clientId }`, diff --git a/packages/block-editor/src/hooks/duotone.js b/packages/block-editor/src/hooks/duotone.js index fbc712ac28d154..75dc6b32dc09f0 100644 --- a/packages/block-editor/src/hooks/duotone.js +++ b/packages/block-editor/src/hooks/duotone.js @@ -15,6 +15,7 @@ import { import { useInstanceId } from '@wordpress/compose'; import { addFilter } from '@wordpress/hooks'; import { useMemo, useEffect } from '@wordpress/element'; +import { getBlockSelector } from '@wordpress/global-styles-engine'; /** * Internal dependencies @@ -30,7 +31,6 @@ import { getDuotoneStylesheet, getDuotoneUnsetStylesheet, } from '../components/duotone/utils'; -import { getBlockCSSSelector } from '../components/global-styles/get-block-css-selector'; import { scopeSelector } from '../components/global-styles/utils'; import { cleanEmptyObject, @@ -354,14 +354,14 @@ function useBlockProps( { clientId, name, style } ) { false ); if ( experimentalDuotone ) { - const rootSelector = getBlockCSSSelector( blockType ); + const rootSelector = getBlockSelector( blockType ); return typeof experimentalDuotone === 'string' ? scopeSelector( rootSelector, experimentalDuotone ) : rootSelector; } // Regular filter.duotone support uses filter.duotone selectors with fallbacks. - return getBlockCSSSelector( blockType, 'filter.duotone', { + return getBlockSelector( blockType, 'filter.duotone', { fallback: true, } ); } diff --git a/packages/block-editor/src/hooks/font-size.js b/packages/block-editor/src/hooks/font-size.js index e0f8c47c163b07..6fa22d6a6e4f7b 100644 --- a/packages/block-editor/src/hooks/font-size.js +++ b/packages/block-editor/src/hooks/font-size.js @@ -4,6 +4,7 @@ import { addFilter } from '@wordpress/hooks'; import { hasBlockSupport } from '@wordpress/blocks'; import TokenList from '@wordpress/token-list'; +import { getTypographyFontSizeValue } from '@wordpress/global-styles-engine'; /** * Internal dependencies @@ -21,7 +22,6 @@ import { shouldSkipSerialization, } from './utils'; import { useSettings } from '../components/use-settings'; -import { getTypographyFontSizeValue } from '../components/global-styles/typography-utils'; export const FONT_SIZE_SUPPORT_KEY = 'typography.fontSize'; diff --git a/packages/block-editor/src/hooks/use-typography-props.js b/packages/block-editor/src/hooks/use-typography-props.js index 0f1a2caefde048..6a8423dcf26ae3 100644 --- a/packages/block-editor/src/hooks/use-typography-props.js +++ b/packages/block-editor/src/hooks/use-typography-props.js @@ -7,13 +7,13 @@ import clsx from 'clsx'; * WordPress dependencies */ import { privateApis as componentsPrivateApis } from '@wordpress/components'; +import { getTypographyFontSizeValue } from '@wordpress/global-styles-engine'; /** * Internal dependencies */ import { getInlineStyles } from './style'; import { getFontSizeClass } from '../components/font-sizes'; -import { getTypographyFontSizeValue } from '../components/global-styles/typography-utils'; import { unlock } from '../lock-unlock'; const { kebabCase } = unlock( componentsPrivateApis ); diff --git a/packages/block-editor/tsconfig.json b/packages/block-editor/tsconfig.json index 30fe326d35b83e..56bf25f6164034 100644 --- a/packages/block-editor/tsconfig.json +++ b/packages/block-editor/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../dom" }, { "path": "../element" }, { "path": "../escape-html" }, + { "path": "../global-styles-engine" }, { "path": "../priority-queue" }, { "path": "../private-apis" }, { "path": "../hooks" }, diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json index 8feadb1a588d82..b717f91470794d 100644 --- a/packages/edit-post/package.json +++ b/packages/edit-post/package.json @@ -51,6 +51,7 @@ "@wordpress/dom": "file:../dom", "@wordpress/editor": "file:../editor", "@wordpress/element": "file:../element", + "@wordpress/global-styles-engine": "file:../global-styles-engine", "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", diff --git a/packages/edit-post/src/components/layout/index.js b/packages/edit-post/src/components/layout/index.js index 28707f3bcd9636..6bf2b2349d240f 100644 --- a/packages/edit-post/src/components/layout/index.js +++ b/packages/edit-post/src/components/layout/index.js @@ -19,10 +19,8 @@ import { privateApis as editorPrivateApis, } from '@wordpress/editor'; import { useSelect, useDispatch } from '@wordpress/data'; -import { - privateApis as blockEditorPrivateApis, - store as blockEditorStore, -} from '@wordpress/block-editor'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { getLayoutStyles } from '@wordpress/global-styles-engine'; import { PluginArea } from '@wordpress/plugins'; import { __, sprintf } from '@wordpress/i18n'; import { @@ -75,7 +73,6 @@ import { useShouldIframe } from './use-should-iframe'; import useNavigateToEntityRecord from '../../hooks/use-navigate-to-entity-record'; import { useMetaBoxInitialization } from '../meta-boxes/use-meta-box-initialization'; -const { getLayoutStyles } = unlock( blockEditorPrivateApis ); const { useCommandContext } = unlock( commandsPrivateApis ); const { Editor, FullscreenMode } = unlock( editorPrivateApis ); const { BlockKeyboardShortcuts } = unlock( blockLibraryPrivateApis ); diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 4e64c6e3c3cde9..ffa6c0c1ac77e5 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -58,6 +58,7 @@ "@wordpress/element": "file:../element", "@wordpress/escape-html": "file:../escape-html", "@wordpress/fields": "file:../fields", + "@wordpress/global-styles-engine": "file:../global-styles-engine", "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", diff --git a/packages/edit-site/src/components/global-styles-renderer/index.js b/packages/edit-site/src/components/global-styles-renderer/index.js index d3e84ff5390996..ee539bbddb1367 100644 --- a/packages/edit-site/src/components/global-styles-renderer/index.js +++ b/packages/edit-site/src/components/global-styles-renderer/index.js @@ -3,15 +3,12 @@ */ import { useEffect } from '@wordpress/element'; import { useSelect, useDispatch } from '@wordpress/data'; -import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ import { store as editSiteStore } from '../../store'; -import { unlock } from '../../lock-unlock'; - -const { useGlobalStylesOutput } = unlock( blockEditorPrivateApis ); +import { useGlobalStylesOutput } from '../../hooks/use-global-styles-output'; function useGlobalStylesRenderer( disableRootPadding ) { const [ styles, settings ] = useGlobalStylesOutput( disableRootPadding ); diff --git a/packages/edit-site/src/components/revisions/index.js b/packages/edit-site/src/components/revisions/index.js index 5ce95872e0c9ae..528ec6c2647390 100644 --- a/packages/edit-site/src/components/revisions/index.js +++ b/packages/edit-site/src/components/revisions/index.js @@ -20,11 +20,11 @@ import { useContext, useMemo } from '@wordpress/element'; import { unlock } from '../../lock-unlock'; import EditorCanvasContainer from '../editor-canvas-container'; +import { useGlobalStylesOutputWithConfig } from '../../hooks/use-global-styles-output'; const { ExperimentalBlockEditorProvider, GlobalStylesContext, - useGlobalStylesOutputWithConfig, __unstableBlockStyleVariationOverridesWithConfig, } = unlock( blockEditorPrivateApis ); const { mergeBaseAndUserConfigs } = unlock( editorPrivateApis ); diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js index c7102d24f3f0b3..1c85422a89ac71 100644 --- a/packages/edit-site/src/components/style-book/index.js +++ b/packages/edit-site/src/components/style-book/index.js @@ -56,13 +56,10 @@ import { STYLE_BOOK_COLOR_GROUPS, STYLE_BOOK_PREVIEW_CATEGORIES, } from '../style-book/constants'; +import { useGlobalStylesOutputWithConfig } from '../../hooks/use-global-styles-output'; -const { - ExperimentalBlockEditorProvider, - useGlobalStyle, - GlobalStylesContext, - useGlobalStylesOutputWithConfig, -} = unlock( blockEditorPrivateApis ); +const { ExperimentalBlockEditorProvider, useGlobalStyle, GlobalStylesContext } = + unlock( blockEditorPrivateApis ); const { mergeBaseAndUserConfigs } = unlock( editorPrivateApis ); const { Tabs } = unlock( componentsPrivateApis ); diff --git a/packages/edit-site/src/hooks/use-global-styles-output.js b/packages/edit-site/src/hooks/use-global-styles-output.js new file mode 100644 index 00000000000000..6033c9b4954b5a --- /dev/null +++ b/packages/edit-site/src/hooks/use-global-styles-output.js @@ -0,0 +1,80 @@ +/** + * WordPress dependencies + */ +import { getBlockTypes, store as blocksStore } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; +import { useContext, useMemo } from '@wordpress/element'; +import { generateGlobalStyles } from '@wordpress/global-styles-engine'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../store'; +import { unlock } from '../lock-unlock'; + +const { GlobalStylesContext, useGlobalSetting } = unlock( + blockEditorPrivateApis +); + +/** + * Returns the global styles output based on the provided global styles config. + * + * @param {Object} mergedConfig The merged global styles config. + * @param {boolean} disableRootPadding Disable root padding styles. + * + * @return {Array} Array of stylesheets and settings. + */ +export function useGlobalStylesOutputWithConfig( + mergedConfig = {}, + disableRootPadding = false +) { + const [ blockGap ] = useGlobalSetting( 'spacing.blockGap' ); + const hasBlockGapSupport = blockGap !== null; + const hasFallbackGapSupport = ! hasBlockGapSupport; + + const { disableLayoutStyles, getBlockStyles } = useSelect( ( select ) => { + const { getSettings } = select( editSiteStore ); + const { getBlockStyles: getBlockStylesSelector } = + select( blocksStore ); + return { + disableLayoutStyles: !! getSettings()?.disableLayoutStyles, + getBlockStyles: getBlockStylesSelector, + }; + }, [] ); + + return useMemo( () => { + if ( ! mergedConfig?.styles || ! mergedConfig?.settings ) { + return [ [], {} ]; + } + + const blockTypes = getBlockTypes(); + + return generateGlobalStyles( mergedConfig, blockTypes, { + hasBlockGapSupport, + hasFallbackGapSupport, + disableLayoutStyles, + disableRootPadding, + getBlockStyles, + } ); + }, [ + hasBlockGapSupport, + hasFallbackGapSupport, + mergedConfig, + disableLayoutStyles, + disableRootPadding, + getBlockStyles, + ] ); +} + +/** + * Returns the global styles output based on the current state of global styles config loaded in the editor context. + * + * @param {boolean} disableRootPadding Disable root padding styles. + * + * @return {Array} Array of stylesheets and settings. + */ +export function useGlobalStylesOutput( disableRootPadding = false ) { + const { merged: mergedConfig } = useContext( GlobalStylesContext ); + return useGlobalStylesOutputWithConfig( mergedConfig, disableRootPadding ); +} diff --git a/packages/edit-site/tsconfig.json b/packages/edit-site/tsconfig.json index d88842449f1de1..7337f59269286c 100644 --- a/packages/edit-site/tsconfig.json +++ b/packages/edit-site/tsconfig.json @@ -21,6 +21,7 @@ { "path": "../element" }, { "path": "../escape-html" }, { "path": "../fields" }, + { "path": "../global-styles-engine" }, { "path": "../hooks" }, { "path": "../html-entities" }, { "path": "../i18n" }, diff --git a/packages/global-styles-engine/README.md b/packages/global-styles-engine/README.md new file mode 100644 index 00000000000000..5d398daeaede66 --- /dev/null +++ b/packages/global-styles-engine/README.md @@ -0,0 +1,133 @@ +# Global Styles Engine + +A generic library for reading and writing global styles data structures (theme.json format). + +## Overview + +This is a framework-agnostic utility library that provides functions for manipulating global styles objects. It handles the complex nested structure of theme.json format, including block-specific styles and settings. + +## API + +### Reading Data + +#### `getStyle(globalStyles, path, blockName?, shouldDecodeEncode?)` + +Get a style value from the global styles object. + +```typescript +// Get global text color +const textColor = getStyle( globalStyles, 'color.text' ); + +// Get button background color +const buttonBg = getStyle( globalStyles, 'color.background', 'core/button' ); + +// Get raw value without CSS variable resolution +const rawValue = getStyle( globalStyles, 'color.text', undefined, false ); +``` + +#### `getSetting(globalStyles, path, blockName?)` + +Get a setting value with fallback hierarchy (block-specific → global). + +```typescript +// Get global color palette +const colors = getSetting( globalStyles, 'color.palette' ); + +// Get paragraph-specific font sizes +const fontSizes = getSetting( + globalStyles, + 'typography.fontSizes', + 'core/paragraph' +); +``` + +### Writing Data + +#### `setStyle(globalStyles, path, newValue, blockName?)` + +Immutably set a style value. Returns a new global styles object. + +```typescript +// Set global text color +const updated = setStyle( globalStyles, 'color.text', '#333333' ); + +// Set button background color +const updated = setStyle( + globalStyles, + 'color.background', + '#0073aa', + 'core/button' +); +``` + +#### `setSetting(globalStyles, path, newValue, blockName?)` + +Immutably set a setting value. Returns a new global styles object. + +```typescript +// Set global color palette +const updated = setSetting( globalStyles, 'color.palette', newPalette ); + +// Set block-specific settings +const updated = setSetting( + globalStyles, + 'typography.fontSizes', + sizes, + 'core/heading' +); +``` + +### Utility Functions + +#### `getPalettes(globalStyles)` + +Extract color palettes organized by origin (theme, custom, default). + +#### `generateGlobalStyles(globalStyles)` + +Generate CSS from global styles object. + +#### `mergeGlobalStyles(base, user)` + +Merge multiple global styles objects with proper precedence. + +## Data Structure + +The global styles object follows the theme.json schema: + +```typescript +{ + styles: { + color: { text: '#000', background: '#fff' }, + typography: { fontSize: '16px' }, + elements: { + button: { color: { background: '#blue' } } + }, + blocks: { + 'core/paragraph': { color: { text: '#333' } } + } + }, + settings: { + color: { palette: [...] }, + typography: { fontSizes: [...] }, + blocks: { + 'core/paragraph': { color: { text: true } } + } + } +} +``` + +## Features + +- **Immutable Updates**: All setter functions return new objects +- **Type Safety**: Full TypeScript support +- **CSS Variable Resolution**: Automatic resolution of CSS custom properties +- **Block-Specific Styles**: Support for block and element-specific styling +- **Fallback Hierarchy**: Smart fallback from block → global → default settings +- **Framework Agnostic**: No dependencies on React, WordPress, or other frameworks + +## Usage + +This library is designed to be used as the foundation for any global styles editing interface. It provides the core data manipulation functions while remaining completely generic and reusable. + +It is a prerequisite for use that blocks and their styles be globally registered, because block styles are fetched from the global blocks store. diff --git a/packages/global-styles-engine/package.json b/packages/global-styles-engine/package.json new file mode 100644 index 00000000000000..1d55124e9f8ea3 --- /dev/null +++ b/packages/global-styles-engine/package.json @@ -0,0 +1,49 @@ +{ + "name": "@wordpress/global-styles-engine", + "version": "1.0.0", + "description": "Pure CSS generation engine for WordPress global styles.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "global styles" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/patterns/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/global-styles-engine" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "exports": { + ".": { + "types": "./build-types/index.d.ts", + "import": "./build-module/index.js", + "require": "./build/index.js" + }, + "./package.json": "./package.json" + }, + "types": "build-types/index.d.ts", + "sideEffects": false, + "dependencies": { + "@wordpress/blocks": "file:../blocks", + "@wordpress/data": "file:../data", + "@wordpress/i18n": "file:../i18n", + "@wordpress/style-engine": "file:../style-engine", + "colord": "^2.9.2", + "deepmerge": "^4.3.0", + "is-plain-object": "^5.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/global-styles-engine/src/core/merge.ts b/packages/global-styles-engine/src/core/merge.ts new file mode 100644 index 00000000000000..d8267c65d90d0c --- /dev/null +++ b/packages/global-styles-engine/src/core/merge.ts @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import deepmerge from 'deepmerge'; +// @ts-ignore - is-plain-object doesn't have proper types +import { isPlainObject } from 'is-plain-object'; + +/** + * Internal dependencies + */ +import type { GlobalStylesConfig } from '../types'; + +/** + * Merges base and user global styles configurations + * + * @param base Base global styles (theme + WordPress defaults) + * @param user User customizations + * @return Merged global styles configuration + */ +export function mergeGlobalStyles( + base: GlobalStylesConfig, + user: GlobalStylesConfig +): GlobalStylesConfig { + return deepmerge( base, user, { + /* + * We only pass as arrays the presets, + * in which case we want the new array of values + * to override the old array (no merging). + */ + isMergeableObject: isPlainObject, + /* + * Exceptions to the above rule. + * Background images should be replaced, not merged, + * as they themselves are specific object definitions for the style. + */ + customMerge: ( key ) => { + if ( key === 'backgroundImage' ) { + return ( baseConfig, userConfig ) => userConfig ?? baseConfig; + } + return undefined; + }, + } ); +} diff --git a/packages/block-editor/src/components/global-styles/use-global-styles-output.js b/packages/global-styles-engine/src/core/render.tsx similarity index 66% rename from packages/block-editor/src/components/global-styles/use-global-styles-output.js rename to packages/global-styles-engine/src/core/render.tsx index 2d0a7e46ebb2dd..b4811d691cbd0a 100644 --- a/packages/block-editor/src/components/global-styles/use-global-styles-output.js +++ b/packages/global-styles-engine/src/core/render.tsx @@ -7,11 +7,10 @@ import { getBlockSupport, getBlockTypes, store as blocksStore, + // @ts-expect-error - @wordpress/blocks module doesn't have TypeScript declarations } from '@wordpress/blocks'; -import { useSelect } from '@wordpress/data'; -import { useContext, useMemo } from '@wordpress/element'; import { getCSSRules, getCSSValueFromRawStyle } from '@wordpress/style-engine'; -import { privateApis as componentsPrivateApis } from '@wordpress/components'; +import { select } from '@wordpress/data'; /** * Internal dependencies @@ -25,18 +24,125 @@ import { appendToSelector, getBlockStyleVariationSelector, getResolvedValue, -} from './utils'; -import { getBlockCSSSelector } from './get-block-css-selector'; -import { getTypographyFontSizeValue } from './typography-utils'; -import { GlobalStylesContext } from './context'; -import { useGlobalSetting } from './hooks'; -import { getDuotoneFilter } from '../duotone/utils'; -import { getGapCSSValue } from '../../hooks/gap'; -import { setBackgroundStyleDefaults } from '../../hooks/background'; -import { store as blockEditorStore } from '../../store'; -import { LAYOUT_DEFINITIONS } from '../../layouts/definitions'; -import { getValueFromObjectPath, setImmutably } from '../../utils/object'; -import { unlock } from '../../lock-unlock'; +} from '../utils/common'; +import { getBlockSelector } from './selectors'; +import { getTypographyFontSizeValue } from '../utils/typography'; +import { getDuotoneFilter } from '../utils/duotone'; +import { kebabCase } from '../utils/string'; +import { getGapCSSValue } from '../utils/gap'; +import { setBackgroundStyleDefaults } from '../utils/background'; +import { LAYOUT_DEFINITIONS } from '../utils/layout'; +import { getValueFromObjectPath, setImmutably } from '../utils/object'; +import { getSetting } from '../settings/get-setting'; +import type { + BlockStyleVariation, + BlockType, + GlobalStylesConfig, + GlobalStylesSettings, + GlobalStylesStyles, +} from '../types'; + +// ============================================================================= +// LOCAL TYPE DEFINITIONS +// ============================================================================= + +/** + * Preset metadata for CSS variable generation + */ +interface PresetMetadata { + path: string[]; + valueKey?: string; + valueFunc?: ( preset: any, settings: any ) => string | number | null; + cssVarInfix: string; + classes?: Array< { + classSuffix: string; + propertyName: string; + } >; +} + +/** + * Preset collection by origin + */ +interface PresetsByOrigin { + [ origin: string ]: any[]; +} + +/** + * CSS class configuration + */ +interface CSSClassConfig { + classSuffix: string; + propertyName: string; +} + +/** + * Style property configuration from WordPress + */ +interface StylePropertyConfig { + value: string[]; + properties?: Record< string, string >; + useEngine?: boolean; + rootOnly?: boolean; +} + +/** + * Layout definition structure + */ +interface LayoutDefinition { + className: string; + name: string; + displayMode?: string; + spacingStyles?: Array< { + selector?: string; + rules?: Record< string, any >; + } >; + baseStyles?: Array< { + selector?: string; + rules?: Record< string, any >; + } >; +} + +/** + * CSS rule from style engine + */ +interface CSSRule { + key: string; + value: any; +} + +/** + * Block variation in theme.json (different from BlockStyleVariation) + */ +interface BlockVariation { + css?: string; + elements?: Record< string, any >; + blocks?: Record< string, any >; + [ key: string ]: any; // For additional style properties +} + +/** + * Block node in theme.json + */ +interface BlockNode { + variations?: Record< string, BlockVariation >; + elements?: Record< string, any >; + [ key: string ]: any; // For additional style properties +} + +export type BlockSelectors = Record< + string, + { + duotoneSelector?: string; + selector: string; + fallbackGapValue?: string; + hasLayoutSupport?: boolean; + featureSelectors?: + | string + | Record< string, string | Record< string, string > >; + name?: string; + styleVariationSelectors?: Record< string, string >; + } +>; // Elements that rely on class names in their selectors. const ELEMENT_CLASS_NAMES = { @@ -52,27 +158,31 @@ const BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS = { spacing: 'spacing', typography: 'typography', }; -const { kebabCase } = unlock( componentsPrivateApis ); /** * Transform given preset tree into a set of style declarations. * - * @param {Object} blockPresets - * @param {Object} mergedSettings Merged theme.json settings. - * - * @return {Array} An array of style declarations. + * @param blockPresets Block presets object + * @param mergedSettings Merged theme.json settings + * @return An array of style declarations */ -function getPresetsDeclarations( blockPresets = {}, mergedSettings ) { +function getPresetsDeclarations( + blockPresets: Record< string, any > = {}, + mergedSettings: GlobalStylesSettings +): string[] { return PRESET_METADATA.reduce( - ( declarations, { path, valueKey, valueFunc, cssVarInfix } ) => { + ( + declarations: string[], + { path, valueKey, valueFunc, cssVarInfix }: PresetMetadata + ) => { const presetByOrigin = getValueFromObjectPath( blockPresets, path, [] - ); + ) as PresetsByOrigin; [ 'default', 'theme', 'custom' ].forEach( ( origin ) => { if ( presetByOrigin[ origin ] ) { - presetByOrigin[ origin ].forEach( ( value ) => { + presetByOrigin[ origin ].forEach( ( value: any ) => { if ( valueKey && ! valueFunc ) { declarations.push( `--wp--preset--${ cssVarInfix }--${ kebabCase( @@ -95,20 +205,26 @@ function getPresetsDeclarations( blockPresets = {}, mergedSettings ) { return declarations; }, - [] + [] as string[] ); } /** * Transform given preset tree into a set of preset class declarations. * - * @param {?string} blockSelector - * @param {Object} blockPresets - * @return {string} CSS declarations for the preset classes. + * @param blockSelector Block selector string + * @param blockPresets Block presets object + * @return CSS declarations for the preset classes */ -function getPresetsClasses( blockSelector = '*', blockPresets = {} ) { +function getPresetsClasses( + blockSelector: string = '*', + blockPresets: Record< string, any > = {} +): string { return PRESET_METADATA.reduce( - ( declarations, { path, cssVarInfix, classes } ) => { + ( + declarations: string, + { path, cssVarInfix, classes }: PresetMetadata + ) => { if ( ! classes ) { return declarations; } @@ -117,27 +233,34 @@ function getPresetsClasses( blockSelector = '*', blockPresets = {} ) { blockPresets, path, [] - ); + ) as PresetsByOrigin; [ 'default', 'theme', 'custom' ].forEach( ( origin ) => { if ( presetByOrigin[ origin ] ) { - presetByOrigin[ origin ].forEach( ( { slug } ) => { - classes.forEach( ( { classSuffix, propertyName } ) => { - const classSelectorToUse = `.has-${ kebabCase( - slug - ) }-${ classSuffix }`; - const selectorToUse = blockSelector - .split( ',' ) // Selector can be "h1, h2, h3" - .map( - ( selector ) => - `${ selector }${ classSelectorToUse }` - ) - .join( ',' ); - const value = `var(--wp--preset--${ cssVarInfix }--${ kebabCase( - slug - ) })`; - declarations += `${ selectorToUse }{${ propertyName }: ${ value } !important;}`; - } ); - } ); + presetByOrigin[ origin ].forEach( + ( { slug }: { slug: string } ) => { + classes!.forEach( + ( { + classSuffix, + propertyName, + }: CSSClassConfig ) => { + const classSelectorToUse = `.has-${ kebabCase( + slug + ) }-${ classSuffix }`; + const selectorToUse = blockSelector + .split( ',' ) // Selector can be "h1, h2, h3" + .map( + ( selector ) => + `${ selector }${ classSelectorToUse }` + ) + .join( ',' ); + const value = `var(--wp--preset--${ cssVarInfix }--${ kebabCase( + slug + ) })`; + declarations += `${ selectorToUse }{${ propertyName }: ${ value } !important;}`; + } + ); + } + ); } } ); return declarations; @@ -146,20 +269,22 @@ function getPresetsClasses( blockSelector = '*', blockPresets = {} ) { ); } -function getPresetsSvgFilters( blockPresets = {} ) { +function getPresetsSvgFilters( + blockPresets: Record< string, any > = {} +): string[] { return PRESET_METADATA.filter( // Duotone are the only type of filters for now. - ( metadata ) => metadata.path.at( -1 ) === 'duotone' - ).flatMap( ( metadata ) => { + ( metadata: PresetMetadata ) => metadata.path.at( -1 ) === 'duotone' + ).flatMap( ( metadata: PresetMetadata ) => { const presetByOrigin = getValueFromObjectPath( blockPresets, metadata.path, {} - ); + ) as PresetsByOrigin; return [ 'default', 'theme' ] .filter( ( origin ) => presetByOrigin[ origin ] ) .flatMap( ( origin ) => - presetByOrigin[ origin ].map( ( preset ) => + presetByOrigin[ origin ].map( ( preset: any ) => getDuotoneFilter( `wp-duotone-${ preset.slug }`, preset.colors @@ -170,8 +295,12 @@ function getPresetsSvgFilters( blockPresets = {} ) { } ); } -function flattenTree( input = {}, prefix, token ) { - let result = []; +function flattenTree( + input: any = {}, + prefix: string, + token: string +): string[] { + let result: string[] = []; Object.keys( input ).forEach( ( key ) => { const newKey = prefix + kebabCase( key.replace( '/', '-' ) ); const newLeaf = input[ key ]; @@ -189,17 +318,16 @@ function flattenTree( input = {}, prefix, token ) { /** * Gets variation selector string from feature selector. * - * @param {string} featureSelector The feature selector. - * - * @param {string} styleVariationSelector The style variation selector. - * @return {string} Combined selector string. + * @param featureSelector The feature selector + * @param styleVariationSelector The style variation selector + * @return Combined selector string */ function concatFeatureVariationSelectorString( - featureSelector, - styleVariationSelector -) { + featureSelector: string, + styleVariationSelector: string +): string { const featureSelectors = featureSelector.split( ',' ); - const combinedSelectors = []; + const combinedSelectors: string[] = []; featureSelectors.forEach( ( selector ) => { combinedSelectors.push( `${ styleVariationSelector.trim() }${ selector.trim() }` @@ -214,13 +342,15 @@ function concatFeatureVariationSelectorString( * * NOTE: The passed `styles` object will be mutated by this function. * - * @param {Object} selectors Custom selectors object for a block. - * @param {Object} styles A block's styles object. - * - * @return {Object} Style declarations. + * @param selectors Custom selectors object for a block + * @param styles A block's styles object + * @return Style declarations */ -const getFeatureDeclarations = ( selectors, styles ) => { - const declarations = {}; +const getFeatureDeclarations = ( + selectors: Record< string, any >, + styles: Record< string, any > +): Record< string, string[] > => { + const declarations: Record< string, string[] > = {}; Object.entries( selectors ).forEach( ( [ feature, selector ] ) => { // We're only processing features/subfeatures that have styles. @@ -231,8 +361,12 @@ const getFeatureDeclarations = ( selectors, styles ) => { const isShorthand = typeof selector === 'string'; // If we have a selector object instead of shorthand process it. - if ( ! isShorthand ) { - Object.entries( selector ).forEach( + if ( + ! isShorthand && + typeof selector === 'object' && + selector !== null + ) { + Object.entries( selector as Record< string, string > ).forEach( ( [ subfeature, subfeatureSelector ] ) => { // Don't process root feature selector yet or any // subfeature that doesn't have a style. @@ -269,8 +403,15 @@ const getFeatureDeclarations = ( selectors, styles ) => { // Now subfeatures have been processed and removed, we can // process root, or shorthand, feature selectors. - if ( isShorthand || selector.root ) { - const featureSelector = isShorthand ? selector : selector.root; + if ( + isShorthand || + ( typeof selector === 'object' && + selector !== null && + 'root' in selector ) + ) { + const featureSelector = isShorthand + ? ( selector as string ) + : ( selector as any ).root; // Create temporary style object and build declarations for feature. const featureStyles = { [ feature ]: styles[ feature ] }; @@ -294,29 +435,30 @@ const getFeatureDeclarations = ( selectors, styles ) => { /** * Transform given style tree into a set of style declarations. * - * @param {Object} blockStyles Block styles. - * - * @param {string} selector The selector these declarations should attach to. - * - * @param {boolean} useRootPaddingAlign Whether to use CSS custom properties in root selector. - * - * @param {Object} tree A theme.json tree containing layout definitions. - * - * @param {boolean} disableRootPadding Whether to force disable the root padding styles. - * @return {Array} An array of style declarations. + * @param blockStyles Block styles + * @param selector The selector these declarations should attach to + * @param useRootPaddingAlign Whether to use CSS custom properties in root selector + * @param tree A theme.json tree containing layout definitions + * @param disableRootPadding Whether to force disable the root padding styles + * @return An array of style declarations */ export function getStylesDeclarations( - blockStyles = {}, - selector = '', - useRootPaddingAlign, - tree = {}, - disableRootPadding = false -) { + blockStyles: any = {}, + selector: string = '', + useRootPaddingAlign?: boolean, + tree: any = {}, + disableRootPadding: boolean = false +): string[] { const isRoot = ROOT_BLOCK_SELECTOR === selector; - const output = Object.entries( STYLE_PROPERTY ).reduce( + const output = Object.entries( + STYLE_PROPERTY as Record< string, StylePropertyConfig > + ).reduce( ( - declarations, - [ key, { value, properties, useEngine, rootOnly } ] + declarations: string[], + [ key, { value, properties, useEngine, rootOnly } ]: [ + string, + StylePropertyConfig, + ] ) => { if ( rootOnly && ! isRoot ) { return declarations; @@ -376,7 +518,7 @@ export function getStylesDeclarations( return declarations; }, - [] + [] as string[] ); /* @@ -415,7 +557,7 @@ export function getStylesDeclarations( } const extraRules = getCSSRules( blockStyles ); - extraRules.forEach( ( rule ) => { + extraRules.forEach( ( rule: CSSRule ) => { // Don't output padding properties if padding variables are set or if we're not editing a full template. if ( isRoot && @@ -428,7 +570,7 @@ export function getStylesDeclarations( ? rule.key : kebabCase( rule.key ); - let ruleValue = getResolvedValue( rule.value, tree, null ); + let ruleValue = getResolvedValue( rule.value, tree ); // Calculate fluid typography rules where available. if ( cssProperty === 'font-size' ) { @@ -440,7 +582,7 @@ export function getStylesDeclarations( * and therefore the original $value will be returned. */ ruleValue = getTypographyFontSizeValue( - { size: ruleValue }, + { name: '', slug: '', size: ruleValue as string }, tree?.settings ); } @@ -461,14 +603,15 @@ export function getStylesDeclarations( * Get generated CSS for layout styles by looking up layout definitions provided * in theme.json, and outputting common layout styles, and specific blockGap values. * - * @param {Object} props - * @param {Object} props.layoutDefinitions Layout definitions, keyed by layout type. - * @param {Object} props.style A style object containing spacing values. - * @param {string} props.selector Selector used to group together layout styling rules. - * @param {boolean} props.hasBlockGapSupport Whether or not the theme opts-in to blockGap support. - * @param {boolean} props.hasFallbackGapSupport Whether or not the theme allows fallback gap styles. - * @param {?string} props.fallbackGapValue An optional fallback gap value if no real gap value is available. - * @return {string} Generated CSS rules for the layout styles. + * @param props Layout styles configuration + * @param props.layoutDefinitions Layout definitions from theme.json + * @param props.style Style object for the block + * @param props.selector Selector to apply the styles to + * @param props.hasBlockGapSupport Whether the block supports block gap styles + * @param props.hasFallbackGapSupport Whether the block supports fallback gap styles + * @param props.fallbackGapValue Fallback gap value to use if block gap support is + * + * @return Generated CSS rules for the layout styles */ export function getLayoutStyles( { layoutDefinitions = LAYOUT_DEFINITIONS, @@ -477,7 +620,14 @@ export function getLayoutStyles( { hasBlockGapSupport, hasFallbackGapSupport, fallbackGapValue, -} ) { +}: { + layoutDefinitions?: Record< string, LayoutDefinition >; + style?: GlobalStylesStyles; + selector?: string; + hasBlockGapSupport?: boolean; + hasFallbackGapSupport?: boolean; + fallbackGapValue?: string; +} ): string { let ruleset = ''; let gapValue = hasBlockGapSupport ? getGapCSSValue( style?.spacing?.blockGap ) @@ -506,8 +656,8 @@ export function getLayoutStyles( { } if ( spacingStyles?.length ) { - spacingStyles.forEach( ( spacingStyle ) => { - const declarations = []; + spacingStyles.forEach( ( spacingStyle: any ) => { + const declarations: string[] = []; if ( spacingStyle.rules ) { Object.entries( spacingStyle.rules ).forEach( @@ -562,7 +712,7 @@ export function getLayoutStyles( { if ( selector === ROOT_BLOCK_SELECTOR && layoutDefinitions ) { const validDisplayModes = [ 'block', 'flex', 'grid' ]; Object.values( layoutDefinitions ).forEach( - ( { className, displayMode, baseStyles } ) => { + ( { className, displayMode, baseStyles }: LayoutDefinition ) => { if ( displayMode && validDisplayModes.includes( displayMode ) @@ -571,8 +721,8 @@ export function getLayoutStyles( { } if ( baseStyles?.length ) { - baseStyles.forEach( ( baseStyle ) => { - const declarations = []; + baseStyles.forEach( ( baseStyle: any ) => { + const declarations: string[] = []; if ( baseStyle.rules ) { Object.entries( baseStyle.rules ).forEach( @@ -613,7 +763,7 @@ const STYLE_KEYS = [ 'background', ]; -function pickStyleKeys( treeToPickFrom ) { +function pickStyleKeys( treeToPickFrom: any ): any { if ( ! treeToPickFrom ) { return {}; } @@ -629,8 +779,22 @@ function pickStyleKeys( treeToPickFrom ) { return Object.fromEntries( clonedEntries ); } -export const getNodesWithStyles = ( tree, blockSelectors ) => { - const nodes = []; +export const getNodesWithStyles = ( + tree: GlobalStylesConfig, + blockSelectors: string | BlockSelectors +): any[] => { + const nodes: { + styles: Partial< Omit< GlobalStylesStyles, 'elements' | 'blocks' > >; + selector: string; + skipSelectorWrapper?: boolean; + duotoneSelector?: string; + featureSelectors?: + | string + | Record< string, string | Record< string, string > >; + fallbackGapValue?: string; + hasLayoutSupport?: boolean; + styleVariationSelectors?: Record< string, string >; + }[] = []; if ( ! tree?.styles ) { return nodes; @@ -651,11 +815,13 @@ export const getNodesWithStyles = ( tree, blockSelectors ) => { Object.entries( ELEMENTS ).forEach( ( [ name, selector ] ) => { if ( tree.styles?.elements?.[ name ] ) { nodes.push( { - styles: tree.styles?.elements?.[ name ], - selector, + styles: tree.styles?.elements?.[ name ] ?? {}, + selector: selector as string, // Top level elements that don't use a class name should not receive the // `:root :where()` wrapper to maintain backwards compatibility. - skipSelectorWrapper: ! ELEMENT_CLASS_NAMES[ name ], + skipSelectorWrapper: ! ( + ELEMENT_CLASS_NAMES as Record< string, string > + )[ name ], } ); } } ); @@ -664,60 +830,78 @@ export const getNodesWithStyles = ( tree, blockSelectors ) => { Object.entries( tree.styles?.blocks ?? {} ).forEach( ( [ blockName, node ] ) => { const blockStyles = pickStyleKeys( node ); + const typedNode = node as BlockNode; - if ( node?.variations ) { - const variations = {}; - Object.entries( node.variations ).forEach( + if ( typedNode?.variations ) { + const variations: Record< string, any > = {}; + Object.entries( typedNode.variations ).forEach( ( [ variationName, variation ] ) => { + const typedVariation = variation as BlockVariation; variations[ variationName ] = - pickStyleKeys( variation ); - if ( variation?.css ) { - variations[ variationName ].css = variation.css; + pickStyleKeys( typedVariation ); + if ( typedVariation?.css ) { + variations[ variationName ].css = + typedVariation.css; } const variationSelector = - blockSelectors[ blockName ] - ?.styleVariationSelectors?.[ variationName ]; + typeof blockSelectors !== 'string' + ? blockSelectors[ blockName ] + ?.styleVariationSelectors?.[ + variationName + ] + : undefined; // Process the variation's inner element styles. // This comes before the inner block styles so the // element styles within the block type styles take // precedence over these. - Object.entries( variation?.elements ?? {} ).forEach( - ( [ element, elementStyles ] ) => { - if ( elementStyles && ELEMENTS[ element ] ) { - nodes.push( { - styles: elementStyles, - selector: scopeSelector( - variationSelector, - ELEMENTS[ element ] - ), - } ); - } + Object.entries( + typedVariation?.elements ?? {} + ).forEach( ( [ element, elementStyles ] ) => { + if ( elementStyles && ELEMENTS[ element ] ) { + nodes.push( { + styles: elementStyles, + selector: scopeSelector( + variationSelector, + ELEMENTS[ element ] + ), + } ); } - ); + } ); // Process the variations inner block type styles. - Object.entries( variation?.blocks ?? {} ).forEach( + Object.entries( typedVariation?.blocks ?? {} ).forEach( ( [ variationBlockName, variationBlockStyles, ] ) => { - const variationBlockSelector = scopeSelector( - variationSelector, - blockSelectors[ variationBlockName ] - ?.selector - ); - const variationDuotoneSelector = scopeSelector( - variationSelector, - blockSelectors[ variationBlockName ] - ?.duotoneSelector - ); + const variationBlockSelector = + typeof blockSelectors !== 'string' + ? scopeSelector( + variationSelector, + blockSelectors[ + variationBlockName + ]?.selector + ) + : undefined; + const variationDuotoneSelector = + typeof blockSelectors !== 'string' + ? scopeSelector( + variationSelector, + blockSelectors[ + variationBlockName + ]?.duotoneSelector as string + ) + : undefined; const variationFeatureSelectors = - scopeFeatureSelectors( - variationSelector, - blockSelectors[ variationBlockName ] - ?.featureSelectors - ); + typeof blockSelectors !== 'string' + ? scopeFeatureSelectors( + variationSelector, + blockSelectors[ + variationBlockName + ]?.featureSelectors ?? {} + ) + : undefined; const variationBlockStyleNodes = pickStyleKeys( variationBlockStyles ); @@ -727,6 +911,13 @@ export const getNodesWithStyles = ( tree, blockSelectors ) => { variationBlockStyles.css; } + if ( + ! variationBlockSelector || + typeof blockSelectors === 'string' + ) { + return; + } + nodes.push( { selector: variationBlockSelector, duotoneSelector: variationDuotoneSelector, @@ -772,7 +963,10 @@ export const getNodesWithStyles = ( tree, blockSelectors ) => { blockStyles.variations = variations; } - if ( blockSelectors?.[ blockName ]?.selector ) { + if ( + typeof blockSelectors !== 'string' && + blockSelectors?.[ blockName ]?.selector + ) { nodes.push( { duotoneSelector: blockSelectors[ blockName ].duotoneSelector, @@ -789,9 +983,10 @@ export const getNodesWithStyles = ( tree, blockSelectors ) => { } ); } - Object.entries( node?.elements ?? {} ).forEach( + Object.entries( typedNode?.elements ?? {} ).forEach( ( [ elementName, value ] ) => { if ( + typeof blockSelectors !== 'string' && value && blockSelectors?.[ blockName ] && ELEMENTS[ elementName ] @@ -800,11 +995,11 @@ export const getNodesWithStyles = ( tree, blockSelectors ) => { styles: value, selector: blockSelectors[ blockName ]?.selector .split( ',' ) - .map( ( sel ) => { + .map( ( sel: string ) => { const elementSelectors = ELEMENTS[ elementName ].split( ',' ); return elementSelectors.map( - ( elementSelector ) => + ( elementSelector: string ) => sel + ' ' + elementSelector ); } ) @@ -819,14 +1014,26 @@ export const getNodesWithStyles = ( tree, blockSelectors ) => { return nodes; }; -export const getNodesWithSettings = ( tree, blockSelectors ) => { - const nodes = []; +export const getNodesWithSettings = ( + tree: GlobalStylesConfig, + blockSelectors: string | BlockSelectors +): any[] => { + const nodes: { + presets: Record< string, any >; + custom?: Record< string, any >; + selector?: string; + duotoneSelector?: string; + fallbackGapValue?: string; + hasLayoutSupport?: boolean; + featureSelectors?: Record< string, string >; + styleVariationSelectors?: Record< string, string >; + }[] = []; if ( ! tree?.settings ) { return nodes; } - const pickPresets = ( treeToPickFrom ) => { + const pickPresets = ( treeToPickFrom: any ): any => { let presets = {}; PRESET_METADATA.forEach( ( { path } ) => { const value = getValueFromObjectPath( treeToPickFrom, path, false ); @@ -851,8 +1058,14 @@ export const getNodesWithSettings = ( tree, blockSelectors ) => { // Blocks. Object.entries( tree.settings?.blocks ?? {} ).forEach( ( [ blockName, node ] ) => { - const blockPresets = pickPresets( node ); const blockCustom = node.custom; + if ( + typeof blockSelectors === 'string' || + ! blockSelectors[ blockName ] + ) { + return; + } + const blockPresets = pickPresets( node ); if ( Object.keys( blockPresets ).length > 0 || blockCustom ) { nodes.push( { presets: blockPresets, @@ -866,11 +1079,16 @@ export const getNodesWithSettings = ( tree, blockSelectors ) => { return nodes; }; -export const toCustomProperties = ( tree, blockSelectors ) => { +export const generateCustomProperties = ( + tree: GlobalStylesConfig, + blockSelectors: BlockSelectors +): string => { const settings = getNodesWithSettings( tree, blockSelectors ); let ruleset = ''; settings.forEach( ( { presets, custom, selector } ) => { - const declarations = getPresetsDeclarations( presets, tree?.settings ); + const declarations = tree?.settings + ? getPresetsDeclarations( presets, tree?.settings ) + : []; const customProps = flattenTree( custom, '--wp--custom--', '--' ); if ( customProps.length > 0 ) { declarations.push( ...customProps ); @@ -884,15 +1102,15 @@ export const toCustomProperties = ( tree, blockSelectors ) => { return ruleset; }; -export const toStyles = ( - tree, - blockSelectors, - hasBlockGapSupport, - hasFallbackGapSupport, - disableLayoutStyles = false, - disableRootPadding = false, - styleOptions = undefined -) => { +export const transformToStyles = ( + tree: GlobalStylesConfig, + blockSelectors: string | BlockSelectors, + hasBlockGapSupport?: boolean, + hasFallbackGapSupport?: boolean, + disableLayoutStyles: boolean = false, + disableRootPadding: boolean = false, + styleOptions: Record< string, boolean > = {} +): string => { // These allow opting out of certain sets of styles. const options = { blockGap: true, @@ -984,7 +1202,7 @@ export const toStyles = ( // Process duotone styles. if ( duotoneSelector ) { - const duotoneStyles = {}; + const duotoneStyles: any = {}; if ( styles?.filter ) { duotoneStyles.filter = styles.filter; delete styles.filter; @@ -1052,12 +1270,15 @@ export const toStyles = ( Object.entries( featureDeclarations ).forEach( - ( [ baseSelector, declarations ] ) => { + ( [ baseSelector, declarations ]: [ + string, + string[], + ] ) => { if ( declarations.length ) { const cssSelector = concatFeatureVariationSelectorString( baseSelector, - styleVariationSelector + styleVariationSelector as string ); const rules = declarations.join( ';' ); @@ -1071,7 +1292,7 @@ export const toStyles = ( const styleVariationDeclarations = getStylesDeclarations( styleVariations, - styleVariationSelector, + styleVariationSelector as string, useRootPaddingAlign, tree ); @@ -1115,7 +1336,7 @@ export const toStyles = ( // the proper rules to target the elements. const _selector = selector .split( ',' ) - .map( ( sel ) => sel + pseudoKey ) + .map( ( sel: string ) => sel + pseudoKey ) .join( ',' ); // As pseudo classes such as :hover, :focus etc. have class-level @@ -1182,14 +1403,17 @@ export const toStyles = ( return ruleset; }; -export function toSvgFilters( tree, blockSelectors ) { +export function generateSvgFilters( + tree: GlobalStylesConfig, + blockSelectors: BlockSelectors +): string[] { const nodesWithSettings = getNodesWithSettings( tree, blockSelectors ); return nodesWithSettings.flatMap( ( { presets } ) => { return getPresetsSvgFilters( presets ); } ); } -const getSelectorsConfig = ( blockType, rootSelector ) => { +const getSelectorsConfig = ( blockType: BlockType, rootSelector: string ) => { if ( blockType?.selectors && Object.keys( blockType.selectors ).length > 0 @@ -1197,13 +1421,12 @@ const getSelectorsConfig = ( blockType, rootSelector ) => { return blockType.selectors; } - const config = { root: rootSelector }; + const config: Record< string, string > = { + root: rootSelector, + }; Object.entries( BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS ).forEach( ( [ featureKey, featureName ] ) => { - const featureSelector = getBlockCSSSelector( - blockType, - featureKey - ); + const featureSelector = getBlockSelector( blockType, featureKey ); if ( featureSelector ) { config[ featureName ] = featureSelector; @@ -1215,40 +1438,43 @@ const getSelectorsConfig = ( blockType, rootSelector ) => { }; export const getBlockSelectors = ( - blockTypes, - getBlockStyles, - variationInstanceId + blockTypes: BlockType[], + variationInstanceId?: string ) => { - const result = {}; + const { getBlockStyles } = select( blocksStore ); + const result: BlockSelectors = {}; blockTypes.forEach( ( blockType ) => { const name = blockType.name; - const selector = getBlockCSSSelector( blockType ); - let duotoneSelector = getBlockCSSSelector( - blockType, - 'filter.duotone' - ); + const selector = getBlockSelector( blockType ); + if ( ! selector ) { + return; // Skip blocks without valid selectors + } + let duotoneSelector = getBlockSelector( blockType, 'filter.duotone' ); // Keep backwards compatibility for support.color.__experimentalDuotone. if ( ! duotoneSelector ) { - const rootSelector = getBlockCSSSelector( blockType ); + const rootSelector = getBlockSelector( blockType ); const duotoneSupport = getBlockSupport( blockType, 'color.__experimentalDuotone', false ); duotoneSelector = - duotoneSupport && scopeSelector( rootSelector, duotoneSupport ); + duotoneSupport && + rootSelector && + scopeSelector( rootSelector, duotoneSupport ); } const hasLayoutSupport = !! blockType?.supports?.layout || !! blockType?.supports?.__experimentalLayout; const fallbackGapValue = + // @ts-expect-error blockType?.supports?.spacing?.blockGap?.__experimentalDefault; const blockStyleVariations = getBlockStyles( name ); - const styleVariationSelectors = {}; - blockStyleVariations?.forEach( ( variation ) => { + const styleVariationSelectors: Record< string, string > = {}; + blockStyleVariations?.forEach( ( variation: BlockStyleVariation ) => { const variationSuffix = variationInstanceId ? `-${ variationInstanceId }` : ''; @@ -1265,7 +1491,7 @@ export const getBlockSelectors = ( const featureSelectors = getSelectorsConfig( blockType, selector ); result[ name ] = { - duotoneSelector, + duotoneSelector: duotoneSelector ?? undefined, fallbackGapValue, featureSelectors: Object.keys( featureSelectors ).length ? featureSelectors @@ -1286,28 +1512,31 @@ export const getBlockSelectors = ( * If there is a separator block whose color is defined in theme.json via background, * update the separator color to the same value by using border color. * - * @param {Object} config Theme.json configuration file object. - * @return {Object} configTheme.json configuration file object updated. + * @param config Theme.json configuration file object + * @return Theme.json configuration file object updated */ -function updateConfigWithSeparator( config ) { +function updateConfigWithSeparator( + config: GlobalStylesConfig +): GlobalStylesConfig { + const blocks = config.styles?.blocks; + const separatorBlock = blocks?.[ 'core/separator' ]; const needsSeparatorStyleUpdate = - config.styles?.blocks?.[ 'core/separator' ] && - config.styles?.blocks?.[ 'core/separator' ].color?.background && - ! config.styles?.blocks?.[ 'core/separator' ].color?.text && - ! config.styles?.blocks?.[ 'core/separator' ].border?.color; + separatorBlock && + separatorBlock.color?.background && + ! separatorBlock.color?.text && + ! separatorBlock.border?.color; if ( needsSeparatorStyleUpdate ) { return { ...config, styles: { ...config.styles, blocks: { - ...config.styles.blocks, + ...blocks, 'core/separator': { - ...config.styles.blocks[ 'core/separator' ], + ...separatorBlock, color: { - ...config.styles.blocks[ 'core/separator' ].color, - text: config.styles?.blocks[ 'core/separator' ] - .color.background, + ...separatorBlock.color, + text: separatorBlock.color?.background, }, }, }, @@ -1317,7 +1546,7 @@ function updateConfigWithSeparator( config ) { return config; } -export function processCSSNesting( css, blockSelector ) { +export function processCSSNesting( css: string, blockSelector: string ) { let processedCSS = ''; if ( ! css || css.trim() === '' ) { @@ -1326,7 +1555,7 @@ export function processCSSNesting( css, blockSelector ) { // Split CSS nested rules. const parts = css.split( '&' ); - parts.forEach( ( part ) => { + parts.forEach( ( part: string ) => { if ( ! part || part.trim() === '' ) { return; } @@ -1374,114 +1603,94 @@ export function processCSSNesting( css, blockSelector ) { return processedCSS; } +export interface GlobalStylesRenderOptions { + hasBlockGapSupport?: boolean; + hasFallbackGapSupport?: boolean; + disableLayoutStyles?: boolean; + disableRootPadding?: boolean; + getBlockStyles?: ( blockName: string ) => any[]; +} + /** - * Returns the global styles output using a global styles configuration. - * If wishing to generate global styles and settings based on the - * global styles config loaded in the editor context, use `useGlobalStylesOutput()`. - * The use case for a custom config is to generate bespoke styles - * and settings for previews, or other out-of-editor experiences. - * - * @param {Object} mergedConfig Global styles configuration. - * @param {boolean} disableRootPadding Disable root padding styles. + * Returns the global styles output based on the current state of global styles config loaded in the editor context. * - * @return {Array} Array of stylesheets and settings. + * @param config Global styles configuration + * @param blockTypes Array of block types from WordPress blocks store + * @param options Options for rendering global styles + * @return Array of stylesheets and settings */ -export function useGlobalStylesOutputWithConfig( - mergedConfig = {}, - disableRootPadding -) { - const [ blockGap ] = useGlobalSetting( 'spacing.blockGap' ); - const hasBlockGapSupport = blockGap !== null; - const hasFallbackGapSupport = ! hasBlockGapSupport; // This setting isn't useful yet: it exists as a placeholder for a future explicit fallback styles support. - const disableLayoutStyles = useSelect( ( select ) => { - const { getSettings } = select( blockEditorStore ); - return !! getSettings().disableLayoutStyles; - } ); - - const { getBlockStyles } = useSelect( blocksStore ); - - return useMemo( () => { - if ( ! mergedConfig?.styles || ! mergedConfig?.settings ) { - return []; - } - const updatedConfig = updateConfigWithSeparator( mergedConfig ); - - const blockSelectors = getBlockSelectors( - getBlockTypes(), - getBlockStyles - ); - - const customProperties = toCustomProperties( - updatedConfig, - blockSelectors - ); - - const globalStyles = toStyles( - updatedConfig, - blockSelectors, - hasBlockGapSupport, - hasFallbackGapSupport, - disableLayoutStyles, - disableRootPadding - ); - const svgs = toSvgFilters( updatedConfig, blockSelectors ); - - const styles = [ - { - css: customProperties, - isGlobalStyles: true, - }, - { - css: globalStyles, - isGlobalStyles: true, - }, - // Load custom CSS in own stylesheet so that any invalid CSS entered in the input won't break all the global styles in the editor. - { - css: updatedConfig.styles.css ?? '', - isGlobalStyles: true, - }, - { - assets: svgs, - __unstableType: 'svg', - isGlobalStyles: true, - }, - ]; - - // Loop through the blocks to check if there are custom CSS values. - // If there are, get the block selector and push the selector together with - // the CSS value to the 'stylesheets' array. - getBlockTypes().forEach( ( blockType ) => { - if ( updatedConfig.styles.blocks[ blockType.name ]?.css ) { - const selector = blockSelectors[ blockType.name ].selector; - styles.push( { - css: processCSSNesting( - updatedConfig.styles.blocks[ blockType.name ]?.css, - selector - ), - isGlobalStyles: true, - } ); - } - } ); - - return [ styles, updatedConfig.settings ]; - }, [ +export function generateGlobalStyles( + config: GlobalStylesConfig | undefined = {}, + blockTypes: any[] = [], + options: GlobalStylesRenderOptions = {} +): [ any[], any ] { + const { + hasBlockGapSupport: hasBlockGapSupportOption, + hasFallbackGapSupport: hasFallbackGapSupportOption, + disableLayoutStyles = false, + disableRootPadding = false, + } = options; + + // Use provided block types or fall back to getBlockTypes() + const blocks = blockTypes.length > 0 ? blockTypes : getBlockTypes(); + + const blockGap = getSetting( config, 'spacing.blockGap' ); + const hasBlockGapSupport = hasBlockGapSupportOption ?? blockGap !== null; + const hasFallbackGapSupport = + hasFallbackGapSupportOption ?? ! hasBlockGapSupport; + + if ( ! config?.styles || ! config?.settings ) { + return [ [], {} ]; + } + const updatedConfig = updateConfigWithSeparator( config ); + const blockSelectors = getBlockSelectors( blocks ); + const customProperties = generateCustomProperties( + updatedConfig, + blockSelectors + ); + const globalStyles = transformToStyles( + updatedConfig, + blockSelectors, hasBlockGapSupport, hasFallbackGapSupport, - mergedConfig, disableLayoutStyles, - disableRootPadding, - getBlockStyles, - ] ); -} + disableRootPadding + ); + const svgs = generateSvgFilters( updatedConfig, blockSelectors ); + const styles = [ + { + css: customProperties, + isGlobalStyles: true, + }, + { + css: globalStyles, + isGlobalStyles: true, + }, + // Load custom CSS in own stylesheet so that any invalid CSS entered in the input won't break all the global styles in the editor. + { + css: updatedConfig?.styles?.css ?? '', + isGlobalStyles: true, + }, + { + assets: svgs, + __unstableType: 'svg', + isGlobalStyles: true, + }, + ]; + + // Loop through the blocks to check if there are custom CSS values. + // If there are, get the block selector and push the selector together with + // the CSS value to the 'stylesheets' array. + blocks.forEach( ( blockType: BlockType ) => { + const blockStyles = updatedConfig?.styles?.blocks?.[ blockType.name ]; + if ( blockStyles?.css ) { + const selector = blockSelectors[ blockType.name ].selector; + styles.push( { + css: processCSSNesting( blockStyles.css, selector ), + isGlobalStyles: true, + } ); + } + } ); -/** - * Returns the global styles output based on the current state of global styles config loaded in the editor context. - * - * @param {boolean} disableRootPadding Disable root padding styles. - * - * @return {Array} Array of stylesheets and settings. - */ -export function useGlobalStylesOutput( disableRootPadding = false ) { - const { merged: mergedConfig } = useContext( GlobalStylesContext ); - return useGlobalStylesOutputWithConfig( mergedConfig, disableRootPadding ); + return [ styles, updatedConfig.settings ]; } diff --git a/packages/block-editor/src/components/global-styles/get-block-css-selector.js b/packages/global-styles-engine/src/core/selectors.ts similarity index 69% rename from packages/block-editor/src/components/global-styles/get-block-css-selector.js rename to packages/global-styles-engine/src/core/selectors.ts index 6cb7cfaa3f47d3..7b4ecb5a063235 100644 --- a/packages/block-editor/src/components/global-styles/get-block-css-selector.js +++ b/packages/global-styles-engine/src/core/selectors.ts @@ -1,25 +1,26 @@ /** * Internal dependencies */ -import { scopeSelector } from './utils'; -import { getValueFromObjectPath } from '../../utils/object'; +import type { BlockType } from '../types'; +import { scopeSelector } from '../utils/common'; +import { getValueFromObjectPath } from '../utils/object'; /** * Determine the CSS selector for the block type and target provided, returning * it if available. * - * @param {import('@wordpress/blocks').Block} blockType The block's type. - * @param {string|string[]} target The desired selector's target e.g. `root`, delimited string, or array path. - * @param {Object} options Options object. - * @param {boolean} options.fallback Whether or not to fallback to broader selector. + * @param blockType The block's type. + * @param target The desired selector's target e.g. `root`, delimited string, or array path. + * @param options Options object. + * @param options.fallback Whether or not to fallback to broader selector. * - * @return {?string} The CSS selector or `null` if no selector available. + * @return The CSS selector or `null` if no selector available. */ -export function getBlockCSSSelector( - blockType, - target = 'root', - options = {} -) { +export function getBlockSelector( + blockType: BlockType, + target: string = 'root', + options: { fallback?: boolean } = {} +): string | null { if ( ! target ) { return null; } @@ -34,11 +35,11 @@ export function getBlockCSSSelector( // Calculated before returning as it can be used as a fallback for feature // selectors later on. - let rootSelector = null; + let rootSelector: string | null = null; if ( hasSelectors && selectors.root ) { // Use the selectors API if available. - rootSelector = selectors?.root; + rootSelector = selectors?.root as string; } else if ( supports?.__experimentalSelector ) { // Use the old experimental selector supports property if set. rootSelector = supports.__experimentalSelector; @@ -65,19 +66,25 @@ export function getBlockCSSSelector( if ( hasSelectors ) { // Get selector from either `feature.root` or shorthand path. const featureSelector = - getValueFromObjectPath( selectors, `${ path }.root`, null ) || - getValueFromObjectPath( selectors, path, null ); + ( getValueFromObjectPath( + selectors, + `${ path }.root`, + null + ) as string ) || + ( getValueFromObjectPath( selectors, path, null ) as string ); // Return feature selector if found or any available fallback. return featureSelector || fallbackSelector; } // Try getting old experimental supports selector value. - const featureSelector = getValueFromObjectPath( - supports, - `${ path }.__experimentalSelector`, - null - ); + const featureSelector = supports + ? ( getValueFromObjectPath( + supports, + `${ path }.__experimentalSelector`, + null + ) as string | undefined ) + : undefined; // If nothing to work with, provide fallback selector if available. if ( ! featureSelector ) { @@ -99,14 +106,14 @@ export function getBlockCSSSelector( // Only return if we have a subfeature selector. if ( subfeatureSelector ) { - return subfeatureSelector; + return subfeatureSelector as string; } // To this point we don't have a subfeature selector. If a fallback has been // requested, remove subfeature from target path and return results of a // call for the parent feature's selector. if ( fallback ) { - return getBlockCSSSelector( blockType, pathArray[ 0 ], options ); + return getBlockSelector( blockType, pathArray[ 0 ], options ); } // We tried. diff --git a/packages/global-styles-engine/src/index.ts b/packages/global-styles-engine/src/index.ts new file mode 100644 index 00000000000000..64f08165077893 --- /dev/null +++ b/packages/global-styles-engine/src/index.ts @@ -0,0 +1,29 @@ +// High-level Settings API +export { getSetting } from './settings/get-setting'; +export { setSetting } from './settings/set-setting'; +export { getStyle } from './settings/get-style'; +export { setStyle } from './settings/set-style'; +export { default as getPalettes } from './settings/get-palette'; + +// Merge utility +export { mergeGlobalStyles } from './core/merge'; + +// Core rendering +export { generateGlobalStyles } from './core/render'; +export { + transformToStyles as toStyles, + getBlockSelectors, + getLayoutStyles, +} from './core/render'; +export { getBlockSelector } from './core/selectors'; + +// Utilities (Ideally these shouldn't be exposed) +export { getTypographyFontSizeValue } from './utils/typography'; +export { + getValueFromVariable, + getPresetVariableFromValue, + getResolvedValue, +} from './utils/common'; + +// Types +export type * from './types'; diff --git a/packages/global-styles-engine/src/settings/get-palette.ts b/packages/global-styles-engine/src/settings/get-palette.ts new file mode 100644 index 00000000000000..dacda41a6c7a67 --- /dev/null +++ b/packages/global-styles-engine/src/settings/get-palette.ts @@ -0,0 +1,187 @@ +/** + * WordPress dependencies + */ +import { _x } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { getSetting } from './get-setting'; +import type { + Color, + Gradient, + Duotone, + MultiOriginPalettes, + GlobalStylesConfig, +} from '../types'; + +/** + * Retrieves color and gradient related settings. + * + * The arrays for colors and gradients are made up of color palettes from each + * origin i.e. "Core", "Theme", and "User". + * + * @param settings Global styles settings. + * + * @return Color and gradient related settings. + */ +export default function getPalettes( + settings?: GlobalStylesConfig +): MultiOriginPalettes { + if ( ! settings ) { + return { + disableCustomColors: true, + disableCustomGradients: true, + colors: [], + gradients: [], + duotones: [], + hasColorsOrGradients: false, + }; + } + + const [ + enableCustomColors, + customColors, + themeColors, + defaultColors, + shouldDisplayDefaultColors, + enableCustomGradients, + customGradients, + themeGradients, + defaultGradients, + shouldDisplayDefaultGradients, + shouldDisplayDefaultDuotones, + customDuotones, + themeDuotones, + defaultDuotones, + ] = [ + 'color.custom', + 'color.palette.custom', + 'color.palette.theme', + 'color.palette.default', + 'color.defaultPalette', + 'color.customGradient', + 'color.gradients.custom', + 'color.gradients.theme', + 'color.gradients.default', + 'color.defaultGradients', + 'color.defaultDuotone', + 'color.duotone.custom', + 'color.duotone.theme', + 'color.duotone.default', + ].map( ( path ) => getSetting( settings, path ) ); + + const palettes: MultiOriginPalettes = { + disableCustomColors: ! enableCustomColors, + disableCustomGradients: ! enableCustomGradients, + colors: [], + gradients: [], + duotones: [], + hasColorsOrGradients: false, + }; + + if ( themeColors && ( themeColors as Color[] ).length ) { + palettes.colors?.push( { + name: _x( 'Theme', 'Indicates this palette comes from the theme.' ), + slug: 'theme', + colors: themeColors as Color[], + } ); + } + if ( + shouldDisplayDefaultColors && + defaultColors && + ( defaultColors as Color[] ).length + ) { + palettes.colors?.push( { + name: _x( + 'Default', + 'Indicates this palette comes from WordPress.' + ), + slug: 'default', + colors: defaultColors as Color[], + } ); + } + if ( customColors && ( customColors as Color[] ).length ) { + palettes.colors?.push( { + name: _x( + 'Custom', + 'Indicates this palette is created by the user.' + ), + slug: 'custom', + colors: customColors as Color[], + } ); + } + + if ( themeGradients && ( themeGradients as Gradient[] ).length ) { + palettes.gradients?.push( { + name: _x( 'Theme', 'Indicates this palette comes from the theme.' ), + slug: 'theme', + gradients: themeGradients as Gradient[], + } ); + } + if ( + shouldDisplayDefaultGradients && + defaultGradients && + ( defaultGradients as Gradient[] ).length + ) { + palettes.gradients?.push( { + name: _x( + 'Default', + 'Indicates this palette comes from WordPress.' + ), + slug: 'default', + gradients: defaultGradients as Gradient[], + } ); + } + if ( customGradients && ( customGradients as Gradient[] ).length ) { + palettes.gradients?.push( { + name: _x( + 'Custom', + 'Indicates this palette is created by the user.' + ), + slug: 'custom', + gradients: customGradients as Gradient[], + } ); + } + + if ( themeDuotones && ( themeDuotones as Duotone[] ).length ) { + palettes.duotones?.push( { + name: _x( + 'Theme', + 'Indicates these duotone filters come from the theme.' + ), + slug: 'theme', + duotones: themeDuotones as Duotone[], + } ); + } + + if ( + shouldDisplayDefaultDuotones && + defaultDuotones && + ( defaultDuotones as Duotone[] ).length + ) { + palettes.duotones?.push( { + name: _x( + 'Default', + 'Indicates these duotone filters come from WordPress.' + ), + slug: 'default', + duotones: defaultDuotones as Duotone[], + } ); + } + if ( customDuotones && ( customDuotones as Duotone[] ).length ) { + palettes.duotones?.push( { + name: _x( + 'Custom', + 'Indicates these doutone filters are created by the user.' + ), + slug: 'custom', + duotones: customDuotones as Duotone[], + } ); + } + + palettes.hasColorsOrGradients = + !! palettes.colors?.length || !! palettes.gradients?.length; + + return palettes; +} diff --git a/packages/global-styles-engine/src/settings/get-setting.ts b/packages/global-styles-engine/src/settings/get-setting.ts new file mode 100644 index 00000000000000..e4c7889ad877f6 --- /dev/null +++ b/packages/global-styles-engine/src/settings/get-setting.ts @@ -0,0 +1,99 @@ +/** + * Internal dependencies + */ +import { getValueFromObjectPath, setImmutably } from '../utils/object'; +import type { GlobalStylesConfig } from '../types'; + +const VALID_SETTINGS = [ + 'appearanceTools', + 'useRootPaddingAwareAlignments', + 'background.backgroundImage', + 'background.backgroundRepeat', + 'background.backgroundSize', + 'background.backgroundPosition', + 'border.color', + 'border.radius', + 'border.radiusSizes', + 'border.style', + 'border.width', + 'shadow.presets', + 'shadow.defaultPresets', + 'color.background', + 'color.button', + 'color.caption', + 'color.custom', + 'color.customDuotone', + 'color.customGradient', + 'color.defaultDuotone', + 'color.defaultGradients', + 'color.defaultPalette', + 'color.duotone', + 'color.gradients', + 'color.heading', + 'color.link', + 'color.palette', + 'color.text', + 'custom', + 'dimensions.aspectRatio', + 'dimensions.minHeight', + 'layout.contentSize', + 'layout.definitions', + 'layout.wideSize', + 'lightbox.enabled', + 'lightbox.allowEditing', + 'position.fixed', + 'position.sticky', + 'spacing.customSpacingSize', + 'spacing.defaultSpacingSizes', + 'spacing.spacingSizes', + 'spacing.spacingScale', + 'spacing.blockGap', + 'spacing.margin', + 'spacing.padding', + 'spacing.units', + 'typography.fluid', + 'typography.customFontSize', + 'typography.defaultFontSizes', + 'typography.dropCap', + 'typography.fontFamilies', + 'typography.fontSizes', + 'typography.fontStyle', + 'typography.fontWeight', + 'typography.letterSpacing', + 'typography.lineHeight', + 'typography.textAlign', + 'typography.textColumns', + 'typography.textDecoration', + 'typography.textTransform', + 'typography.writingMode', +]; + +export function getSetting< T = any >( + globalStyles: GlobalStylesConfig, + path: string, + blockName?: string +): T { + const appendedBlockPath = blockName ? '.blocks.' + blockName : ''; + const appendedPropertyPath = path ? '.' + path : ''; + const contextualPath = `settings${ appendedBlockPath }${ appendedPropertyPath }`; + const globalPath = `settings${ appendedPropertyPath }`; + + if ( path ) { + return ( getValueFromObjectPath( globalStyles, contextualPath ) ?? + getValueFromObjectPath( globalStyles, globalPath ) ) as T; + } + + let result = {}; + VALID_SETTINGS.forEach( ( setting ) => { + const value = + getValueFromObjectPath( + globalStyles, + `settings${ appendedBlockPath }.${ setting }` + ) ?? + getValueFromObjectPath( globalStyles, `settings.${ setting }` ); + if ( value !== undefined ) { + result = setImmutably( result, setting.split( '.' ), value ); + } + } ); + return result as T; +} diff --git a/packages/global-styles-engine/src/settings/get-style.ts b/packages/global-styles-engine/src/settings/get-style.ts new file mode 100644 index 00000000000000..411888e023acf4 --- /dev/null +++ b/packages/global-styles-engine/src/settings/get-style.ts @@ -0,0 +1,29 @@ +/** + * Internal dependencies + */ +import { getValueFromObjectPath } from '../utils/object'; +import { getValueFromVariable } from '../utils/common'; +import type { GlobalStylesConfig, UnresolvedValue } from '../types'; + +export function getStyle< T = any >( + globalStyles?: GlobalStylesConfig, + path?: string, + blockName?: string, + shouldDecodeEncode = true +): T | undefined { + const appendedPath = path ? '.' + path : ''; + const finalPath = ! blockName + ? `styles${ appendedPath }` + : `styles.blocks.${ blockName }${ appendedPath }`; + if ( ! globalStyles ) { + return undefined; + } + + const rawResult = getValueFromObjectPath( globalStyles, finalPath ) as + | string + | UnresolvedValue; + const result = shouldDecodeEncode + ? getValueFromVariable( globalStyles, blockName, rawResult ) + : rawResult; + return result as T | undefined; +} diff --git a/packages/global-styles-engine/src/settings/set-setting.ts b/packages/global-styles-engine/src/settings/set-setting.ts new file mode 100644 index 00000000000000..e577e976e5583c --- /dev/null +++ b/packages/global-styles-engine/src/settings/set-setting.ts @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import { setImmutably } from '../utils/object'; +import type { GlobalStylesConfig } from '../types'; + +export function setSetting< T = any >( + globalStyles: GlobalStylesConfig, + path: string, + newValue: T | undefined, + blockName?: string +): GlobalStylesConfig { + const appendedBlockPath = blockName ? '.blocks.' + blockName : ''; + const appendedPropertyPath = path ? '.' + path : ''; + const finalPath = `settings${ appendedBlockPath }${ appendedPropertyPath }`; + + return setImmutably( + globalStyles, + finalPath.split( '.' ), + newValue + ) as GlobalStylesConfig; +} diff --git a/packages/global-styles-engine/src/settings/set-style.ts b/packages/global-styles-engine/src/settings/set-style.ts new file mode 100644 index 00000000000000..a95df3e3530600 --- /dev/null +++ b/packages/global-styles-engine/src/settings/set-style.ts @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import { setImmutably } from '../utils/object'; +import type { GlobalStylesConfig } from '../types'; + +export function setStyle< T = any >( + globalStyles: GlobalStylesConfig, + path: string, + newValue: T | undefined, + blockName?: string +): GlobalStylesConfig { + const appendedPath = path ? '.' + path : ''; + const finalPath = ! blockName + ? `styles${ appendedPath }` + : `styles.blocks.${ blockName }${ appendedPath }`; + + return setImmutably( + globalStyles, + finalPath.split( '.' ), + newValue + ) as GlobalStylesConfig; +} diff --git a/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js b/packages/global-styles-engine/src/test/render.test.ts similarity index 58% rename from packages/block-editor/src/components/global-styles/test/use-global-styles-output.js rename to packages/global-styles-engine/src/test/render.test.ts index 5022e8ba591dbb..571f6af0140205 100644 --- a/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js +++ b/packages/global-styles-engine/src/test/render.test.ts @@ -1,22 +1,60 @@ -/** - * WordPress dependencies - */ -import { __EXPERIMENTAL_ELEMENTS as ELEMENTS } from '@wordpress/blocks'; - /** * Internal dependencies */ import { - getLayoutStyles, - getNodesWithSettings, getNodesWithStyles, + getNodesWithSettings, + generateCustomProperties, + transformToStyles, getBlockSelectors, - toCustomProperties, - toStyles, - getStylesDeclarations, - processCSSNesting, -} from '../use-global-styles-output'; -import { ROOT_BLOCK_SELECTOR, ROOT_CSS_PROPERTIES_SELECTOR } from '../utils'; +} from '../core/render'; +import type { GlobalStylesConfig } from '../types'; +import { + ROOT_BLOCK_SELECTOR, + ROOT_CSS_PROPERTIES_SELECTOR, +} from '../utils/common'; + +// Mock WordPress data store +jest.mock( '@wordpress/data', () => ( { + select: jest.fn(), +} ) ); + +// Mock WordPress blocks store +jest.mock( '@wordpress/blocks', () => ( { + __EXPERIMENTAL_STYLE_PROPERTY: { + filter: { + value: [ 'filter', 'duotone' ], + support: [ 'filter', 'duotone' ], + }, + }, + __EXPERIMENTAL_ELEMENTS: { + link: 'a:where(:not(.wp-element-button))', + h1: 'h1', + h2: 'h2', + h3: 'h3', + h4: 'h4', + h5: 'h5', + h6: 'h6', + button: '.wp-element-button', + caption: '.wp-element-caption', + }, + getBlockSupport: jest.fn(), + getBlockTypes: jest.fn(), + store: 'core/blocks', +} ) ); + +// Mock WordPress elements (minimal, no mocking of complex APIs) +const ELEMENTS = { + link: 'a:where(:not(.wp-element-button))', + h1: 'h1', + h2: 'h2', + h3: 'h3', + h4: 'h4', + h5: 'h5', + h6: 'h6', + button: '.wp-element-button', + caption: '.wp-element-caption', +}; describe( 'global styles renderer', () => { describe( 'getNodesWithStyles', () => { @@ -186,9 +224,10 @@ describe( 'global styles renderer', () => { ] ); } ); } ); + describe( 'getNodesWithSettings', () => { it( 'should return nodes with settings', () => { - const tree = { + const tree: GlobalStylesConfig = { styles: { color: { background: 'red', @@ -281,9 +320,9 @@ describe( 'global styles renderer', () => { } ); } ); - describe( 'toCustomProperties', () => { + describe( 'generateCustomProperties', () => { it( 'should return a ruleset', () => { - const tree = { + const tree: GlobalStylesConfig = { settings: { color: { palette: { @@ -343,13 +382,13 @@ describe( 'global styles renderer', () => { }, }; - expect( toCustomProperties( tree, blockSelectors ) ).toEqual( + expect( generateCustomProperties( tree, blockSelectors ) ).toEqual( ':root{--wp--preset--color--white: white;--wp--preset--color--black: black;--wp--preset--color--white-2-black: value;--wp--custom--white-2-black: value;--wp--custom--font-primary: value;--wp--custom--line-height--body: 1.7;--wp--custom--line-height--heading: 1.3;}h1,h2,h3,h4,h5,h6{--wp--preset--font-size--small: 12px;--wp--preset--font-size--medium: 23px;}' ); } ); } ); - describe( 'toStyles', () => { + describe( 'transformToStyles', () => { it( 'should return a ruleset', () => { const tree = { settings: { @@ -481,7 +520,7 @@ describe( 'global styles renderer', () => { }, }; - expect( toStyles( tree, blockSelectors ) ).toEqual( + expect( transformToStyles( tree, blockSelectors ) ).toEqual( ':where(body) {margin: 0;}.is-layout-flow > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-flow > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-flow > .aligncenter { margin-left: auto !important; margin-right: auto !important; }.is-layout-constrained > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-constrained > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-constrained > .aligncenter { margin-left: auto !important; margin-right: auto !important; }.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)) { max-width: var(--wp--style--global--content-size); margin-left: auto !important; margin-right: auto !important; }.is-layout-constrained > .alignwide { max-width: var(--wp--style--global--wide-size); }body .is-layout-flex { display:flex; }.is-layout-flex { flex-wrap: wrap; align-items: center; }.is-layout-flex > :is(*, div) { margin: 0; }body .is-layout-grid { display:grid; }.is-layout-grid > :is(*, div) { margin: 0; }body{background-color: red;margin: 10px;padding: 10px;}a:where(:not(.wp-element-button)){color: blue;}:root :where(a:where(:not(.wp-element-button)):hover){color: orange;}:root :where(a:where(:not(.wp-element-button)):focus){color: orange;}h1{font-size: 42px;}:root :where(.wp-block-group){margin-top: 10px;margin-right: 20px;margin-bottom: 30px;margin-left: 40px;padding-top: 11px;padding-right: 22px;padding-bottom: 33px;padding-left: 44px;}:root :where(h1,h2,h3,h4,h5,h6){color: orange;}:root :where(h1 a:where(:not(.wp-element-button)),h2 a:where(:not(.wp-element-button)),h3 a:where(:not(.wp-element-button)),h4 a:where(:not(.wp-element-button)),h5 a:where(:not(.wp-element-button)),h6 a:where(:not(.wp-element-button))){color: hotpink;}:root :where(h1 a:where(:not(.wp-element-button)):hover,h2 a:where(:not(.wp-element-button)):hover,h3 a:where(:not(.wp-element-button)):hover,h4 a:where(:not(.wp-element-button)):hover,h5 a:where(:not(.wp-element-button)):hover,h6 a:where(:not(.wp-element-button)):hover){color: red;}:root :where(h1 a:where(:not(.wp-element-button)):focus,h2 a:where(:not(.wp-element-button)):focus,h3 a:where(:not(.wp-element-button)):focus,h4 a:where(:not(.wp-element-button)):focus,h5 a:where(:not(.wp-element-button)):focus,h6 a:where(:not(.wp-element-button)):focus){color: red;}:root :where(.wp-block-image img, .wp-block-image .wp-crop-area){border-radius: 9999px;}:root :where(.wp-block-image){color: red;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }.has-white-color{color: var(--wp--preset--color--white) !important;}.has-white-background-color{background-color: var(--wp--preset--color--white) !important;}.has-white-border-color{border-color: var(--wp--preset--color--white) !important;}.has-black-color{color: var(--wp--preset--color--black) !important;}.has-black-background-color{background-color: var(--wp--preset--color--black) !important;}.has-black-border-color{border-color: var(--wp--preset--color--black) !important;}h1.has-blue-color,h2.has-blue-color,h3.has-blue-color,h4.has-blue-color,h5.has-blue-color,h6.has-blue-color{color: var(--wp--preset--color--blue) !important;}h1.has-blue-background-color,h2.has-blue-background-color,h3.has-blue-background-color,h4.has-blue-background-color,h5.has-blue-background-color,h6.has-blue-background-color{background-color: var(--wp--preset--color--blue) !important;}h1.has-blue-border-color,h2.has-blue-border-color,h3.has-blue-border-color,h4.has-blue-border-color,h5.has-blue-border-color,h6.has-blue-border-color{border-color: var(--wp--preset--color--blue) !important;}' ); } ); @@ -521,13 +560,15 @@ describe( 'global styles renderer', () => { }, }; - expect( toStyles( Object.freeze( tree ), blockSelectors ) ).toEqual( + expect( + transformToStyles( Object.freeze( tree ), blockSelectors ) + ).toEqual( ':where(body) {margin: 0;}.is-layout-flow > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-flow > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-flow > .aligncenter { margin-left: auto !important; margin-right: auto !important; }.is-layout-constrained > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-constrained > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-constrained > .aligncenter { margin-left: auto !important; margin-right: auto !important; }.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)) { max-width: var(--wp--style--global--content-size); margin-left: auto !important; margin-right: auto !important; }.is-layout-constrained > .alignwide { max-width: var(--wp--style--global--wide-size); }body .is-layout-flex { display:flex; }.is-layout-flex { flex-wrap: wrap; align-items: center; }.is-layout-flex > :is(*, div) { margin: 0; }body .is-layout-grid { display:grid; }.is-layout-grid > :is(*, div) { margin: 0; }:root :where(.wp-image-spacing){padding-top: 1px;}:root :where(.wp-image-border-color){border-color: red;}:root :where(.wp-image-border){border-radius: 9999px;}:root :where(.wp-image){color: red;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }' ); } ); it( 'should handle block variations', () => { - const tree = { + const tree: GlobalStylesConfig = { styles: { blocks: { 'core/image': { @@ -571,7 +612,7 @@ describe( 'global styles renderer', () => { const hasFallbackGapSupport = true; const disableLayoutStyles = true; const disableRootPadding = true; - const styleOptions = { + const styleOptions: Record< string, boolean > = { blockGap: false, blockStyles: true, layoutStyles: false, @@ -581,7 +622,7 @@ describe( 'global styles renderer', () => { }; // Confirm no variation styles by default. - const withoutVariations = toStyles( + const withoutVariations = transformToStyles( Object.freeze( tree ), blockSelectors, hasBlockGapSupport, @@ -594,7 +635,7 @@ describe( 'global styles renderer', () => { // Includes variation styles when requested. styleOptions.variationStyles = true; - const withVariations = toStyles( + const withVariations = transformToStyles( Object.freeze( tree ), blockSelectors, hasBlockGapSupport, @@ -628,7 +669,9 @@ describe( 'global styles renderer', () => { }, }; - expect( toStyles( Object.freeze( tree ), blockSelectors ) ).toEqual( + expect( + transformToStyles( Object.freeze( tree ), blockSelectors ) + ).toEqual( ':where(body) {margin: 0;}.is-layout-flow > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-flow > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-flow > .aligncenter { margin-left: auto !important; margin-right: auto !important; }.is-layout-constrained > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-constrained > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-constrained > .aligncenter { margin-left: auto !important; margin-right: auto !important; }.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)) { max-width: var(--wp--style--global--content-size); margin-left: auto !important; margin-right: auto !important; }.is-layout-constrained > .alignwide { max-width: var(--wp--style--global--wide-size); }body .is-layout-flex { display:flex; }.is-layout-flex { flex-wrap: wrap; align-items: center; }.is-layout-flex > :is(*, div) { margin: 0; }body .is-layout-grid { display:grid; }.is-layout-grid > :is(*, div) { margin: 0; }.wp-image img{filter: blur(5px);}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }' ); } ); @@ -642,186 +685,27 @@ describe( 'global styles renderer', () => { }, }, }; - expect( toStyles( Object.freeze( tree ), 'body' ) ).toEqual( + expect( + transformToStyles( Object.freeze( tree ), 'body' ) + ).toEqual( ':root { --wp--style--global--content-size: 840px; --wp--style--global--wide-size: 1100px;}:where(body) {margin: 0;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }' ); } ); } ); - describe( 'getLayoutStyles', () => { - const layoutDefinitionsTree = { - settings: { - layout: { - definitions: { - default: { - name: 'default', - slug: 'flow', - className: 'is-layout-flow', - baseStyles: [ - { - selector: ' > .alignleft', - rules: { - float: 'left', - 'margin-inline-start': '0', - 'margin-inline-end': '2em', - }, - }, - { - selector: ' > .alignright', - rules: { - float: 'right', - 'margin-inline-start': '2em', - 'margin-inline-end': '0', - }, - }, - { - selector: ' > .aligncenter', - rules: { - 'margin-left': 'auto !important', - 'margin-right': 'auto !important', - }, - }, - ], - spacingStyles: [ - { - selector: ' > *', - rules: { - 'margin-block-start': '0', - 'margin-block-end': '0', - }, - }, - { - selector: ' > * + *', - rules: { - 'margin-block-start': null, - 'margin-block-end': '0', - }, - }, - ], - }, - flex: { - name: 'flex', - slug: 'flex', - className: 'is-layout-flex', - displayMode: 'flex', - baseStyles: [ - { - selector: '', - rules: { - 'flex-wrap': 'wrap', - 'align-items': 'center', - }, - }, - { - selector: ' > *', - rules: { - margin: '0', - }, - }, - ], - spacingStyles: [ - { - selector: '', - rules: { - gap: null, - }, - }, - ], - }, - }, - }, - }, - }; - - it( 'should return fallback gap flex layout style, and all base styles, if block styles are enabled and blockGap is disabled', () => { - const style = { spacing: { blockGap: '12px' } }; - - const layoutStyles = getLayoutStyles( { - layoutDefinitions: - layoutDefinitionsTree.settings.layout.definitions, - style, - selector: 'body', - hasBlockGapSupport: false, - hasFallbackGapSupport: true, - } ); - - expect( layoutStyles ).toEqual( - ':where(.is-layout-flex) { gap: 0.5em; }.is-layout-flow > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-flow > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-flow > .aligncenter { margin-left: auto !important; margin-right: auto !important; }body .is-layout-flex { display:flex; }.is-layout-flex { flex-wrap: wrap; align-items: center; }.is-layout-flex > * { margin: 0; }' - ); - } ); - - it( 'should return fallback gap layout styles, and base styles, if blockGap is enabled, but there is no blockGap value', () => { - const style = {}; - - const layoutStyles = getLayoutStyles( { - layoutDefinitions: - layoutDefinitionsTree.settings.layout.definitions, - style, - selector: 'body', - hasBlockGapSupport: true, - hasFallbackGapSupport: true, - } ); - - expect( layoutStyles ).toEqual( - ':root :where(.is-layout-flow) > * { margin-block-start: 0; margin-block-end: 0; }:root :where(.is-layout-flow) > * + * { margin-block-start: 0.5em; margin-block-end: 0; }:root :where(.is-layout-flex) { gap: 0.5em; }:root { --wp--style--block-gap: 0.5em; }.is-layout-flow > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-flow > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-flow > .aligncenter { margin-left: auto !important; margin-right: auto !important; }body .is-layout-flex { display:flex; }.is-layout-flex { flex-wrap: wrap; align-items: center; }.is-layout-flex > * { margin: 0; }' - ); - } ); - - it( 'should return real gap layout style if blockGap is enabled, and base styles', () => { - const style = { spacing: { blockGap: '12px' } }; - - const layoutStyles = getLayoutStyles( { - layoutDefinitions: - layoutDefinitionsTree.settings.layout.definitions, - style, - selector: 'body', - hasBlockGapSupport: true, - hasFallbackGapSupport: true, - } ); - - expect( layoutStyles ).toEqual( - ':root :where(.is-layout-flow) > * { margin-block-start: 0; margin-block-end: 0; }:root :where(.is-layout-flow) > * + * { margin-block-start: 12px; margin-block-end: 0; }:root :where(.is-layout-flex) { gap: 12px; }:root { --wp--style--block-gap: 12px; }.is-layout-flow > .alignleft { float: left; margin-inline-start: 0; margin-inline-end: 2em; }.is-layout-flow > .alignright { float: right; margin-inline-start: 2em; margin-inline-end: 0; }.is-layout-flow > .aligncenter { margin-left: auto !important; margin-right: auto !important; }body .is-layout-flex { display:flex; }.is-layout-flex { flex-wrap: wrap; align-items: center; }.is-layout-flex > * { margin: 0; }' - ); - } ); - - it( 'should return real gap layout style if blockGap is enabled', () => { - const style = { spacing: { blockGap: '12px' } }; - - const layoutStyles = getLayoutStyles( { - layoutDefinitions: - layoutDefinitionsTree.settings.layout.definitions, - style, - selector: '.wp-block-group', - hasBlockGapSupport: true, - hasFallbackGapSupport: true, - } ); - - expect( layoutStyles ).toEqual( - ':root :where(.wp-block-group-is-layout-flow) > * { margin-block-start: 0; margin-block-end: 0; }:root :where(.wp-block-group-is-layout-flow) > * + * { margin-block-start: 12px; margin-block-end: 0; }:root :where(.wp-block-group-is-layout-flex) { gap: 12px; }' - ); + describe( 'getBlockSelectors', () => { + beforeEach( () => { + // Reset mocks before each test + jest.clearAllMocks(); } ); - it( 'should return fallback gap flex layout style for a block if blockGap is disabled, and a fallback value is provided', () => { - const style = { spacing: { blockGap: '12px' } }; - - const layoutStyles = getLayoutStyles( { - layoutDefinitions: - layoutDefinitionsTree.settings.layout.definitions, - style, - selector: '.wp-block-group', - hasBlockGapSupport: false, // This means that the fallback value will be used instead of the "real" one. - hasFallbackGapSupport: true, - fallbackGapValue: '2em', + it( 'should return block selectors data', () => { + // Mock the select function to return getBlockStyles + const mockSelect = require( '@wordpress/data' ).select as jest.Mock; + mockSelect.mockReturnValue( { + getBlockStyles: () => [ { name: 'foo', label: 'foo' } ], } ); - expect( layoutStyles ).toEqual( - ':where(.wp-block-group.is-layout-flex) { gap: 2em; }' - ); - } ); - } ); - - describe( 'getBlockSelectors', () => { - it( 'should return block selectors data', () => { const imageSelectors = { root: '.my-image', border: '.my-image img, .my-image .crop-area', @@ -830,11 +714,11 @@ describe( 'global styles renderer', () => { const imageBlock = { name: 'core/image', selectors: imageSelectors, + title: 'My Image', + category: 'media', }; const blockTypes = [ imageBlock ]; - const getBlockStyles = () => [ { name: 'foo' } ]; - - expect( getBlockSelectors( blockTypes, getBlockStyles ) ).toEqual( { + expect( getBlockSelectors( blockTypes ) ).toEqual( { 'core/image': { name: imageBlock.name, selector: imageSelectors.root, @@ -854,6 +738,24 @@ describe( 'global styles renderer', () => { } ); it( 'should return block selectors data with old experimental selectors', () => { + // Mock the select function to return getBlockStyles with empty array + const mockSelect = require( '@wordpress/data' ).select as jest.Mock; + mockSelect.mockReturnValue( { + getBlockStyles: () => [], + } ); + + // Mock getBlockSupport to handle experimental duotone support + const mockGetBlockSupport = require( '@wordpress/blocks' ) + .getBlockSupport as jest.Mock; + mockGetBlockSupport.mockImplementation( + ( blockType, path, defaultValue ) => { + if ( path === 'color.__experimentalDuotone' ) { + return 'img'; + } + return defaultValue; + } + ); + const imageSupports = { __experimentalBorder: { radius: true, @@ -864,10 +766,15 @@ describe( 'global styles renderer', () => { }, __experimentalSelector: '.my-image', }; - const imageBlock = { name: 'core/image', supports: imageSupports }; + const imageBlock = { + name: 'core/image', + supports: imageSupports, + title: 'My Image', + category: 'media', + }; const blockTypes = [ imageBlock ]; - expect( getBlockSelectors( blockTypes, () => {} ) ).toEqual( { + expect( getBlockSelectors( blockTypes ) ).toEqual( { 'core/image': { name: imageBlock.name, selector: imageSupports.__experimentalSelector, @@ -882,250 +789,4 @@ describe( 'global styles renderer', () => { } ); } ); } ); - - describe( 'getStylesDeclarations', () => { - const blockStyles = { - spacing: { - padding: { - top: '33px', - right: '33px', - bottom: '33px', - left: '33px', - }, - }, - color: { - background: 'var:preset|color|light-green-cyan', - }, - typography: { - fontFamily: 'sans-serif', - fontSize: '15px', - }, - }; - - it( 'should output padding variables and other properties if useRootPaddingAwareAlignments is enabled', () => { - expect( - getStylesDeclarations( blockStyles, 'body', true ) - ).toEqual( [ - '--wp--style--root--padding-top: 33px', - '--wp--style--root--padding-right: 33px', - '--wp--style--root--padding-bottom: 33px', - '--wp--style--root--padding-left: 33px', - 'background-color: var(--wp--preset--color--light-green-cyan)', - 'font-family: sans-serif', - 'font-size: 15px', - ] ); - } ); - - it( 'should output padding and other properties if useRootPaddingAwareAlignments is disabled', () => { - expect( - getStylesDeclarations( blockStyles, 'body', false ) - ).toEqual( [ - 'background-color: var(--wp--preset--color--light-green-cyan)', - 'padding-top: 33px', - 'padding-right: 33px', - 'padding-bottom: 33px', - 'padding-left: 33px', - 'font-family: sans-serif', - 'font-size: 15px', - ] ); - } ); - - it( 'should not output padding variables if selector is not root', () => { - expect( - getStylesDeclarations( - blockStyles, - '.wp-block-button__link', - true - ) - ).toEqual( [ - 'background-color: var(--wp--preset--color--light-green-cyan)', - 'padding-top: 33px', - 'padding-right: 33px', - 'padding-bottom: 33px', - 'padding-left: 33px', - 'font-family: sans-serif', - 'font-size: 15px', - ] ); - } ); - - it( 'should output clamp values for font-size when fluid typography is enabled', () => { - expect( - getStylesDeclarations( - blockStyles, - '.wp-block-site-title', - true, - { - settings: { - typography: { - fluid: true, - }, - }, - } - ) - ).toEqual( [ - 'background-color: var(--wp--preset--color--light-green-cyan)', - 'padding-top: 33px', - 'padding-right: 33px', - 'padding-bottom: 33px', - 'padding-left: 33px', - 'font-family: sans-serif', - 'font-size: clamp(14px, 0.875rem + ((1vw - 3.2px) * 0.078), 15px)', - ] ); - } ); - - it( 'should output direct values for font-size when fluid typography is disabled', () => { - expect( - getStylesDeclarations( - blockStyles, - '.wp-block-site-title', - true, - { - settings: { - typography: { - fluid: false, - }, - }, - } - ) - ).toEqual( [ - 'background-color: var(--wp--preset--color--light-green-cyan)', - 'padding-top: 33px', - 'padding-right: 33px', - 'padding-bottom: 33px', - 'padding-left: 33px', - 'font-family: sans-serif', - 'font-size: 15px', - ] ); - } ); - - it( 'should correctly resolve referenced values', () => { - const stylesWithRef = { - typography: { - fontSize: { - ref: 'styles.elements.h1.typography.fontSize', - }, - letterSpacing: { - ref: 'styles.elements.h1.typography.letterSpacing', - }, - }, - background: { - backgroundImage: { - ref: 'styles.background.backgroundImage', - }, - backgroundSize: { - ref: 'styles.background.backgroundSize', - }, - }, - }; - const tree = { - styles: { - background: { - backgroundImage: { - url: 'http://my-image.org/image.gif', - }, - backgroundSize: 'cover', - }, - elements: { - h1: { - typography: { - fontSize: 'var:preset|font-size|xx-large', - letterSpacing: '2px', - }, - }, - }, - }, - }; - expect( - getStylesDeclarations( stylesWithRef, '.wp-block', false, tree ) - ).toEqual( [ - 'font-size: var(--wp--preset--font-size--xx-large)', - 'letter-spacing: 2px', - "background-image: url( 'http://my-image.org/image.gif' )", - 'background-size: cover', - ] ); - } ); - it( 'should set default values for block background styles', () => { - const backgroundStyles = { - background: { - backgroundImage: { - url: 'https://wordpress.org/assets/image.jpg', - id: 123, - }, - }, - }; - expect( - getStylesDeclarations( backgroundStyles, '.wp-block-group' ) - ).toEqual( [ - "background-image: url( 'https://wordpress.org/assets/image.jpg' )", - 'background-size: cover', - ] ); - // Test with root-level styles. - expect( - getStylesDeclarations( backgroundStyles, ROOT_BLOCK_SELECTOR ) - ).toEqual( [ - "background-image: url( 'https://wordpress.org/assets/image.jpg' )", - ] ); - expect( - getStylesDeclarations( - { - background: { - ...backgroundStyles.background, - backgroundSize: 'contain', - }, - }, - '.wp-block-group' - ) - ).toEqual( [ - "background-image: url( 'https://wordpress.org/assets/image.jpg' )", - 'background-position: 50% 50%', - 'background-size: contain', - ] ); - } ); - } ); - - describe( 'processCSSNesting', () => { - it( 'should return empty string when supplied css is empty', () => { - expect( processCSSNesting( '', '.foo' ) ).toEqual( '' ); - } ); - it( 'should return processed CSS without any nested selectors', () => { - expect( - processCSSNesting( 'color: red; margin: auto;', '.foo' ) - ).toEqual( ':root :where(.foo){color: red; margin: auto;}' ); - } ); - it( 'should return processed CSS when there are no root selectors', () => { - expect( - processCSSNesting( '&::before{color: red;}', '.foo' ) - ).toEqual( ':root :where(.foo)::before{color: red;}' ); - } ); - it( 'should return processed CSS with nested selectors', () => { - expect( - processCSSNesting( - 'color: red; margin: auto; &.one{color: blue;} & .two{color: green;}', - '.foo' - ) - ).toEqual( - ':root :where(.foo){color: red; margin: auto;}:root :where(.foo.one){color: blue;}:root :where(.foo .two){color: green;}' - ); - } ); - it( 'should return processed CSS with pseudo elements', () => { - expect( - processCSSNesting( - 'color: red; margin: auto; &::before{color: blue;} & ::before{color: green;} &.one::before{color: yellow;} & .two::before{color: purple;} & > ::before{color: darkseagreen;}', - '.foo' - ) - ).toEqual( - ':root :where(.foo){color: red; margin: auto;}:root :where(.foo)::before{color: blue;}:root :where(.foo) ::before{color: green;}:root :where(.foo.one)::before{color: yellow;}:root :where(.foo .two)::before{color: purple;}:root :where(.foo) > ::before{color: darkseagreen;}' - ); - } ); - it( 'should return processed CSS with multiple root selectors', () => { - expect( - processCSSNesting( - 'color: red; margin: auto; &.one{color: blue;} & .two{color: green;} &::before{color: yellow;} & ::before{color: purple;} &.three::before{color: orange;} & .four::before{color: skyblue;} & > ::before{color: darkseagreen;}', - '.foo, .bar' - ) - ).toEqual( - ':root :where(.foo, .bar){color: red; margin: auto;}:root :where(.foo.one, .bar.one){color: blue;}:root :where(.foo .two, .bar .two){color: green;}:root :where(.foo, .bar)::before{color: yellow;}:root :where(.foo, .bar) ::before{color: purple;}:root :where(.foo.three, .bar.three)::before{color: orange;}:root :where(.foo .four, .bar .four)::before{color: skyblue;}:root :where(.foo, .bar) > ::before{color: darkseagreen;}' - ); - } ); - } ); } ); diff --git a/packages/global-styles-engine/src/test/typography-utils.test.ts b/packages/global-styles-engine/src/test/typography-utils.test.ts new file mode 100644 index 00000000000000..cc9c74cc32dfee --- /dev/null +++ b/packages/global-styles-engine/src/test/typography-utils.test.ts @@ -0,0 +1,354 @@ +/** + * Tests for typography utility functions + * Ported from Gutenberg's typography-utils.js tests + */ + +/** + * Internal dependencies + */ +import { + getTypographyFontSizeValue, + getFluidTypographyOptionsFromSettings, +} from '../utils/typography'; + +describe( 'typography utils', () => { + describe( 'getTypographyFontSizeValue', () => { + const testCases = [ + { + message: + 'should return value when fluid typography is not active', + preset: { + size: '28px', + name: 'preset', + slug: 'preset', + }, + typographySettings: undefined, + expected: '28px', + }, + { + message: 'should return value where font size is 0', + preset: { + size: 0, + name: 'preset', + slug: 'preset', + }, + typographySettings: { + fluid: true, + }, + expected: 0, + }, + { + message: "should return value where font size is '0'", + preset: { + size: '0', + name: 'preset', + slug: 'preset', + }, + typographySettings: { + fluid: true, + }, + expected: '0', + }, + { + message: 'should return value where `size` is `null`.', + preset: { + size: null, + name: 'preset', + slug: 'preset', + }, + typographySettings: { + fluid: true, + }, + expected: null, + }, + { + message: 'should return value when fluid is `false`', + preset: { + size: '28px', + fluid: false, + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: true, + }, + }, + expected: '28px', + }, + { + message: 'should return value when fluid config is empty`', + preset: { + size: '28px', + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: {}, + }, + }, + expected: '28px', + }, + { + message: + 'should return clamp value with `minViewportWidth` override', + preset: { + size: '28px', + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: { + minViewportWidth: '500px', + }, + }, + }, + expected: + 'clamp(17.905px, 1.119rem + ((1vw - 5px) * 0.918), 28px)', + }, + { + message: + 'should return clamp value with `maxViewportWidth` override', + preset: { + size: '28px', + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: { + maxViewportWidth: '500px', + }, + }, + }, + expected: + 'clamp(17.905px, 1.119rem + ((1vw - 3.2px) * 5.608), 28px)', + }, + { + message: + 'should return clamp value with `layout.wideSize` override', + preset: { + size: '28px', + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: true, + }, + layout: { + wideSize: '500px', + }, + }, + expected: + 'clamp(17.905px, 1.119rem + ((1vw - 3.2px) * 5.608), 28px)', + }, + { + message: 'should return already clamped value', + preset: { + size: 'clamp(21px, 1.313rem + ((1vw - 7.68px) * 2.524), 42px)', + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: true, + }, + }, + expected: + 'clamp(21px, 1.313rem + ((1vw - 7.68px) * 2.524), 42px)', + }, + { + message: 'should return value with unsupported unit', + preset: { + size: '1000%', + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: true, + }, + }, + expected: '1000%', + }, + { + message: 'should return clamp value with rem min and max units', + preset: { + size: '1.75rem', + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: true, + }, + }, + expected: + 'clamp(1.119rem, 1.119rem + ((1vw - 0.2rem) * 0.789), 1.75rem)', + }, + { + message: 'should return clamp value with em min and max units', + preset: { + size: '1.75em', + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: true, + }, + }, + expected: + 'clamp(1.119em, 1.119rem + ((1vw - 0.2em) * 0.789), 1.75em)', + }, + { + message: 'should return clamp value for floats', + preset: { + size: '70.175px', + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: true, + }, + }, + expected: + 'clamp(37.897px, 2.369rem + ((1vw - 3.2px) * 2.522), 70.175px)', + }, + { + message: + 'should coerce integer to `px` and returns clamp value', + preset: { + size: 33, + fluid: true, + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: true, + }, + }, + expected: + 'clamp(20.515px, 1.282rem + ((1vw - 3.2px) * 0.975), 33px)', + }, + { + message: 'should coerce float to `px` and returns clamp value', + preset: { + size: 70.175, + fluid: true, + name: 'preset', + slug: 'preset', + }, + settings: { + typography: { + fluid: true, + }, + }, + expected: + 'clamp(37.897px, 2.369rem + ((1vw - 3.2px) * 2.522), 70.175px)', + }, + ]; + + testCases.forEach( ( { message, preset, settings, expected } ) => { + // eslint-disable-next-line jest/valid-title + it( message, () => { + expect( + getTypographyFontSizeValue( preset, settings || {} ) + ).toEqual( expected ); + } ); + } ); + } ); + + describe( 'getFluidTypographyOptionsFromSettings', () => { + const testCases = [ + { + message: + 'should return `undefined` when settings is `undefined`', + settings: undefined, + expected: { fluid: undefined }, + }, + { + message: + 'should return `undefined` when typography is `undefined`', + settings: {}, + expected: { fluid: undefined }, + }, + { + message: + 'should return `undefined` when typography.fluid is `undefined`', + settings: { typography: {} }, + expected: { fluid: undefined }, + }, + { + message: + 'should return `false` when typography.fluid is disabled by `false`', + settings: { typography: { fluid: false } }, + expected: { fluid: false }, + }, + { + message: 'should return `{}` when typography.fluid is empty', + settings: { typography: { fluid: {} } }, + expected: { fluid: {} }, + }, + { + message: + 'should return false when typography.fluid is disabled and layout.wideSize is defined', + settings: { + typography: { fluid: false }, + layout: { wideSize: '1000rem' }, + }, + expected: { fluid: false }, + }, + { + message: + 'should return true when fluid is enabled by a boolean', + settings: { typography: { fluid: true } }, + expected: { fluid: true }, + }, + { + message: + 'should return fluid settings with merged `layout.wideSize`', + settings: { + typography: { fluid: { minFontSize: '16px' } }, + layout: { wideSize: '1000rem' }, + }, + expected: { + fluid: { maxViewportWidth: '1000rem', minFontSize: '16px' }, + }, + }, + { + message: + 'should prioritize fluid `maxViewportWidth` over `layout.wideSize`', + settings: { + typography: { fluid: { maxViewportWidth: '10px' } }, + layout: { wideSize: '1000rem' }, + }, + expected: { fluid: { maxViewportWidth: '10px' } }, + }, + { + message: 'should not merge `layout.wideSize` if it is fluid', + settings: { + typography: { fluid: { minFontSize: '16px' } }, + layout: { wideSize: 'clamp(1000px, 85vw, 2000px)' }, + }, + expected: { + fluid: { minFontSize: '16px' }, + }, + }, + ]; + + testCases.forEach( ( { message, settings, expected } ) => { + // eslint-disable-next-line jest/valid-title + it( message, () => { + expect( + getFluidTypographyOptionsFromSettings( settings || {} ) + ).toEqual( expected ); + } ); + } ); + } ); +} ); diff --git a/packages/global-styles-engine/src/test/utils.test.ts b/packages/global-styles-engine/src/test/utils.test.ts new file mode 100644 index 00000000000000..6578cbb673cc4d --- /dev/null +++ b/packages/global-styles-engine/src/test/utils.test.ts @@ -0,0 +1,451 @@ +/** + * Tests for utility functions + * Ported from Gutenberg's utils.js tests + */ + +/** + * Internal dependencies + */ +import type { GlobalStylesConfig } from '../types'; +import { + getBlockStyleVariationSelector, + getValueFromVariable, + getPresetVariableFromValue, + scopeFeatureSelectors, + getResolvedThemeFilePath, + getResolvedRefValue, + getResolvedValue, +} from '../utils/common'; + +describe( 'editor utils', () => { + const themeJson: GlobalStylesConfig = { + version: 1, + settings: { + color: { + palette: { + theme: [ + { + slug: 'primary', + color: '#007cba', + name: 'Primary', + }, + { + slug: 'secondary', + color: '#006ba1', + name: 'Secondary', + }, + ], + custom: [ + { + slug: 'primary', + color: '#007cba', + name: 'Primary', + }, + { + slug: 'secondary', + color: '#a65555', + name: 'Secondary', + }, + ], + }, + custom: true, + customDuotone: true, + customGradient: true, + link: true, + }, + custom: { + color: { + primary: 'var(--wp--preset--color--primary)', + secondary: 'var(--wp--preset--color--secondary)', + }, + }, + }, + styles: { + background: { + backgroundImage: { + url: 'file:./assets/image.jpg', + }, + backgroundAttachment: 'fixed', + backgroundPosition: 'top left', + }, + blocks: { + 'core/group': { + background: { + backgroundImage: { + ref: 'styles.background.backgroundImage', + }, + }, + dimensions: { + minHeight: '100px', + }, + spacing: { + padding: { + top: 0, + }, + }, + }, + }, + }, + _links: { + 'wp:theme-file': [ + { + name: 'file:./assets/image.jpg', + href: 'https://wordpress.org/assets/image.jpg', + target: 'styles.background.backgroundImage.url', + }, + { + name: 'file:./assets/other/image.jpg', + href: 'https://wordpress.org/assets/other/image.jpg', + target: "styles.blocks.['core/group'].background.backgroundImage.url", + }, + ], + }, + }; + + describe( 'getValueFromVariable', () => { + describe( 'when provided an invalid variable', () => { + it( 'returns the originally provided value', () => { + const actual = getValueFromVariable( + themeJson, + 'root', + undefined + ); + + expect( actual ).toBe( undefined ); + } ); + } ); + + describe( 'when provided a preset variable', () => { + it( 'retrieves the correct preset value', () => { + const actual = getValueFromVariable( + themeJson, + 'root', + 'var:preset|color|primary' + ); + + expect( actual ).toBe( '#007cba' ); + } ); + } ); + + describe( 'when provided a custom variable', () => { + it( 'retrieves the correct custom value', () => { + const actual = getValueFromVariable( + themeJson, + 'root', + 'var(--wp--custom--color--secondary)' + ); + + expect( actual ).toBe( '#a65555' ); + } ); + } ); + + describe( 'when provided a dynamic reference', () => { + it( 'retrieves the referenced value', () => { + const stylesWithRefs = { + ...themeJson, + styles: { + color: { + background: { + ref: 'styles.color.text', + }, + text: 'purple-rain', + }, + }, + }; + const actual = getValueFromVariable( stylesWithRefs, 'root', { + ref: 'styles.color.text', + } ); + + expect( actual ).toBe( stylesWithRefs.styles.color.text ); + } ); + + it( 'returns the originally provided value where value is dynamic reference and reference does not exist', () => { + const stylesWithRefs = { + ...themeJson, + styles: { + color: { + text: { + ref: 'styles.background.text', + }, + }, + }, + }; + const actual = getValueFromVariable( stylesWithRefs, 'root', { + ref: 'styles.color.text', + } ); + + expect( actual ).toBe( stylesWithRefs.styles.color.text ); + } ); + + it( 'returns the originally provided value where value is dynamic reference', () => { + const stylesWithRefs = { + ...themeJson, + styles: { + color: { + background: { + ref: 'styles.color.text', + }, + text: { + ref: 'styles.background.text', + }, + }, + }, + }; + const actual = getValueFromVariable( stylesWithRefs, 'root', { + ref: 'styles.color.text', + } ); + + expect( actual ).toBe( stylesWithRefs.styles.color.text ); + } ); + } ); + } ); + + describe( 'getPresetVariableFromValue', () => { + const context = 'root'; + const propertyName = 'color.text'; + const value = '#007cba'; + + describe( 'when a provided global style (e.g. fontFamily, color,etc.) does not exist', () => { + it( 'returns the originally provided value', () => { + const actual = getPresetVariableFromValue( + themeJson.settings!, + context, + 'fakePropertyName', + value + ); + expect( actual ).toBe( value ); + } ); + } ); + + describe( 'when a global style is cleared by the user', () => { + it( 'returns an undefined preset variable', () => { + const actual = getPresetVariableFromValue( + themeJson.settings!, + context, + propertyName, + undefined + ); + expect( actual ).toBe( undefined ); + } ); + } ); + + describe( 'when a global style is selected by the user', () => { + describe( 'and it is not a preset value (e.g. custom color)', () => { + it( 'returns the originally provided value', () => { + const customValue = '#6e4545'; + const actual = getPresetVariableFromValue( + themeJson.settings!, + context, + propertyName, + customValue + ); + expect( actual ).toBe( customValue ); + } ); + } ); + + describe( 'and it is a preset value', () => { + it( 'returns the preset variable', () => { + const actual = getPresetVariableFromValue( + themeJson.settings!, + context, + propertyName, + value + ); + expect( actual ).toBe( 'var:preset|color|primary' ); + } ); + } ); + } ); + } ); + + describe( 'getBlockStyleVariationSelector', () => { + test.each( [ + { type: 'empty', selector: '', expected: '.is-style-custom' }, + { + type: 'class', + selector: '.wp-block', + expected: '.wp-block.is-style-custom', + }, + { + type: 'id', + selector: '#wp-block', + expected: '#wp-block.is-style-custom', + }, + { + type: 'element tag', + selector: 'p', + expected: 'p.is-style-custom', + }, + { + type: 'attribute', + selector: '[style*="color"]', + expected: '[style*="color"].is-style-custom', + }, + { + type: 'descendant', + selector: '.wp-block .inner', + expected: '.wp-block.is-style-custom .inner', + }, + { + type: 'comma-separated', + selector: '.wp-block .inner, .wp-block .alternative', + expected: + '.wp-block.is-style-custom .inner, .wp-block.is-style-custom .alternative', + }, + { + type: 'pseudo', + selector: 'div:first-child', + expected: 'div.is-style-custom:first-child', + }, + { + type: ':is', + selector: '.wp-block:is(.outer .inner:first-child)', + expected: + '.wp-block.is-style-custom:is(.outer .inner:first-child)', + }, + { + type: ':not', + selector: '.wp-block:not(.outer .inner:first-child)', + expected: + '.wp-block.is-style-custom:not(.outer .inner:first-child)', + }, + { + type: ':has', + selector: '.wp-block:has(.outer .inner:first-child)', + expected: + '.wp-block.is-style-custom:has(.outer .inner:first-child)', + }, + { + type: ':where', + selector: '.wp-block:where(.outer .inner:first-child)', + expected: + '.wp-block.is-style-custom:where(.outer .inner:first-child)', + }, + { + type: 'wrapping :where', + selector: ':where(.outer .inner:first-child)', + expected: ':where(.outer.is-style-custom .inner:first-child)', + }, + { + type: 'complex', + selector: + '.wp:where(.something):is(.test:not(.nothing p)):has(div[style]) .content, .wp:where(.nothing):not(.test:is(.something div)):has(span[style]) .inner', + expected: + '.wp.is-style-custom:where(.something):is(.test:not(.nothing p)):has(div[style]) .content, .wp.is-style-custom:where(.nothing):not(.test:is(.something div)):has(span[style]) .inner', + }, + ] )( + 'should add variation class to ancestor in $type selector', + ( { selector, expected } ) => { + expect( + getBlockStyleVariationSelector( 'custom', selector ) + ).toBe( expected ); + } + ); + } ); + + describe( 'scopeFeatureSelectors', () => { + it( 'correctly scopes selectors while maintaining selectors object structure', () => { + const actual = scopeFeatureSelectors( '.custom, .secondary', { + color: '.my-block h1', + typography: { + root: '.my-block', + lineHeight: '.my-block h1', + }, + } ); + + expect( actual ).toEqual( { + color: '.custom .my-block h1, .secondary .my-block h1', + typography: { + root: '.custom .my-block, .secondary .my-block', + lineHeight: '.custom .my-block h1, .secondary .my-block h1', + }, + } ); + } ); + } ); + + describe( 'getResolvedThemeFilePath()', () => { + it.each( [ + [ + 'file:./assets/image.jpg', + 'https://wordpress.org/assets/image.jpg', + 'Should return absolute URL if found in themeFileURIs', + ], + [ + 'file:./misc/image.jpg', + 'file:./misc/image.jpg', + 'Should return value if not found in themeFileURIs', + ], + [ + 'https://wordpress.org/assets/image.jpg', + 'https://wordpress.org/assets/image.jpg', + 'Should not match absolute URLs', + ], + ] )( + 'Given file %s and return value %s: %s', + ( file, returnedValue ) => { + expect( + getResolvedThemeFilePath( + file, + themeJson?._links?.[ 'wp:theme-file' ] + ) === returnedValue + ).toBe( true ); + } + ); + } ); + + describe( 'getResolvedRefValue()', () => { + it.each( [ + [ 'blue', 'blue', null ], + [ 0, 0, themeJson ], + [ + { ref: 'styles.background.backgroundImage' }, + { url: 'file:./assets/image.jpg' }, + themeJson, + ], + [ + { ref: 'styles.blocks.core/group.spacing.padding.top' }, + 0, + themeJson, + ], + [ + { + ref: 'styles.blocks.core/group.background.backgroundImage', + }, + undefined, + themeJson, + ], + ] )( + 'Given ruleValue %s return expected value of %s', + ( ruleValue, returnedValue, tree ) => { + expect( + getResolvedRefValue( ruleValue, tree ? tree : undefined ) + ).toEqual( returnedValue ); + } + ); + } ); + + describe( 'getResolvedValue()', () => { + it.each( [ + [ 'blue', 'blue', null ], + [ 0, 0, themeJson ], + [ + { ref: 'styles.background.backgroundImage' }, + { url: 'https://wordpress.org/assets/image.jpg' }, + themeJson, + ], + [ + { + ref: 'styles.blocks.core/group.background.backgroundImage', + }, + undefined, + themeJson, + ], + ] )( + 'Given ruleValue %s return expected value of %s', + ( ruleValue, returnedValue, tree ) => { + expect( + getResolvedValue( ruleValue, tree ? tree : undefined ) + ).toEqual( returnedValue ); + } + ); + } ); +} ); diff --git a/packages/global-styles-engine/src/types.ts b/packages/global-styles-engine/src/types.ts new file mode 100644 index 00000000000000..dc66229776621e --- /dev/null +++ b/packages/global-styles-engine/src/types.ts @@ -0,0 +1,408 @@ +// ============================================================================= +// CORE PRIMITIVE TYPES +// ============================================================================= + +/** + * Value that can be resolved from various sources (direct value, reference, or URL) + */ +export type UnresolvedValue = + | string + | number + | { ref: string } + | { url: string } + | undefined + | null; + +/** + * Origin of a preset (theme, user customizations, or WordPress defaults) + */ +export type PresetOrigin = 'theme' | 'custom' | 'default'; + +/** + * Common properties for all preset types + */ +export interface BasePreset { + name: string; + slug: string; +} + +// ============================================================================= +// COLOR SYSTEM TYPES +// ============================================================================= + +/** + * Color preset definition + */ +export interface Color extends BasePreset { + color: string; +} + +/** + * Gradient preset definition + */ +export interface Gradient extends BasePreset { + gradient: string; +} + +/** + * Duotone filter preset definition + */ +export interface Duotone extends BasePreset { + colors: string[]; +} + +/** + * Palette collection for a specific origin (theme, custom, default) + */ +export interface Palette { + name: string; + slug: PresetOrigin; + colors?: Color[]; + gradients?: Gradient[]; + duotones?: Duotone[]; +} + +/** + * Multi-origin palette structure used by StyleBook + */ +export interface MultiOriginPalettes { + colors?: Palette[]; + gradients?: Palette[]; + duotones?: Palette[]; + disableCustomColors?: boolean; + disableCustomGradients?: boolean; + hasColorsOrGradients?: boolean; +} + +/** + * Background style properties + */ +export interface BackgroundStyle { + backgroundColor?: UnresolvedValue; + backgroundImage?: + | { + url: string; + id?: number; + } + | UnresolvedValue; + backgroundSize?: UnresolvedValue; + backgroundPosition?: UnresolvedValue; + backgroundRepeat?: UnresolvedValue; + backgroundAttachment?: UnresolvedValue; + backgroundBlendMode?: UnresolvedValue; + backgroundOpacity?: UnresolvedValue; +} + +// ============================================================================= +// TYPOGRAPHY SYSTEM TYPES +// ============================================================================= + +/** + * Fluid typography settings for responsive font sizes + */ +export interface FluidTypographyConfig { + min?: string; + max?: string; +} + +/** + * Typography preset (font size) definition + */ +export interface TypographyPreset extends BasePreset { + size: string | number | null; + previewFontSize?: string; + fluid?: boolean | FluidTypographyConfig; +} + +/** + * Font size preset definition (alias for TypographyPreset for clarity) + */ +export interface FontSizePreset extends TypographyPreset {} + +/** + * Convenience type alias for font size data + */ +export type FontSize = FontSizePreset; + +/** + * Font face definition as found in theme.json + */ +export interface FontFace { + fontFamily: string; + fontWeight?: string | number; + fontStyle?: string; + fontStretch?: string; + fontDisplay?: string; + src?: string | string[]; +} + +/** + * Font family preset definition as found in theme.json + */ +export interface FontFamilyPreset extends BasePreset { + fontFamily: string; + fontFace?: FontFace[]; +} + +/** + * Global fluid typography settings + */ +export interface FluidTypographySettings { + maxViewportWidth?: string; + minFontSize?: string; + minViewportWidth?: string; +} + +/** + * Typography settings collection + */ +export interface TypographySettings { + fluid?: boolean | FluidTypographySettings; + fontSizes?: TypographyPreset[] | Record< string, TypographyPreset[] >; + fontFamilies?: Record< string, FontFamilyPreset[] >; + defaultFontSizes?: boolean; +} + +// ============================================================================= +// LAYOUT SYSTEM TYPES +// ============================================================================= + +/** + * Layout constraint settings + */ +export interface LayoutSettings { + wideSize?: string; + contentSize?: string; +} + +/** + * Spacing settings + */ +export interface SpacingSettings { + padding?: string | Record< string, string >; + margin?: string | Record< string, string >; + blockGap?: string; +} + +// ============================================================================= +// BLOCK SYSTEM TYPES (need to move to the blocks package eventually) +// ============================================================================= + +/** + * Block type definition with global styles support + */ +export interface BlockType { + name: string; + title: string; + category: string; + example?: any; + attributes?: Record< string, unknown >; + supports?: { + __experimentalSelector?: string; + inserter?: boolean; + spacing?: + | boolean + | { + blockGap?: + | boolean + | string[] + | { + __experimentalDefault?: string; + sides: string[]; + }; + }; + [ key: string ]: unknown; + }; + selectors?: Record< string, string | Record< string, string > >; +} + +/** + * Block style variation + */ +export interface BlockStyleVariation { + name: string; + label: string; + styles?: Record< string, any >; +} + +// ============================================================================= +// GLOBAL STYLES STRUCTURE TYPES +// ============================================================================= + +/** + * Global styles settings node + */ +export interface GlobalStylesSettings { + useRootPaddingAwareAlignments?: boolean; + typography?: TypographySettings; + layout?: LayoutSettings; + spacing?: SpacingSettings; + color?: { + palette?: + | Color[] + | { + theme?: Color[]; + custom?: Color[]; + default?: Color[]; + }; + gradients?: { + theme?: Gradient[]; + custom?: Gradient[]; + default?: Gradient[]; + }; + duotone?: { + theme?: Duotone[]; + custom?: Duotone[]; + default?: Duotone[]; + }; + link?: boolean; + custom?: boolean; + customGradient?: boolean; + customDuotone?: boolean; + defaultPalette?: boolean; + defaultGradients?: boolean; + defaultDuotone?: boolean; + }; + custom?: Record< + string, + string | number | Record< string, string | number > + >; + blocks?: Record< string, Omit< GlobalStylesSettings, 'blocks' > >; +} + +/** + * Global styles values node + */ +export interface GlobalStylesStyles { + color?: { + background?: UnresolvedValue; + text?: UnresolvedValue; + }; + typography?: { + fontFamily?: UnresolvedValue; + fontSize?: UnresolvedValue; + fontWeight?: UnresolvedValue; + lineHeight?: UnresolvedValue; + letterSpacing?: UnresolvedValue; + textTransform?: UnresolvedValue; + }; + spacing?: { + padding?: UnresolvedValue | Record< string, UnresolvedValue >; + margin?: UnresolvedValue | Record< string, UnresolvedValue >; + blockGap?: string; + }; + background?: BackgroundStyle; + border?: { + color?: UnresolvedValue; + width?: UnresolvedValue; + style?: UnresolvedValue; + radius?: + | UnresolvedValue + | { + topLeft?: UnresolvedValue; + topRight?: UnresolvedValue; + bottomRight?: UnresolvedValue; + bottomLeft?: UnresolvedValue; + }; + top?: { + color?: UnresolvedValue; + width?: UnresolvedValue; + style?: UnresolvedValue; + }; + right?: { + color?: UnresolvedValue; + width?: UnresolvedValue; + style?: UnresolvedValue; + }; + bottom?: { + color?: UnresolvedValue; + width?: UnresolvedValue; + style?: UnresolvedValue; + }; + left?: { + color?: UnresolvedValue; + width?: UnresolvedValue; + style?: UnresolvedValue; + }; + }; + filter?: { + duotone?: UnresolvedValue; + opacity?: UnresolvedValue; + }; + dimensions?: { + width?: UnresolvedValue; + height?: UnresolvedValue; + minWidth?: UnresolvedValue; + minHeight?: UnresolvedValue; + maxWidth?: UnresolvedValue; + maxHeight?: UnresolvedValue; + }; + elements?: Record< + string, + Omit< GlobalStylesStyles, 'blocks' | 'elements' | 'variations' > & { + ':hover'?: Omit< + GlobalStylesStyles, + 'blocks' | 'elements' | 'variations' + >; + } + >; + blocks?: Record< string, Omit< GlobalStylesStyles, 'blocks' > >; + variations?: Record< string, Omit< GlobalStylesStyles, 'blocks' > >; + css?: string; +} + +/** + * Complete global styles configuration + */ +export interface GlobalStylesConfig { + version?: number; + settings?: GlobalStylesSettings; + styles?: GlobalStylesStyles; + _links?: { + [ 'wp:theme-file' ]?: ThemeFileLink[]; + [ 'wp:action-edit-css' ]?: Array< { href: string } >; + }; +} + +/** + * Style variation (extends GlobalStylesConfig with metadata) + */ +export interface StyleVariation extends GlobalStylesConfig { + title?: string; + description?: string; +} + +/** + * WordPress theme file link + */ +export interface ThemeFileLink { + name: string; + href: string; + target?: string; +} + +// ============================================================================= +// UTILITY TYPES +// ============================================================================= + +/** + * Deep partial type for global styles merging + */ +export type DeepPartial< T > = { + [ P in keyof T ]?: T[ P ] extends object ? DeepPartial< T[ P ] > : T[ P ]; +}; + +/** + * CSS selector string + */ +export type CSSSelector = string; + +/** + * CSS property value + */ +export type CSSValue = string | number | undefined; + +/** + * CSS rules object + */ +export type CSSRules = Record< string, CSSValue >; diff --git a/packages/global-styles-engine/src/utils/background.ts b/packages/global-styles-engine/src/utils/background.ts new file mode 100644 index 00000000000000..d706ab9df7411d --- /dev/null +++ b/packages/global-styles-engine/src/utils/background.ts @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import type { BackgroundStyle } from '../types'; + +export const BACKGROUND_BLOCK_DEFAULT_VALUES = { + backgroundSize: 'cover', + backgroundPosition: '50% 50%', // used only when backgroundSize is 'contain'. +}; + +export function setBackgroundStyleDefaults( backgroundStyle: BackgroundStyle ) { + if ( + ! backgroundStyle || + // @ts-expect-error + ! backgroundStyle?.backgroundImage?.url + ) { + return; + } + + let backgroundStylesWithDefaults; + + // Set block background defaults. + if ( ! backgroundStyle?.backgroundSize ) { + backgroundStylesWithDefaults = { + backgroundSize: BACKGROUND_BLOCK_DEFAULT_VALUES.backgroundSize, + }; + } + + if ( + 'contain' === backgroundStyle?.backgroundSize && + ! backgroundStyle?.backgroundPosition + ) { + backgroundStylesWithDefaults = { + backgroundPosition: + BACKGROUND_BLOCK_DEFAULT_VALUES.backgroundPosition, + }; + } + return backgroundStylesWithDefaults; +} diff --git a/packages/global-styles-engine/src/utils/common.ts b/packages/global-styles-engine/src/utils/common.ts new file mode 100644 index 00000000000000..73446e1f4600e7 --- /dev/null +++ b/packages/global-styles-engine/src/utils/common.ts @@ -0,0 +1,671 @@ +/** + * WordPress dependencies + */ +import { getCSSValueFromRawStyle } from '@wordpress/style-engine'; + +/** + * Internal dependencies + */ +import type { + GlobalStylesSettings, + ThemeFileLink, + TypographyPreset, + UnresolvedValue, + GlobalStylesConfig, +} from '../types'; +import { getTypographyFontSizeValue } from './typography'; +import { getValueFromObjectPath } from './object'; + +export const ROOT_BLOCK_SELECTOR = 'body'; +export const ROOT_CSS_PROPERTIES_SELECTOR = ':root'; + +export const PRESET_METADATA = [ + { + path: [ 'color', 'palette' ], + valueKey: 'color', + cssVarInfix: 'color', + classes: [ + { classSuffix: 'color', propertyName: 'color' }, + { + classSuffix: 'background-color', + propertyName: 'background-color', + }, + { + classSuffix: 'border-color', + propertyName: 'border-color', + }, + ], + }, + { + path: [ 'color', 'gradients' ], + valueKey: 'gradient', + cssVarInfix: 'gradient', + classes: [ + { + classSuffix: 'gradient-background', + propertyName: 'background', + }, + ], + }, + { + path: [ 'color', 'duotone' ], + valueKey: 'colors', + cssVarInfix: 'duotone', + valueFunc: ( { slug }: { slug: string } ) => + `url( '#wp-duotone-${ slug }' )`, + classes: [], + }, + { + path: [ 'shadow', 'presets' ], + valueKey: 'shadow', + cssVarInfix: 'shadow', + classes: [], + }, + { + path: [ 'typography', 'fontSizes' ], + valueFunc: ( + preset: TypographyPreset, + settings: GlobalStylesSettings + ) => getTypographyFontSizeValue( preset, settings ), + valueKey: 'size', + cssVarInfix: 'font-size', + classes: [ { classSuffix: 'font-size', propertyName: 'font-size' } ], + }, + { + path: [ 'typography', 'fontFamilies' ], + valueKey: 'fontFamily', + cssVarInfix: 'font-family', + classes: [ + { classSuffix: 'font-family', propertyName: 'font-family' }, + ], + }, + { + path: [ 'spacing', 'spacingSizes' ], + valueKey: 'size', + cssVarInfix: 'spacing', + valueFunc: ( { size }: { size: string } ) => size, + classes: [], + }, + { + path: [ 'border', 'radiusSizes' ], + valueKey: 'size', + cssVarInfix: 'border-radius', + classes: [], + }, +]; + +export const STYLE_PATH_TO_CSS_VAR_INFIX: Record< string, string > = { + 'color.background': 'color', + 'color.text': 'color', + 'filter.duotone': 'duotone', + 'elements.link.color.text': 'color', + 'elements.link.:hover.color.text': 'color', + 'elements.link.typography.fontFamily': 'font-family', + 'elements.link.typography.fontSize': 'font-size', + 'elements.button.color.text': 'color', + 'elements.button.color.background': 'color', + 'elements.caption.color.text': 'color', + 'elements.button.typography.fontFamily': 'font-family', + 'elements.button.typography.fontSize': 'font-size', + 'elements.heading.color': 'color', + 'elements.heading.color.background': 'color', + 'elements.heading.typography.fontFamily': 'font-family', + 'elements.heading.gradient': 'gradient', + 'elements.heading.color.gradient': 'gradient', + 'elements.h1.color': 'color', + 'elements.h1.color.background': 'color', + 'elements.h1.typography.fontFamily': 'font-family', + 'elements.h1.color.gradient': 'gradient', + 'elements.h2.color': 'color', + 'elements.h2.color.background': 'color', + 'elements.h2.typography.fontFamily': 'font-family', + 'elements.h2.color.gradient': 'gradient', + 'elements.h3.color': 'color', + 'elements.h3.color.background': 'color', + 'elements.h3.typography.fontFamily': 'font-family', + 'elements.h3.color.gradient': 'gradient', + 'elements.h4.color': 'color', + 'elements.h4.color.background': 'color', + 'elements.h4.typography.fontFamily': 'font-family', + 'elements.h4.color.gradient': 'gradient', + 'elements.h5.color': 'color', + 'elements.h5.color.background': 'color', + 'elements.h5.typography.fontFamily': 'font-family', + 'elements.h5.color.gradient': 'gradient', + 'elements.h6.color': 'color', + 'elements.h6.color.background': 'color', + 'elements.h6.typography.fontFamily': 'font-family', + 'elements.h6.color.gradient': 'gradient', + 'color.gradient': 'gradient', + shadow: 'shadow', + 'typography.fontSize': 'font-size', + 'typography.fontFamily': 'font-family', +}; + +/** + * Function that scopes a selector with another one. This works a bit like + * SCSS nesting except the `&` operator isn't supported. + * + * @example + * ```js + * const scope = '.a, .b .c'; + * const selector = '> .x, .y'; + * const merged = scopeSelector( scope, selector ); + * // merged is '.a > .x, .a .y, .b .c > .x, .b .c .y' + * ``` + * + * @param scope Selector to scope to. + * @param selector Original selector. + * + * @return Scoped selector. + */ +export function scopeSelector( scope: string | undefined, selector: string ) { + if ( ! scope || ! selector ) { + return selector; + } + + const scopes = scope.split( ',' ); + const selectors = selector.split( ',' ); + + const selectorsScoped: string[] = []; + scopes.forEach( ( outer ) => { + selectors.forEach( ( inner ) => { + selectorsScoped.push( `${ outer.trim() } ${ inner.trim() }` ); + } ); + } ); + + return selectorsScoped.join( ', ' ); +} + +/** + * Scopes a collection of selectors for features and subfeatures. + * + * @example + * ```js + * const scope = '.custom-scope'; + * const selectors = { + * color: '.wp-my-block p', + * typography: { fontSize: '.wp-my-block caption' }, + * }; + * const result = scopeFeatureSelector( scope, selectors ); + * // result is { + * // color: '.custom-scope .wp-my-block p', + * // typography: { fonSize: '.custom-scope .wp-my-block caption' }, + * // } + * ``` + * + * @param scope Selector to scope collection of selectors with. + * @param selectors Collection of feature selectors e.g. + * + * @return Scoped collection of feature selectors. + */ +export function scopeFeatureSelectors( + scope: string | undefined, + selectors: string | Record< string, string | Record< string, string > > +) { + if ( ! scope || ! selectors ) { + return; + } + + const featureSelectors: Record< + string, + string | Record< string, string > + > = {}; + + Object.entries( selectors ).forEach( ( [ feature, selector ] ) => { + if ( typeof selector === 'string' ) { + featureSelectors[ feature ] = scopeSelector( scope, selector ); + } + + if ( typeof selector === 'object' ) { + featureSelectors[ feature ] = {}; + + Object.entries( selector ).forEach( + ( [ subfeature, subfeatureSelector ] ) => { + // @ts-expect-error + featureSelectors[ feature ][ subfeature ] = scopeSelector( + scope, + subfeatureSelector as string + ); + } + ); + } + } ); + + return featureSelectors; +} + +/** + * Appends a sub-selector to an existing one. + * + * Given the compounded `selector` "h1, h2, h3" + * and the `toAppend` selector ".some-class" the result will be + * "h1.some-class, h2.some-class, h3.some-class". + * + * @param selector Original selector. + * @param toAppend Selector to append. + * + * @return The new selector. + */ +export function appendToSelector( selector: string, toAppend: string ) { + if ( ! selector.includes( ',' ) ) { + return selector + toAppend; + } + const selectors = selector.split( ',' ); + const newSelectors = selectors.map( ( sel ) => sel + toAppend ); + return newSelectors.join( ',' ); +} + +/** + * Generates the selector for a block style variation by creating the + * appropriate CSS class and adding it to the ancestor portion of the block's + * selector. + * + * For example, take the Button block which has a compound selector: + * `.wp-block-button .wp-block-button__link`. With a variation named 'custom', + * the class `.is-style-custom` should be added to the `.wp-block-button` + * ancestor only. + * + * This function will take into account comma separated and complex selectors. + * + * @param variation Name for the variation. + * @param blockSelector CSS selector for the block. + * + * @return CSS selector for the block style variation. + */ +export function getBlockStyleVariationSelector( + variation: string, + blockSelector: string +) { + const variationClass = `.is-style-${ variation }`; + + if ( ! blockSelector ) { + return variationClass; + } + + const ancestorRegex = /((?::\([^)]+\))?\s*)([^\s:]+)/; + const addVariationClass = ( + _match: string, + group1: string, + group2: string + ) => { + return group1 + group2 + variationClass; + }; + + const result = blockSelector + .split( ',' ) + .map( ( part ) => part.replace( ancestorRegex, addVariationClass ) ); + + return result.join( ',' ); +} + +/** + * Resolves ref values in theme JSON. + * + * @param ruleValue A block style value that may contain a reference to a theme.json value. + * @param tree A theme.json object. + * @return The resolved value or incoming ruleValue. + */ +export function getResolvedRefValue( + ruleValue: UnresolvedValue, + tree?: GlobalStylesConfig +): UnresolvedValue { + if ( ! ruleValue || ! tree ) { + return ruleValue; + } + + /* + * Where the rule value is an object with a 'ref' property pointing + * to a path, this converts that path into the value at that path. + * For example: { "ref": "style.color.background" } => "#fff". + */ + if ( + typeof ruleValue === 'object' && + 'ref' in ruleValue && + ruleValue?.ref + ) { + const resolvedRuleValue = getCSSValueFromRawStyle( + getValueFromObjectPath( tree, ruleValue.ref ) + ) as UnresolvedValue; + + /* + * Presence of another ref indicates a reference to another dynamic value. + * Pointing to another dynamic value is not supported. + */ + if ( + typeof resolvedRuleValue === 'object' && + resolvedRuleValue !== null && + 'ref' in resolvedRuleValue && + resolvedRuleValue?.ref + ) { + return undefined; + } + + if ( resolvedRuleValue === undefined ) { + return ruleValue; + } + + return resolvedRuleValue; + } + return ruleValue; +} + +/** + * Looks up a theme file URI based on a relative path. + * + * @param file A relative path. + * @param themeFileURIs A collection of absolute theme file URIs and their corresponding file paths. + * @return A resolved theme file URI, if one is found in the themeFileURIs collection. + */ +export function getResolvedThemeFilePath( + file: string, + themeFileURIs?: ThemeFileLink[] +) { + if ( ! file || ! themeFileURIs || ! Array.isArray( themeFileURIs ) ) { + return file; + } + + const uri = themeFileURIs.find( + ( themeFileUri ) => themeFileUri?.name === file + ); + + if ( ! uri?.href ) { + return file; + } + + return uri?.href; +} + +/** + * Resolves ref and relative path values in theme JSON. + * + * @param ruleValue A block style value that may contain a reference to a theme.json value. + * @param tree A theme.json object. + * @return The resolved value or incoming ruleValue. + */ +export function getResolvedValue( + ruleValue: UnresolvedValue, + tree: GlobalStylesConfig | undefined +) { + if ( ! ruleValue || ! tree ) { + return ruleValue; + } + + // Resolve ref values. + const resolvedValue = getResolvedRefValue( ruleValue, tree ); + + // Resolve relative paths. + if ( + typeof resolvedValue === 'object' && + resolvedValue !== null && + 'url' in resolvedValue && + resolvedValue?.url + ) { + resolvedValue.url = getResolvedThemeFilePath( + resolvedValue.url, + tree?._links?.[ 'wp:theme-file' ] + ); + } + + return resolvedValue; +} + +function findInPresetsBy( + settings: GlobalStylesSettings, + blockName?: string, + presetPath: string[] = [], + presetProperty: string = 'slug', + presetValueValue?: string +) { + // Block presets take priority above root level presets. + const orderedPresetsByOrigin = [ + blockName + ? getValueFromObjectPath( settings, [ + 'blocks', + blockName, + ...presetPath, + ] ) + : undefined, + getValueFromObjectPath( settings, presetPath ), + ].filter( Boolean ); + + for ( const presetByOrigin of orderedPresetsByOrigin ) { + if ( presetByOrigin ) { + // Preset origins ordered by priority. + const origins = [ 'custom', 'theme', 'default' ]; + for ( const origin of origins ) { + // @ts-expect-error + const presets = presetByOrigin[ origin ]; + if ( presets ) { + const presetObject = presets.find( + ( preset: any ) => + preset[ presetProperty ] === presetValueValue + ); + if ( presetObject ) { + if ( presetProperty === 'slug' ) { + return presetObject; + } + // If there is a highest priority preset with the same slug but different value the preset we found was overwritten and should be ignored. + const highestPresetObjectWithSameSlug = findInPresetsBy( + settings, + blockName, + presetPath, + 'slug', + presetObject.slug + ); + if ( + highestPresetObjectWithSameSlug[ + presetProperty + ] === presetObject[ presetProperty ] + ) { + return presetObject; + } + return undefined; + } + } + } + } + } +} + +function getValueFromPresetVariable( + features: GlobalStylesConfig, + blockName?: string, + variable?: string, + [ presetType, slug ]: string[] = [] +) { + const metadata = PRESET_METADATA.find( + ( data ) => data.cssVarInfix === presetType + ); + if ( ! metadata || ! features.settings ) { + return variable; + } + + const presetObject = findInPresetsBy( + features.settings, + blockName, + metadata.path, + 'slug', + slug + ); + + if ( presetObject ) { + const { valueKey } = metadata; + const result = presetObject[ valueKey ]; + return getValueFromVariable( features, blockName, result ); + } + + return variable; +} + +function getValueFromCustomVariable( + features: GlobalStylesConfig, + blockName?: string, + variable?: string, + path: string[] = [] +): string | undefined { + const result = + ( blockName + ? getValueFromObjectPath( features?.settings ?? {}, [ + 'blocks', + blockName, + 'custom', + ...path, + ] ) + : undefined ) ?? + getValueFromObjectPath( features?.settings ?? {}, [ + 'custom', + ...path, + ] ); + if ( ! result ) { + return variable; + } + // A variable may reference another variable so we need recursion until we find the value. + return getValueFromVariable( features, blockName, result as string ); +} + +/** + * Attempts to fetch the value of a theme.json CSS variable. + * + * This function resolves CSS variable references in two formats: + * - User format: `var:preset|color|red` or `var:custom|spacing|small` + * - Theme format: `var(--wp--preset--color--red)` or `var(--wp--custom--spacing--small)` + * + * It also handles ref-style variables in the format `{ ref: "path.to.value" }`. + * + * @param features GlobalStylesContext config (user, base, or merged). Represents the theme.json tree. + * @param blockName The name of a block as represented in the styles property. E.g., 'root' for root-level, and 'core/block-name' for blocks. + * @param variable An incoming style value. A CSS var value is expected, but it could be any value. + * @return The value of the CSS var, if found. If not found, returns the original variable argument. + */ +export function getValueFromVariable( + features: GlobalStylesConfig, + blockName?: string, + variable?: string | UnresolvedValue +): any { + if ( ! variable || typeof variable !== 'string' ) { + if ( + typeof variable === 'object' && + variable !== null && + 'ref' in variable && + typeof variable.ref === 'string' + ) { + const resolvedVariable = getValueFromObjectPath( + features, + variable.ref + ); + // Presence of another ref indicates a reference to another dynamic value. + // Pointing to another dynamic value is not supported. + if ( + ! resolvedVariable || + ( typeof resolvedVariable === 'object' && + 'ref' in resolvedVariable ) + ) { + return resolvedVariable; + } + variable = resolvedVariable as string; + } else { + return variable; + } + } + const USER_VALUE_PREFIX = 'var:'; + const THEME_VALUE_PREFIX = 'var(--wp--'; + const THEME_VALUE_SUFFIX = ')'; + + let parsedVar; + + if ( variable.startsWith( USER_VALUE_PREFIX ) ) { + parsedVar = variable.slice( USER_VALUE_PREFIX.length ).split( '|' ); + } else if ( + variable.startsWith( THEME_VALUE_PREFIX ) && + variable.endsWith( THEME_VALUE_SUFFIX ) + ) { + parsedVar = variable + .slice( THEME_VALUE_PREFIX.length, -THEME_VALUE_SUFFIX.length ) + .split( '--' ); + } else { + // We don't know how to parse the value: either is raw of uses complex CSS such as `calc(1px * var(--wp--variable) )` + return variable; + } + + const [ type, ...path ] = parsedVar; + if ( type === 'preset' ) { + return getValueFromPresetVariable( + features, + blockName, + variable, + path + ); + } + if ( type === 'custom' ) { + return getValueFromCustomVariable( + features, + blockName, + variable, + path + ); + } + return variable; +} + +/** + * Encodes a value to a preset variable format if it matches a preset. + * This is the inverse operation of getValueFromVariable(). + * + * @example + * ```js + * const presetVar = getPresetVariableFromValue( + * globalStyles.settings, + * 'core/paragraph', + * 'color.text', + * '#ff0000' + * ); + * // If #ff0000 is the 'red' preset color, returns 'var:preset|color|red' + * // Otherwise returns '#ff0000' + * ``` + * + * @param features GlobalStylesContext settings object. + * @param blockName The name of a block (e.g., 'core/paragraph'). + * @param variableStylePath The style path (e.g., 'color.text', 'typography.fontSize'). + * @param presetPropertyValue The value to encode (e.g., '#ff0000'). + * @return The preset variable if found, otherwise the original value. + */ +export function getPresetVariableFromValue( + features: GlobalStylesSettings, + blockName: string | undefined, + variableStylePath: string, + presetPropertyValue: any +): any { + if ( ! presetPropertyValue ) { + return presetPropertyValue; + } + + const cssVarInfix = STYLE_PATH_TO_CSS_VAR_INFIX[ variableStylePath ]; + + const metadata = PRESET_METADATA.find( + ( data ) => data.cssVarInfix === cssVarInfix + ); + + if ( ! metadata ) { + // The property doesn't have preset data + // so the value should be returned as it is. + return presetPropertyValue; + } + const { valueKey, path } = metadata; + + const presetObject = findInPresetsBy( + features, + blockName, + path, + valueKey, + presetPropertyValue + ); + + if ( ! presetObject ) { + // Value wasn't found in the presets, + // so it must be a custom value. + return presetPropertyValue; + } + + return `var:preset|${ cssVarInfix }|${ presetObject.slug }`; +} diff --git a/packages/global-styles-engine/src/utils/duotone.ts b/packages/global-styles-engine/src/utils/duotone.ts new file mode 100644 index 00000000000000..cca1f14239f9f0 --- /dev/null +++ b/packages/global-styles-engine/src/utils/duotone.ts @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import { colord } from 'colord'; + +/** + * Convert a list of colors to an object of R, G, and B values. + * + * @param colors Array of RBG color strings. + * + * @return R, G, and B values. + */ +export function getValuesFromColors( colors: string[] = [] ) { + const values: { r: number[]; g: number[]; b: number[]; a: number[] } = { + r: [], + g: [], + b: [], + a: [], + }; + + colors.forEach( ( color ) => { + const rgbColor = colord( color ).toRgb(); + values.r.push( rgbColor.r / 255 ); + values.g.push( rgbColor.g / 255 ); + values.b.push( rgbColor.b / 255 ); + values.a.push( rgbColor.a ); + } ); + + return values; +} + +/** + * Stylesheet for disabling a global styles duotone filter. + * + * @param selector Selector to disable the filter for. + * + * @return Filter none style. + */ +export function getDuotoneUnsetStylesheet( selector: string ) { + return `${ selector }{filter:none}`; +} + +/** + * SVG and stylesheet needed for rendering the duotone filter. + * + * @param {string} selector Selector to apply the filter to. + * @param {string} id Unique id for this duotone filter. + * + * @return {string} Duotone filter style. + */ +export function getDuotoneStylesheet( selector: string, id: string ) { + return `${ selector }{filter:url(#${ id })}`; +} + +/** + * The SVG part of the duotone filter. + * + * @param id Unique id for this duotone filter. + * @param colors Color strings from dark to light. + * + * @return Duotone SVG. + */ +export function getDuotoneFilter( id: string, colors: string[] ) { + const values = getValuesFromColors( colors ); + return ` +`; +} diff --git a/packages/global-styles-engine/src/utils/fluid.ts b/packages/global-styles-engine/src/utils/fluid.ts new file mode 100644 index 00000000000000..4b691714d08165 --- /dev/null +++ b/packages/global-styles-engine/src/utils/fluid.ts @@ -0,0 +1,311 @@ +/** + * The fluid utilities must match the backend equivalent. + * See: gutenberg_get_typography_font_size_value() in lib/block-supports/typography.php + * --------------------------------------------------------------- + */ + +// Defaults. +const DEFAULT_MAXIMUM_VIEWPORT_WIDTH = '1600px'; +const DEFAULT_MINIMUM_VIEWPORT_WIDTH = '320px'; +const DEFAULT_SCALE_FACTOR = 1; +const DEFAULT_MINIMUM_FONT_SIZE_FACTOR_MIN = 0.25; +const DEFAULT_MINIMUM_FONT_SIZE_FACTOR_MAX = 0.75; +const DEFAULT_MINIMUM_FONT_SIZE_LIMIT = '14px'; + +/** + * Computes a fluid font-size value that uses clamp(). A minimum and maximum + * font size OR a single font size can be specified. + * + * If a single font size is specified, it is scaled up and down using a logarithmic scale. + * + * @example + * ```js + * // Calculate fluid font-size value from a minimum and maximum value. + * const fontSize = getComputedFluidTypographyValue( { + * minimumFontSize: '20px', + * maximumFontSize: '45px' + * } ); + * // Calculate fluid font-size value from a single font size. + * const fontSize = getComputedFluidTypographyValue( { + * fontSize: '30px', + * } ); + * ``` + * + * @param {Object} args + * @param {?string} args.minimumViewportWidth Minimum viewport size from which type will have fluidity. Optional if fontSize is specified. + * @param {?string} args.maximumViewportWidth Maximum size up to which type will have fluidity. Optional if fontSize is specified. + * @param {string|number} [args.fontSize] Size to derive maximumFontSize and minimumFontSize from, if necessary. Optional if minimumFontSize and maximumFontSize are specified. + * @param {?string} args.maximumFontSize Maximum font size for any clamp() calculation. Optional. + * @param {?string} args.minimumFontSize Minimum font size for any clamp() calculation. Optional. + * @param {?number} args.scaleFactor A scale factor to determine how fast a font scales within boundaries. Optional. + * @param {?string} args.minimumFontSizeLimit The smallest a calculated font size may be. Optional. + * + * @return {string|null} A font-size value using clamp(). + */ +export function getComputedFluidTypographyValue( { + minimumFontSize, + maximumFontSize, + fontSize, + minimumViewportWidth = DEFAULT_MINIMUM_VIEWPORT_WIDTH, + maximumViewportWidth = DEFAULT_MAXIMUM_VIEWPORT_WIDTH, + scaleFactor = DEFAULT_SCALE_FACTOR, + minimumFontSizeLimit, +}: { + minimumFontSize?: string; + maximumFontSize?: string; + fontSize?: string | number; + minimumViewportWidth?: string; + maximumViewportWidth?: string; + scaleFactor?: number; + minimumFontSizeLimit?: string; +} ) { + // Validate incoming settings and set defaults. + minimumFontSizeLimit = !! getTypographyValueAndUnit( minimumFontSizeLimit ) + ? minimumFontSizeLimit + : DEFAULT_MINIMUM_FONT_SIZE_LIMIT; + + /* + * Calculates missing minimumFontSize and maximumFontSize from + * defaultFontSize if provided. + */ + if ( fontSize ) { + // Parses default font size. + const fontSizeParsed = getTypographyValueAndUnit( fontSize ); + + // Protect against invalid units. + if ( ! fontSizeParsed?.unit || ! fontSizeParsed?.value ) { + return null; + } + + // Parses the minimum font size limit, so we can perform checks using it. + const minimumFontSizeLimitParsed = getTypographyValueAndUnit( + minimumFontSizeLimit, + { + coerceTo: fontSizeParsed.unit, + } + ); + + // Don't enforce minimum font size if a font size has explicitly set a min and max value. + if ( + !! minimumFontSizeLimitParsed?.value && + ! minimumFontSize && + ! maximumFontSize + ) { + /* + * If a minimum size was not passed to this function + * and the user-defined font size is lower than $minimum_font_size_limit, + * do not calculate a fluid value. + */ + if ( fontSizeParsed?.value <= minimumFontSizeLimitParsed?.value ) { + return null; + } + } + + // If no fluid max font size is available use the incoming value. + if ( ! maximumFontSize ) { + maximumFontSize = `${ fontSizeParsed.value }${ fontSizeParsed.unit }`; + } + + /* + * If no minimumFontSize is provided, create one using + * the given font size multiplied by the min font size scale factor. + */ + if ( ! minimumFontSize ) { + const fontSizeValueInPx = + fontSizeParsed.unit === 'px' + ? fontSizeParsed.value + : fontSizeParsed.value * 16; + + /* + * The scale factor is a multiplier that affects how quickly the curve will move towards the minimum, + * that is, how quickly the size factor reaches 0 given increasing font size values. + * For a - b * log2(), lower values of b will make the curve move towards the minimum faster. + * The scale factor is constrained between min and max values. + */ + const minimumFontSizeFactor = Math.min( + Math.max( + 1 - 0.075 * Math.log2( fontSizeValueInPx ), + DEFAULT_MINIMUM_FONT_SIZE_FACTOR_MIN + ), + DEFAULT_MINIMUM_FONT_SIZE_FACTOR_MAX + ); + + // Calculates the minimum font size. + const calculatedMinimumFontSize = roundToPrecision( + fontSizeParsed.value * minimumFontSizeFactor, + 3 + ) as number; + + // Only use calculated min font size if it's > $minimum_font_size_limit value. + if ( + !! minimumFontSizeLimitParsed?.value && + calculatedMinimumFontSize < minimumFontSizeLimitParsed?.value + ) { + minimumFontSize = `${ minimumFontSizeLimitParsed.value }${ minimumFontSizeLimitParsed.unit }`; + } else { + minimumFontSize = `${ calculatedMinimumFontSize }${ fontSizeParsed.unit }`; + } + } + } + + // Grab the minimum font size and normalize it in order to use the value for calculations. + const minimumFontSizeParsed = getTypographyValueAndUnit( minimumFontSize ); + + // We get a 'preferred' unit to keep units consistent when calculating, + // otherwise the result will not be accurate. + const fontSizeUnit = minimumFontSizeParsed?.unit || 'rem'; + + // Grabs the maximum font size and normalize it in order to use the value for calculations. + const maximumFontSizeParsed = getTypographyValueAndUnit( maximumFontSize, { + coerceTo: fontSizeUnit, + } ); + + // Checks for mandatory min and max sizes, and protects against unsupported units. + if ( ! minimumFontSizeParsed || ! maximumFontSizeParsed ) { + return null; + } + + // Uses rem for accessible fluid target font scaling. + const minimumFontSizeRem = getTypographyValueAndUnit( minimumFontSize, { + coerceTo: 'rem', + } ); + + // Viewport widths defined for fluid typography. Normalize units + const maximumViewportWidthParsed = getTypographyValueAndUnit( + maximumViewportWidth, + { coerceTo: fontSizeUnit } + ); + const minimumViewportWidthParsed = getTypographyValueAndUnit( + minimumViewportWidth, + { coerceTo: fontSizeUnit } + ); + + // Protect against unsupported units. + if ( + ! maximumViewportWidthParsed || + ! minimumViewportWidthParsed || + ! minimumFontSizeRem + ) { + return null; + } + + // Calculates the linear factor denominator. If it's 0, we cannot calculate a fluid value. + const linearDenominator = + maximumViewportWidthParsed.value - minimumViewportWidthParsed.value; + if ( ! linearDenominator ) { + return null; + } + + // Build CSS rule. + // Borrowed from https://websemantics.uk/tools/responsive-font-calculator/. + const minViewportWidthOffsetValue = roundToPrecision( + minimumViewportWidthParsed.value / 100, + 3 + ); + + const viewportWidthOffset = + roundToPrecision( minViewportWidthOffsetValue, 3 ) + fontSizeUnit; + const linearFactor = + 100 * + ( ( maximumFontSizeParsed.value - minimumFontSizeParsed.value ) / + linearDenominator ); + const linearFactorScaled = roundToPrecision( + ( linearFactor || 1 ) * scaleFactor, + 3 + ); + const fluidTargetFontSize = `${ minimumFontSizeRem.value }${ minimumFontSizeRem.unit } + ((1vw - ${ viewportWidthOffset }) * ${ linearFactorScaled })`; + + return `clamp(${ minimumFontSize }, ${ fluidTargetFontSize }, ${ maximumFontSize })`; +} + +/** + * Internal method that checks a string for a unit and value and returns an array consisting of `'value'` and `'unit'`, e.g., [ '42', 'rem' ]. + * A raw font size of `value + unit` is expected. If the value is an integer, it will convert to `value + 'px'`. + * + * @param rawValue Raw size value from theme.json. + * @param options Calculation options. + * + * @return An object consisting of `'value'` and `'unit'` properties. + */ +export function getTypographyValueAndUnit( + rawValue?: string | number, + options = {} +) { + if ( typeof rawValue !== 'string' && typeof rawValue !== 'number' ) { + return null; + } + + // Converts numeric values to pixel values by default. + if ( isFinite( rawValue as number ) ) { + rawValue = `${ rawValue }px`; + } + + const { coerceTo, rootSizeValue, acceptableUnits } = { + coerceTo: '', + // Default browser font size. Later we could inject some JS to compute this `getComputedStyle( document.querySelector( "html" ) ).fontSize`. + rootSizeValue: 16, + acceptableUnits: [ 'rem', 'px', 'em' ], + ...options, + }; + + const acceptableUnitsGroup = acceptableUnits?.join( '|' ); + const regexUnits = new RegExp( + `^(\\d*\\.?\\d+)(${ acceptableUnitsGroup }){1,1}$` + ); + + const matches = rawValue.toString().match( regexUnits ); + + // We need a number value and a unit. + if ( ! matches || matches.length < 3 ) { + return null; + } + + let [ , value, unit ] = matches; + + let returnValue = parseFloat( value ); + + if ( 'px' === coerceTo && ( 'em' === unit || 'rem' === unit ) ) { + returnValue = returnValue * rootSizeValue; + unit = coerceTo; + } + + if ( 'px' === unit && ( 'em' === coerceTo || 'rem' === coerceTo ) ) { + returnValue = returnValue / rootSizeValue; + unit = coerceTo; + } + + /* + * No calculation is required if swapping between em and rem yet, + * since we assume a root size value. Later we might like to differentiate between + * :root font size (rem) and parent element font size (em) relativity. + */ + if ( + ( 'em' === coerceTo || 'rem' === coerceTo ) && + ( 'em' === unit || 'rem' === unit ) + ) { + unit = coerceTo; + } + + if ( ! unit ) { + return null; + } + + return { + value: roundToPrecision( returnValue, 3 ), + unit, + }; +} + +/** + * Returns a value rounded to defined precision. + * Returns `undefined` if the value is not a valid finite number. + * + * @param value Raw value. + * @param digits The number of digits to appear after the decimal point + * + * @return Value rounded to standard precision. + */ +export function roundToPrecision( value: number, digits: number = 3 ) { + const base = Math.pow( 10, digits ); + return Math.round( value * base ) / base; +} diff --git a/packages/global-styles-engine/src/utils/gap.ts b/packages/global-styles-engine/src/utils/gap.ts new file mode 100644 index 00000000000000..c33c6338ea529d --- /dev/null +++ b/packages/global-styles-engine/src/utils/gap.ts @@ -0,0 +1,56 @@ +/** + * Internal dependencies + */ +import { getSpacingPresetCssVar } from './spacing'; + +/** + * Returns a BoxControl object value from a given blockGap style value. + * The string check is for backwards compatibility before Gutenberg supported + * split gap values (row and column) and the value was a string n + unit. + * + * @param blockGapValue A block gap string or axial object value, e.g., '10px' or { top: '10px', left: '10px'}. + * @return A value to pass to the BoxControl component. + */ +export function getGapBoxControlValueFromStyle( + blockGapValue?: string | { top: string; left: string } +) { + if ( ! blockGapValue ) { + return null; + } + + const isValueString = typeof blockGapValue === 'string'; + return { + top: isValueString ? blockGapValue : blockGapValue?.top, + left: isValueString ? blockGapValue : blockGapValue?.left, + }; +} + +/** + * Returns a CSS value for the `gap` property from a given blockGap style. + * + * @param blockGapValue A block gap string or axial object value, e.g., '10px' or { top: '10px', left: '10px'}. + * @param defaultValue A default gap value. + * @return The concatenated gap value (row and column). + */ +export function getGapCSSValue( + blockGapValue?: + | string + | { + top: string; + left: string; + }, + defaultValue: string = '0' +) { + const blockGapBoxControlValue = + getGapBoxControlValueFromStyle( blockGapValue ); + if ( ! blockGapBoxControlValue ) { + return null; + } + + const row = + getSpacingPresetCssVar( blockGapBoxControlValue?.top ) || defaultValue; + const column = + getSpacingPresetCssVar( blockGapBoxControlValue?.left ) || defaultValue; + + return row === column ? row : `${ row } ${ column }`; +} diff --git a/packages/global-styles-engine/src/utils/layout.ts b/packages/global-styles-engine/src/utils/layout.ts new file mode 100644 index 00000000000000..a5c6d431c50f20 --- /dev/null +++ b/packages/global-styles-engine/src/utils/layout.ts @@ -0,0 +1,174 @@ +// Layout definitions keyed by layout type. +// Provides a common definition of slugs, classnames, base styles, and spacing styles for each layout type. +// If making changes or additions to layout definitions, be sure to update the corresponding PHP definitions in +// `block-supports/layout.php` so that the server-side and client-side definitions match. +export const LAYOUT_DEFINITIONS = { + default: { + name: 'default', + slug: 'flow', + className: 'is-layout-flow', + baseStyles: [ + { + selector: ' > .alignleft', + rules: { + float: 'left', + 'margin-inline-start': '0', + 'margin-inline-end': '2em', + }, + }, + { + selector: ' > .alignright', + rules: { + float: 'right', + 'margin-inline-start': '2em', + 'margin-inline-end': '0', + }, + }, + { + selector: ' > .aligncenter', + rules: { + 'margin-left': 'auto !important', + 'margin-right': 'auto !important', + }, + }, + ], + spacingStyles: [ + { + selector: ' > :first-child', + rules: { + 'margin-block-start': '0', + }, + }, + { + selector: ' > :last-child', + rules: { + 'margin-block-end': '0', + }, + }, + { + selector: ' > *', + rules: { + 'margin-block-start': null, + 'margin-block-end': '0', + }, + }, + ], + }, + constrained: { + name: 'constrained', + slug: 'constrained', + className: 'is-layout-constrained', + baseStyles: [ + { + selector: ' > .alignleft', + rules: { + float: 'left', + 'margin-inline-start': '0', + 'margin-inline-end': '2em', + }, + }, + { + selector: ' > .alignright', + rules: { + float: 'right', + 'margin-inline-start': '2em', + 'margin-inline-end': '0', + }, + }, + { + selector: ' > .aligncenter', + rules: { + 'margin-left': 'auto !important', + 'margin-right': 'auto !important', + }, + }, + { + selector: + ' > :where(:not(.alignleft):not(.alignright):not(.alignfull))', + rules: { + 'max-width': 'var(--wp--style--global--content-size)', + 'margin-left': 'auto !important', + 'margin-right': 'auto !important', + }, + }, + { + selector: ' > .alignwide', + rules: { + 'max-width': 'var(--wp--style--global--wide-size)', + }, + }, + ], + spacingStyles: [ + { + selector: ' > :first-child', + rules: { + 'margin-block-start': '0', + }, + }, + { + selector: ' > :last-child', + rules: { + 'margin-block-end': '0', + }, + }, + { + selector: ' > *', + rules: { + 'margin-block-start': null, + 'margin-block-end': '0', + }, + }, + ], + }, + flex: { + name: 'flex', + slug: 'flex', + className: 'is-layout-flex', + displayMode: 'flex', + baseStyles: [ + { + selector: '', + rules: { + 'flex-wrap': 'wrap', + 'align-items': 'center', + }, + }, + { + selector: ' > :is(*, div)', // :is(*, div) instead of just * increases the specificity by 001. + rules: { + margin: '0', + }, + }, + ], + spacingStyles: [ + { + selector: '', + rules: { + gap: null, + }, + }, + ], + }, + grid: { + name: 'grid', + slug: 'grid', + className: 'is-layout-grid', + displayMode: 'grid', + baseStyles: [ + { + selector: ' > :is(*, div)', // :is(*, div) instead of just * increases the specificity by 001. + rules: { + margin: '0', + }, + }, + ], + spacingStyles: [ + { + selector: '', + rules: { + gap: null, + }, + }, + ], + }, +}; diff --git a/packages/global-styles-engine/src/utils/object.ts b/packages/global-styles-engine/src/utils/object.ts new file mode 100644 index 00000000000000..cdbc1890a2f1d4 --- /dev/null +++ b/packages/global-styles-engine/src/utils/object.ts @@ -0,0 +1,64 @@ +/** + * Immutably sets a value inside an object. Like `lodash#set`, but returning a + * new object. Treats nullish initial values as empty objects. Clones any + * nested objects. Supports arrays, too. + * + * @param object Object to set a value in. + * @param path Path in the object to modify. + * @param value New value to set. + * @return Cloned object with the new value set. + */ +export function setImmutably( + object: Object, + path: string | number | ( string | number )[], + value: any +) { + // Normalize path + path = Array.isArray( path ) ? [ ...path ] : [ path ]; + + // Shallowly clone the base of the object + object = Array.isArray( object ) ? [ ...object ] : { ...object }; + + const leaf = path.pop(); + + // Traverse object from root to leaf, shallowly cloning at each level + let prev = object; + for ( const key of path ) { + // @ts-expect-error + const lvl = prev[ key ]; + // @ts-expect-error + prev = prev[ key ] = Array.isArray( lvl ) ? [ ...lvl ] : { ...lvl }; + } + // @ts-expect-error + prev[ leaf ] = value; + + return object; +} + +/** + * Helper util to return a value from a certain path of the object. + * + * Path is specified as either: + * - a string of properties, separated by dots, for example: "x.y". + * - an array of properties, for example `[ 'x', 'y' ]`. + * + * You can also specify a default value in case the result is nullish. + * + * @param object Input object. + * @param path Path to the object property. + * @param defaultValue Default value if the value at the specified path is nullish. + * @return Value of the object property at the specified path. + */ +export const getValueFromObjectPath = ( + object: Object, + path: string | string[], + defaultValue?: any +) => { + const arrayPath = Array.isArray( path ) ? path : path.split( '.' ); + let value = object; + arrayPath.forEach( ( fieldName ) => { + // @ts-expect-error + value = value?.[ fieldName ]; + } ); + return value ?? defaultValue; +}; diff --git a/packages/global-styles-engine/src/utils/spacing.ts b/packages/global-styles-engine/src/utils/spacing.ts new file mode 100644 index 00000000000000..7edd38d33bf0ea --- /dev/null +++ b/packages/global-styles-engine/src/utils/spacing.ts @@ -0,0 +1,13 @@ +export function getSpacingPresetCssVar( value?: string ) { + if ( ! value ) { + return; + } + + const slug = value.match( /var:preset\|spacing\|(.+)/ ); + + if ( ! slug ) { + return value; + } + + return `var(--wp--preset--spacing--${ slug[ 1 ] })`; +} diff --git a/packages/global-styles-engine/src/utils/string.ts b/packages/global-styles-engine/src/utils/string.ts new file mode 100644 index 00000000000000..4740ff976d0404 --- /dev/null +++ b/packages/global-styles-engine/src/utils/string.ts @@ -0,0 +1,15 @@ +/** + * Converts a string to kebab-case. + * Matches WordPress kebabCase behavior. + * + * @param str The string to convert + * @return The kebab-cased string + */ +export function kebabCase( str: string ): string { + return str + .replace( /([a-z])([A-Z])/g, '$1-$2' ) // camelCase to kebab-case + .replace( /([0-9])([a-zA-Z])/g, '$1-$2' ) // number followed by letter + .replace( /([a-zA-Z])([0-9])/g, '$1-$2' ) // letter followed by number + .replace( /[\s_]+/g, '-' ) // spaces and underscores to hyphens + .toLowerCase(); +} diff --git a/packages/global-styles-engine/src/utils/typography.ts b/packages/global-styles-engine/src/utils/typography.ts new file mode 100644 index 00000000000000..b1a6d0d9f407da --- /dev/null +++ b/packages/global-styles-engine/src/utils/typography.ts @@ -0,0 +1,142 @@ +/** + * Internal dependencies + */ +import type { + TypographyPreset, + GlobalStylesSettings, + FluidTypographySettings, + TypographySettings, +} from '../types'; +import { + getTypographyValueAndUnit, + getComputedFluidTypographyValue, +} from './fluid'; + +/** + * Checks if fluid typography is enabled in the given typography settings. + * + * Fluid typography is considered enabled if the fluid setting is explicitly set to true, + * or if it's an object with properties (which would contain fluid typography configuration + * like minViewportWidth, maxViewportWidth, etc.). + * + * @param typographySettings Typography settings object that may contain fluid typography configuration. + * @param typographySettings.fluid Fluid typography configuration. Can be: + * - `true` to enable with default settings + * - An object with fluid settings (minViewportWidth, maxViewportWidth, etc.) + * - `false` or `undefined` to disable + * + * @return True if fluid typography is enabled, false otherwise. + */ +function isFluidTypographyEnabled( + typographySettings?: TypographySettings | TypographyPreset +) { + const fluidSettings = typographySettings?.fluid; + return ( + true === fluidSettings || + ( fluidSettings && + typeof fluidSettings === 'object' && + Object.keys( fluidSettings ).length > 0 ) + ); +} + +/** + * Returns fluid typography settings from theme.json setting object. + * + * @param settings Theme.json settings + * @param settings.typography Theme.json typography settings + * @param settings.layout Theme.json layout settings + * @return Fluid typography settings + */ +export function getFluidTypographyOptionsFromSettings( + settings: GlobalStylesSettings +): { fluid?: FluidTypographySettings | boolean | undefined } { + const typographySettings = settings?.typography ?? {}; + const layoutSettings = settings?.layout; + const defaultMaxViewportWidth = getTypographyValueAndUnit( + layoutSettings?.wideSize + ) + ? layoutSettings?.wideSize + : null; + return isFluidTypographyEnabled( typographySettings ) && + defaultMaxViewportWidth + ? { + fluid: { + maxViewportWidth: defaultMaxViewportWidth, + ...( typeof typographySettings.fluid === 'object' + ? typographySettings.fluid + : {} ), + }, + } + : { + fluid: typographySettings?.fluid, + }; +} + +/** + * Returns a font-size value based on a given font-size preset. + * Takes into account fluid typography parameters and attempts to return a css formula depending on available, valid values. + * + * The Core PHP equivalent is wp_get_typography_font_size_value(). + * + * @param preset A typography preset object containing size and fluid properties. + * @param settings Global styles settings object containing typography and layout settings. + * + * @return A font-size value or the value of preset.size. + */ +export function getTypographyFontSizeValue( + preset: TypographyPreset, + settings: GlobalStylesSettings +) { + const { size: defaultSize } = preset; + + /* + * Catch falsy values and 0/'0'. Fluid calculations cannot be performed on `0`. + * Also return early when a preset font size explicitly disables fluid typography with `false`. + */ + if ( ! defaultSize || '0' === defaultSize || false === preset?.fluid ) { + return defaultSize; + } + + /* + * Return early when fluid typography is disabled in the settings, and there + * are no local settings to enable it for the individual preset. + * + * If this condition isn't met, either the settings or individual preset settings + * have enabled fluid typography. + */ + if ( + ! isFluidTypographyEnabled( settings?.typography ) && + ! isFluidTypographyEnabled( preset ) + ) { + return defaultSize; + } + + const fluidTypographySettings = + getFluidTypographyOptionsFromSettings( settings )?.fluid ?? {}; + + const fluidFontSizeValue = getComputedFluidTypographyValue( { + minimumFontSize: + typeof preset?.fluid === 'boolean' ? undefined : preset?.fluid?.min, + maximumFontSize: + typeof preset?.fluid === 'boolean' ? undefined : preset?.fluid?.max, + fontSize: defaultSize, + minimumFontSizeLimit: + typeof fluidTypographySettings === 'object' + ? fluidTypographySettings?.minFontSize + : undefined, + maximumViewportWidth: + typeof fluidTypographySettings === 'object' + ? fluidTypographySettings?.maxViewportWidth + : undefined, + minimumViewportWidth: + typeof fluidTypographySettings === 'object' + ? fluidTypographySettings?.minViewportWidth + : undefined, + } ); + + if ( !! fluidFontSizeValue ) { + return fluidFontSizeValue; + } + + return defaultSize; +} diff --git a/packages/global-styles-engine/tsconfig.json b/packages/global-styles-engine/tsconfig.json new file mode 100644 index 00000000000000..467439c0a30279 --- /dev/null +++ b/packages/global-styles-engine/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "checkJs": false, + "types": [ + "gutenberg-env", + "gutenberg-test-env", + "jest", + "@testing-library/jest-dom" + ] + }, + "references": [ + { "path": "../data" }, + { "path": "../i18n" }, + { "path": "../style-engine" } + ] +} diff --git a/tsconfig.json b/tsconfig.json index 022b70b2439b61..cf5608b93c69d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,9 +28,9 @@ { "path": "packages/escape-html" }, { "path": "packages/eslint-plugin" }, { "path": "packages/fields" }, + { "path": "packages/global-styles-engine" }, { "path": "packages/hooks" }, { "path": "packages/html-entities" }, - { "path": "packages/html-entities" }, { "path": "packages/i18n" }, { "path": "packages/icons" }, { "path": "packages/interactivity" },