diff --git a/docs/manifest.json b/docs/manifest.json index 3ab1908240867..c5deaa3b4b3f0 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1745,6 +1745,12 @@ "markdown_source": "../packages/shortcode/README.md", "parent": "packages" }, + { + "title": "@wordpress/style-engine", + "slug": "packages-style-engine", + "markdown_source": "../packages/style-engine/README.md", + "parent": "packages" + }, { "title": "@wordpress/stylelint-config", "slug": "packages-stylelint-config", diff --git a/package-lock.json b/package-lock.json index 0c3802a1fab78..d6a88a9e466f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15744,6 +15744,7 @@ "@wordpress/notices": "file:packages/notices", "@wordpress/rich-text": "file:packages/rich-text", "@wordpress/shortcode": "file:packages/shortcode", + "@wordpress/style-engine": "file:packages/style-engine", "@wordpress/token-list": "file:packages/token-list", "@wordpress/url": "file:packages/url", "@wordpress/warning": "file:packages/warning", @@ -16215,6 +16216,7 @@ "@wordpress/notices": "file:packages/notices", "@wordpress/plugins": "file:packages/plugins", "@wordpress/reusable-blocks": "file:packages/reusable-blocks", + "@wordpress/style-engine": "file:packages/style-engine", "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", "classnames": "^2.3.1", @@ -16911,6 +16913,13 @@ "memize": "^1.1.0" } }, + "@wordpress/style-engine": { + "version": "file:packages/style-engine", + "requires": { + "@babel/runtime": "^7.16.0", + "lodash": "^4.17.21" + } + }, "@wordpress/stylelint-config": { "version": "file:packages/stylelint-config", "dev": true, diff --git a/package.json b/package.json index ab2f863cc4530..6895168e5a3bc 100755 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@wordpress/rich-text": "file:packages/rich-text", "@wordpress/server-side-render": "file:packages/server-side-render", "@wordpress/shortcode": "file:packages/shortcode", + "@wordpress/style-engine": "file:packages/style-engine", "@wordpress/token-list": "file:packages/token-list", "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index c8d527323e527..ee27b0305da0a 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -53,6 +53,7 @@ "@wordpress/notices": "file:../notices", "@wordpress/rich-text": "file:../rich-text", "@wordpress/shortcode": "file:../shortcode", + "@wordpress/style-engine": "file:../style-engine", "@wordpress/token-list": "file:../token-list", "@wordpress/url": "file:../url", "@wordpress/warning": "file:../warning", diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 5882d5275937b..c6f0ee91a2f2b 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -27,6 +27,7 @@ import { __EXPERIMENTAL_ELEMENTS as ELEMENTS, } from '@wordpress/blocks'; import { createHigherOrderComponent, useInstanceId } from '@wordpress/compose'; +import { getCSSRules } from '@wordpress/style-engine'; /** * Internal dependencies @@ -85,21 +86,35 @@ export function getInlineStyles( styles = {} ) { // option and backwards compatibility for border radius support. const styleValue = get( styles, path ); - if ( !! subPaths && ! isString( styleValue ) ) { - Object.entries( subPaths ).forEach( ( entry ) => { - const [ name, subPath ] = entry; - const value = get( styleValue, [ subPath ] ); - - if ( value ) { - output[ name ] = compileStyleValue( value ); - } - } ); - } else if ( ! ignoredStyles.includes( path.join( '.' ) ) ) { - output[ propKey ] = compileStyleValue( get( styles, path ) ); + if ( ! STYLE_PROPERTY[ propKey ].useEngine ) { + if ( !! subPaths && ! isString( styleValue ) ) { + Object.entries( subPaths ).forEach( ( entry ) => { + const [ name, subPath ] = entry; + const value = get( styleValue, [ subPath ] ); + + if ( value ) { + output[ name ] = compileStyleValue( value ); + } + } ); + } else if ( ! ignoredStyles.includes( path.join( '.' ) ) ) { + output[ propKey ] = compileStyleValue( + get( styles, path ) + ); + } } } } ); + // The goal is to move everything to server side generated engine styles + // This is temporary as we absorb more and more styles into the engine. + const extraRules = getCSSRules( styles, { selector: 'self' } ); + extraRules.forEach( ( rule ) => { + if ( rule.selector !== 'self' ) { + throw "This style can't be added as inline style"; + } + output[ rule.key ] = rule.value; + } ); + return output; } diff --git a/packages/blocks/src/api/constants.js b/packages/blocks/src/api/constants.js index 674caea6af4c9..a237402e143f9 100644 --- a/packages/blocks/src/api/constants.js +++ b/packages/blocks/src/api/constants.js @@ -99,6 +99,7 @@ export const __EXPERIMENTAL_STYLE_PROPERTY = { paddingBottom: 'bottom', paddingLeft: 'left', }, + useEngine: true, }, textDecoration: { value: [ 'typography', 'textDecoration' ], diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js index a22837af6a72e..87d67f8dbd68f 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/util.js +++ b/packages/dependency-extraction-webpack-plugin/lib/util.js @@ -1,5 +1,9 @@ const WORDPRESS_NAMESPACE = '@wordpress/'; -const BUNDLED_PACKAGES = [ '@wordpress/icons', '@wordpress/interface' ]; +const BUNDLED_PACKAGES = [ + '@wordpress/icons', + '@wordpress/interface', + '@wordpress/style-engine', +]; /** * Default request to global transformation diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 698f6234e0bae..23b01517c6f6b 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -50,6 +50,7 @@ "@wordpress/notices": "file:../notices", "@wordpress/plugins": "file:../plugins", "@wordpress/reusable-blocks": "file:../reusable-blocks", + "@wordpress/style-engine": "file:../style-engine", "@wordpress/url": "file:../url", "@wordpress/viewport": "file:../viewport", "classnames": "^2.3.1", diff --git a/packages/edit-site/src/components/global-styles/use-global-styles-output.js b/packages/edit-site/src/components/global-styles/use-global-styles-output.js index b3ca7bd9ba016..72ed5a5afe32e 100644 --- a/packages/edit-site/src/components/global-styles/use-global-styles-output.js +++ b/packages/edit-site/src/components/global-styles/use-global-styles-output.js @@ -23,6 +23,7 @@ import { getBlockTypes, } from '@wordpress/blocks'; import { useEffect, useState, useContext } from '@wordpress/element'; +import { getCSSRules } from '@wordpress/style-engine'; /** * Internal dependencies @@ -146,11 +147,11 @@ function flattenTree( input = {}, prefix, token ) { * @return {Array} An array of style declarations. */ function getStylesDeclarations( blockStyles = {} ) { - return reduce( + const output = reduce( STYLE_PROPERTY, - ( declarations, { value, properties }, key ) => { + ( declarations, { value, properties, useEngine }, key ) => { const pathToValue = value; - if ( first( pathToValue ) === 'elements' ) { + if ( first( pathToValue ) === 'elements' || useEngine ) { return declarations; } @@ -188,6 +189,21 @@ function getStylesDeclarations( blockStyles = {} ) { }, [] ); + + // The goal is to move everything to server side generated engine styles + // This is temporary as we absorb more and more styles into the engine. + const extraRules = getCSSRules( blockStyles, { selector: 'self' } ); + extraRules.forEach( ( rule ) => { + if ( rule.selector !== 'self' ) { + throw "This style can't be added as inline style"; + } + const cssProperty = rule.key.startsWith( '--' ) + ? rule.key + : kebabCase( rule.key ); + output.push( `${ cssProperty }: ${ compileStyleValue( rule.value ) }` ); + } ); + + return output; } export const getNodesWithStyles = ( tree, blockSelectors ) => { diff --git a/packages/style-engine/.npmrc b/packages/style-engine/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/packages/style-engine/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/style-engine/CHANGELOG.md b/packages/style-engine/CHANGELOG.md new file mode 100644 index 0000000000000..6367d4fc4b6ce --- /dev/null +++ b/packages/style-engine/CHANGELOG.md @@ -0,0 +1,7 @@ + + +## Unreleased + +### New Feature + +- Added initial version of the style engine ([#37978](https://github.com/WordPress/gutenberg/pull/37978)). diff --git a/packages/style-engine/README.md b/packages/style-engine/README.md new file mode 100644 index 0000000000000..612097cc49735 --- /dev/null +++ b/packages/style-engine/README.md @@ -0,0 +1,62 @@ +# Style Engine + +The Style Engine powering global styles and block customizations. + +## Installation + +Install the module + +```bash +npm install @wordpress/style-engine --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ + +## Important + +This Package is considered experimental at the moment. The idea is to have a package used to generate styles based on a style object that is consistent between: backend, frontend, block style object and theme.json. + +Currently it's not a package that generates a wp.styleEngine global because it's not ready yet, it's still a bundled package but ultimately, we want it to be so, once the roadmap is finished: + +**TODO List:** + +- Add style definitions for all the currently supported styles in blocks and theme.json. +- the CSS variable shortcuts for values (for presets...) +- Support generating styles in the frontend. +- Support generating styles in the backend (block supports and theme.json stylesheet). +- Refactor all block styles to use the style engine server side. +- Refactor all blocks to consistently use the "style" attribute for all customizations (get rid of the preset specific attributes). + +## Usage + + + +### generate + +Generates a stylesheet for a given style object and selector. + +_Parameters_ + +- _style_ `Style`: Style object. +- _options_ `StyleOptions`: Options object with settings to adjust how the styles are generated. + +_Returns_ + +- `string`: generated stylesheet. + +### getCSSRules + +Returns a JSON representation of the generated CSS rules. + +_Parameters_ + +- _style_ `Style`: Style object. +- _options_ `StyleOptions`: Options object with settings to adjust how the styles are generated. + +_Returns_ + +- `GeneratedCSSRule[]`: generated styles. + + + +

Code is Poetry.

diff --git a/packages/style-engine/package.json b/packages/style-engine/package.json new file mode 100644 index 0000000000000..74512443ba912 --- /dev/null +++ b/packages/style-engine/package.json @@ -0,0 +1,37 @@ +{ + "name": "@wordpress/style-engine", + "version": "0.1.0", + "description": "WordPress Style engine.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "styles", + "global styles" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/style-engine/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/style-engine" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "types": "build-types", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.16.0", + "lodash": "^4.17.21" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/style-engine/src/index.ts b/packages/style-engine/src/index.ts new file mode 100644 index 0000000000000..8117e27892140 --- /dev/null +++ b/packages/style-engine/src/index.ts @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import { groupBy, kebabCase } from 'lodash'; + +/** + * Internal dependencies + */ +import type { + Style, + StyleOptions, + GeneratedCSSRule, + StyleDefinition, +} from './types'; +import { styleDefinitions } from './styles'; + +/** + * Generates a stylesheet for a given style object and selector. + * + * @param style Style object. + * @param options Options object with settings to adjust how the styles are generated. + * + * @return generated stylesheet. + */ +export function generate( style: Style, options: StyleOptions ): string { + const rules = getCSSRules( style, options ); + const groupedRules = groupBy( rules, 'selector' ); + const selectorRules = Object.keys( groupedRules ).reduce( + ( acc: string[], subSelector: string ) => { + acc.push( + `${ subSelector } { ${ groupedRules[ subSelector ] + .map( + ( rule: GeneratedCSSRule ) => + `${ kebabCase( rule.key ) }: ${ rule.value };` + ) + .join( ' ' ) } }` + ); + return acc; + }, + [] + ); + + return selectorRules.join( '\n' ); +} + +/** + * Returns a JSON representation of the generated CSS rules. + * + * @param style Style object. + * @param options Options object with settings to adjust how the styles are generated. + * + * @return generated styles. + */ +export function getCSSRules( + style: Style, + options: StyleOptions +): GeneratedCSSRule[] { + const rules: GeneratedCSSRule[] = []; + styleDefinitions.forEach( ( definition: StyleDefinition ) => { + rules.push( ...definition.generate( style, options ) ); + } ); + + return rules; +} diff --git a/packages/style-engine/src/styles/index.ts b/packages/style-engine/src/styles/index.ts new file mode 100644 index 0000000000000..2f09e42817693 --- /dev/null +++ b/packages/style-engine/src/styles/index.ts @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import padding from './padding'; + +export const styleDefinitions = [ padding ]; diff --git a/packages/style-engine/src/styles/padding.ts b/packages/style-engine/src/styles/padding.ts new file mode 100644 index 0000000000000..94b268de35604 --- /dev/null +++ b/packages/style-engine/src/styles/padding.ts @@ -0,0 +1,19 @@ +/** + * Internal dependencies + */ +import type { Style, StyleOptions } from '../types'; +import { generateBoxRules } from './utils'; + +const padding = { + name: 'padding', + generate: ( style: Style, options: StyleOptions ) => { + return generateBoxRules( + style, + options, + [ 'spacing', 'padding' ], + 'padding' + ); + }, +}; + +export default padding; diff --git a/packages/style-engine/src/styles/utils.ts b/packages/style-engine/src/styles/utils.ts new file mode 100644 index 0000000000000..5455901e49d1a --- /dev/null +++ b/packages/style-engine/src/styles/utils.ts @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { get, upperFirst } from 'lodash'; + +/** + * Internal dependencies + */ +import type { GeneratedCSSRule, Style, Box, StyleOptions } from '../types'; + +export function generateBoxRules( + style: Style, + options: StyleOptions, + path: string[], + ruleKey: string +): GeneratedCSSRule[] { + const boxStyle: Box | string | undefined = get( style, path ); + if ( ! boxStyle ) { + return []; + } + + const rules: GeneratedCSSRule[] = []; + if ( typeof boxStyle === 'string' ) { + rules.push( { + selector: options.selector, + key: ruleKey, + value: boxStyle, + } ); + } else { + const sideRules = [ 'top', 'right', 'bottom', 'left' ].reduce( + ( acc: GeneratedCSSRule[], side: string ) => { + const value: string | undefined = get( boxStyle, [ side ] ); + if ( value ) { + acc.push( { + selector: options.selector, + key: `${ ruleKey }${ upperFirst( side ) }`, + value, + } ); + } + return acc; + }, + [] + ); + rules.push( ...sideRules ); + } + + return rules; +} diff --git a/packages/style-engine/src/test/index.js b/packages/style-engine/src/test/index.js new file mode 100644 index 0000000000000..14c0deb49b806 --- /dev/null +++ b/packages/style-engine/src/test/index.js @@ -0,0 +1,83 @@ +/** + * Internal dependencies + */ +import { getCSSRules, generate } from '../index'; + +describe( 'generate', () => { + it( 'should generate empty style', () => { + expect( generate( {}, '.some-selector' ) ).toEqual( '' ); + } ); + + it( 'should generate padding styles', () => { + expect( + generate( + { + spacing: { padding: '10px' }, + }, + { + selector: '.some-selector', + } + ) + ).toEqual( '.some-selector { padding: 10px; }' ); + + expect( + generate( + { + spacing: { padding: { top: '10px', bottom: '5px' } }, + }, + { + selector: '.some-selector', + } + ) + ).toEqual( + '.some-selector { padding-top: 10px; padding-bottom: 5px; }' + ); + } ); +} ); + +describe( 'getCSSRules', () => { + it( 'should return an empty rules array', () => { + expect( getCSSRules( {}, '.some-selector' ) ).toEqual( [] ); + } ); + + it( 'should return a rules array with CSS keys formatted in camelCase', () => { + expect( + getCSSRules( + { + spacing: { padding: '10px' }, + }, + { + selector: '.some-selector', + } + ) + ).toEqual( [ + { + selector: '.some-selector', + key: 'padding', + value: '10px', + }, + ] ); + + expect( + getCSSRules( + { + spacing: { padding: { top: '10px', bottom: '5px' } }, + }, + { + selector: '.some-selector', + } + ) + ).toEqual( [ + { + selector: '.some-selector', + key: 'paddingTop', + value: '10px', + }, + { + selector: '.some-selector', + key: 'paddingBottom', + value: '5px', + }, + ] ); + } ); +} ); diff --git a/packages/style-engine/src/types.ts b/packages/style-engine/src/types.ts new file mode 100644 index 0000000000000..4046324171360 --- /dev/null +++ b/packages/style-engine/src/types.ts @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import type { CSSProperties } from 'react'; + +export type Box = { + top?: CSSProperties[ 'top' ]; + right?: CSSProperties[ 'right' ]; + bottom?: CSSProperties[ 'bottom' ]; + left?: CSSProperties[ 'left' ]; +}; + +export interface Style { + spacing?: { + margin?: CSSProperties[ 'margin' ] | Box; + padding?: CSSProperties[ 'padding' ] | Box; + }; + typography?: { + fontSize?: CSSProperties[ 'fontSize' ]; + fontFamily?: CSSProperties[ 'fontFamily' ]; + fontWeight?: CSSProperties[ 'fontWeight' ]; + fontStyle?: CSSProperties[ 'fontStyle' ]; + letterSpacing?: CSSProperties[ 'letterSpacing' ]; + lineHeight?: CSSProperties[ 'lineHeight' ]; + textDecoration?: CSSProperties[ 'textDecoration' ]; + textTransform?: CSSProperties[ 'textTransform' ]; + }; +} + +export type StyleOptions = { + /** + * CSS selector for the generated style. + */ + selector: string; +}; + +export type GeneratedCSSRule = { + selector: string; + value: string; + /** + * The CSS key in JS style attribute format, compatible with React. + * E.g. `paddingTop` instead of `padding-top`. + */ + key: string; +}; + +export interface StyleDefinition { + name: string; + generate: ( style: Style, options: StyleOptions ) => GeneratedCSSRule[]; +} diff --git a/packages/style-engine/tsconfig.json b/packages/style-engine/tsconfig.json new file mode 100644 index 0000000000000..3c2c31f506f13 --- /dev/null +++ b/packages/style-engine/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types" + }, + "include": [ "src/**/*" ] +} diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index 48902ee90406a..5aa5bcc74bb9d 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -19,7 +19,11 @@ const { dependencies } = require( '../../package' ); const { baseConfig, plugins, stylesTransform } = require( './shared' ); const WORDPRESS_NAMESPACE = '@wordpress/'; -const BUNDLED_PACKAGES = [ '@wordpress/icons', '@wordpress/interface' ]; +const BUNDLED_PACKAGES = [ + '@wordpress/icons', + '@wordpress/interface', + '@wordpress/style-engine', +]; const gutenbergPackages = Object.keys( dependencies ) .filter(