diff --git a/examples/webpack/src/app.jsx b/examples/webpack/src/app.jsx index 3933d16648..9e40ec9eed 100644 --- a/examples/webpack/src/app.jsx +++ b/examples/webpack/src/app.jsx @@ -4,6 +4,7 @@ import { Suspense, lazy } from 'react'; import { primary } from './common/constants'; import Annotated from './ui/annotated'; +import CSSMap from './ui/css-map'; import { CustomFileExtensionStyled, customFileExtensionCss, @@ -23,5 +24,6 @@ export const App = () => ( Custom File Extension Styled
Custom File Extension CSS
+ CSS Map ); diff --git a/examples/webpack/src/ui/css-map.jsx b/examples/webpack/src/ui/css-map.jsx new file mode 100644 index 0000000000..1c7f3bbc1a --- /dev/null +++ b/examples/webpack/src/ui/css-map.jsx @@ -0,0 +1,12 @@ +import { css, cssMap } from '@compiled/react'; + +const styles = cssMap({ + danger: { + color: 'red', + }, + success: { + color: 'green', + }, +}); + +export default ({ variant, children }) =>
{children}
; diff --git a/packages/babel-plugin/src/babel-plugin.ts b/packages/babel-plugin/src/babel-plugin.ts index 3e4635315f..7b9c004b0c 100644 --- a/packages/babel-plugin/src/babel-plugin.ts +++ b/packages/babel-plugin/src/babel-plugin.ts @@ -20,6 +20,7 @@ import { isCompiledKeyframesTaggedTemplateExpression, isCompiledStyledCallExpression, isCompiledStyledTaggedTemplateExpression, + isCompiledCSSMapCallExpression, } from './utils/is-compiled'; import { normalizePropsUsage } from './utils/normalize-props-usage'; @@ -150,7 +151,7 @@ export default declare((api) => { return; } - (['styled', 'ClassNames', 'css', 'keyframes'] as const).forEach((apiName) => { + (['styled', 'ClassNames', 'css', 'keyframes', 'cssMap'] as const).forEach((apiName) => { if ( state.compiledImports && t.isIdentifier(specifier.node?.imported) && @@ -185,7 +186,8 @@ export default declare((api) => { isCompiledCSSTaggedTemplateExpression(path.node, state) || isCompiledKeyframesTaggedTemplateExpression(path.node, state) || isCompiledCSSCallExpression(path.node, state) || - isCompiledKeyframesCallExpression(path.node, state); + isCompiledKeyframesCallExpression(path.node, state) || + isCompiledCSSMapCallExpression(path.node, state); if (isCompiledUtil) { state.pathsToCleanup.push({ path, action: 'replace' }); diff --git a/packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts b/packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts new file mode 100644 index 0000000000..d00834f476 --- /dev/null +++ b/packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts @@ -0,0 +1,142 @@ +import type { TransformOptions } from '../../test-utils'; +import { transform as transformCode } from '../../test-utils'; + +describe('css map behaviour', () => { + beforeAll(() => { + process.env.AUTOPREFIXER = 'off'; + }); + + afterAll(() => { + delete process.env.AUTOPREFIXER; + }); + + const transform = (code: string, opts: TransformOptions = {}) => + transformCode(code, { pretty: false, ...opts }); + + const styles = ` + import { css, cssMap } from '@compiled/react'; + + const styles = cssMap({ + danger: { + color: 'red', + backgroundColor: 'red' + }, + success: { + color: 'green', + backgroundColor: 'green' + } + }); + `; + + const defaultErrorMessage = 'Using a CSS Map in this manner is incorrect.'; + const nestedErrorMessage = 'You cannot access a nested CSS Map'; + + describe('valid syntax', () => { + it.only('should evulate css map when variant is a runtime variable', () => { + const actual = transform(` + ${styles} + +
; + `); + + expect(actual).toInclude( + '
' + ); + }); + + it('should evulate css map when variant is statically defined', () => { + const actual = transform(` + ${styles} + +
; +
; + `); + + expect(actual).toInclude( + '
' + ); + expect(actual).toInclude( + '
' + ); + }); + + it('should combine CSS Map with other styles', () => { + const actual = transform( + ` + ${styles} + +
; + `, + { pretty: true } + ); + + console.log(actual); + + expect(actual).toInclude( + '
' + ); + }); + }); + + describe('invalid syntax', () => { + it('does not support TemplateLiteral as object property', () => { + expect(() => { + transform(` + ${styles} + +
; + `); + }).toThrow(defaultErrorMessage); + }); + + it('does not support Expression as object property', () => { + expect(() => { + transform(` + ${styles} + +
; + `); + }).toThrow(defaultErrorMessage); + }); + + it('does not support BinaryExpression as object property', () => { + expect(() => { + transform(` + ${styles} + +
; + `); + }).toThrow(defaultErrorMessage); + }); + + it('does not support MemberExpression as object property', () => { + expect(() => { + transform(` + ${styles} + +
; + `); + }).toThrow(defaultErrorMessage); + }); + + it('does not support CallExpression as object property', () => { + expect(() => { + transform(` + ${styles} + +
; + `); + }).toThrow(defaultErrorMessage); + }); + + it('does not support nesting', () => { + expect(() => { + transform(` + ${styles} + +
; + `); + }).toThrow(nestedErrorMessage); + }); + }); +}); diff --git a/packages/babel-plugin/src/types.ts b/packages/babel-plugin/src/types.ts index 0a6b2a95a5..79356d5af3 100644 --- a/packages/babel-plugin/src/types.ts +++ b/packages/babel-plugin/src/types.ts @@ -98,6 +98,7 @@ export interface State extends PluginPass { css?: string; keyframes?: string; styled?: string; + cssMap?: string; }; importedCompiledImports?: { diff --git a/packages/babel-plugin/src/utils/css-map.ts b/packages/babel-plugin/src/utils/css-map.ts new file mode 100644 index 0000000000..cb2d3323ed --- /dev/null +++ b/packages/babel-plugin/src/utils/css-map.ts @@ -0,0 +1,192 @@ +import * as t from '@babel/types'; + +import type { Metadata } from '../types'; + +import { buildCodeFrameError } from './ast'; +import { createResultPair } from './create-result-pair'; +import { evaluateIdentifier } from './traverse-expression/traverse-member-expression/traverse-access-path/resolve-expression/identifier'; +import type { EvaluateExpression } from './types'; + +const createErrorMessage = (message?: string): string => { + return ` +${ + message || 'Using a CSS Map in this manner is incorrect.' +} To correctly implement a CSS Map, follow the syntax below: + +\`\`\` +import { css, cssMap } from '@compiled/react'; +const borderStyleMap = cssMap({ + none: { borderStyle: 'none' }, + solid: { borderStyle: 'solid' }, +}); +const Component = ({ borderStyle }) =>
+\`\`\` + `; +}; + +/** + * Retrieves the leftmost identity from a given expression. + * + * For example: + * Given a member expression "colors.primary.500", the function will return "colors". + * + * @param expression The expression to be evaluated. + * @returns {string} The leftmost identity in the expression. + */ +const findBindingIdentifier = ( + expression: t.Expression | t.V8IntrinsicIdentifier +): t.Identifier | undefined => { + if (t.isIdentifier(expression)) { + return expression; + } else if (t.isCallExpression(expression)) { + return findBindingIdentifier(expression.callee); + } else if (t.isMemberExpression(expression)) { + return findBindingIdentifier(expression.object); + } + + return undefined; +}; + +/** + * Retrieves the CSS Map related information from a given expression. + * + * @param expression The expression to be evaluated. + * @param meta {Metadata} Useful metadata that can be used during the transformation + * @param evaluateExpression {EvaluateExpression} Function that evaluates an expression + */ +const getCSSmap = ( + expression: t.Expression, + meta: Metadata, + evaluateExpression: EvaluateExpression +): + | { + value: t.ObjectExpression; + meta: Metadata; + property: t.Identifier | t.StringLiteral; + computed: boolean; + } + | undefined => { + // Bail out early if cssMap callExpression doesn't exist in the file + if (!meta.state.compiledImports?.cssMap) return undefined; + + // We only care about member expressions. e.g. variants[variant] + if (!t.isMemberExpression(expression)) return undefined; + + const bindingIdentifier = findBindingIdentifier(expression.object); + + if (!bindingIdentifier) return undefined; + + // Evaluate the binding identifier to get the value of the CSS Map + const { value, meta: updatedMeta } = evaluateIdentifier( + bindingIdentifier, + meta, + evaluateExpression + ); + + // Ensure cssMap is used in a correct format. + if ( + t.isCallExpression(value) && + t.isIdentifier(value.callee) && + value.callee.name === meta.state.compiledImports?.cssMap && + value.arguments.length > 0 && + t.isObjectExpression(value.arguments[0]) + ) { + // It's CSS Map! We now need to check if the use of the CSS Map is correct. + if (t.isCallExpression(expression.object)) { + throw buildCodeFrameError(createErrorMessage(), expression, updatedMeta.parentPath); + } + + if (t.isMemberExpression(expression.object)) { + throw buildCodeFrameError( + createErrorMessage('You cannot access a nested CSS Map.'), + expression, + updatedMeta.parentPath + ); + } + + if (!t.isIdentifier(expression.property) && !t.isStringLiteral(expression.property)) { + throw buildCodeFrameError(createErrorMessage(), expression, updatedMeta.parentPath); + } + + return { + value: value.arguments[0], + property: expression.property, + computed: expression.computed, + meta: updatedMeta, + }; + } + + // It's not a CSS Map, let other code handle it + return undefined; +}; + +export const isCSSMap = ( + expression: t.Expression, + meta: Metadata, + evaluateExpression: EvaluateExpression +): boolean => { + return getCSSmap(expression, meta, evaluateExpression) !== undefined; +}; + +/** + * Transform expression that uses a CSS Map into an array of logical expressions. + * For example: + * ```js + * const borderStyleMap = cssMap({ + * none: { borderStyle: 'none' }, + * solid: { borderStyle: 'solid' }, + * }); + * const Component = ({ borderStyle }) =>
+ * ``` + * gets transformed into: + * ```js + * const Component = ({ borderStyle }) =>
+ * ``` + * Throw an error if a valid CSS Map is not provided. + * + * @param expression Expression we want to evulate. + * @param meta {Metadata} Useful metadata that can be used during the transformation + */ +export const evaluateCSSMap = ( + expression: t.Expression, + meta: Metadata, + evaluateExpression: EvaluateExpression +): ReturnType => { + const result = getCSSmap(expression, meta, evaluateExpression); + + // It should never happen because `isCSSMap` should have been checked already. + if (!result) throw buildCodeFrameError(createErrorMessage(), expression, meta.parentPath); + + const { value: objectExpression, property: objectProperty, computed, meta: updatedMeta } = result; + + return createResultPair( + t.arrayExpression( + objectExpression.properties.map((property) => { + if ( + !t.isObjectProperty(property) || + !t.isIdentifier(property.key) || + !t.isExpression(property.value) + ) + throw buildCodeFrameError(createErrorMessage(), expression, updatedMeta.parentPath); + + return t.logicalExpression( + '&&', + t.binaryExpression( + '===', + t.isStringLiteral(objectProperty) + ? objectProperty + : computed + ? objectProperty + : t.stringLiteral(objectProperty.name), + t.stringLiteral(property.key.name) + ), + property.value + ); + }) + ), + updatedMeta + ); +}; diff --git a/packages/babel-plugin/src/utils/evaluate-expression.ts b/packages/babel-plugin/src/utils/evaluate-expression.ts index c2f140ea0a..0b2f83982a 100644 --- a/packages/babel-plugin/src/utils/evaluate-expression.ts +++ b/packages/babel-plugin/src/utils/evaluate-expression.ts @@ -5,6 +5,7 @@ import type { Metadata } from '../types'; import { getPathOfNode } from './ast'; import { createResultPair } from './create-result-pair'; +import { isCSSMap, evaluateCSSMap } from './css-map'; import { isCompiledKeyframesCallExpression } from './is-compiled'; import { traverseBinaryExpression, @@ -137,7 +138,9 @@ export const evaluateExpression = ( // there is something we could do better here. // -------------- - if (t.isIdentifier(targetExpression)) { + if (isCSSMap(targetExpression, updatedMeta, evaluateExpression)) { + return evaluateCSSMap(targetExpression, updatedMeta, evaluateExpression); + } else if (t.isIdentifier(targetExpression)) { ({ value, meta: updatedMeta } = traverseIdentifier( targetExpression, updatedMeta, diff --git a/packages/babel-plugin/src/utils/is-compiled.ts b/packages/babel-plugin/src/utils/is-compiled.ts index 65a7ecf34b..ddc022018e 100644 --- a/packages/babel-plugin/src/utils/is-compiled.ts +++ b/packages/babel-plugin/src/utils/is-compiled.ts @@ -47,6 +47,21 @@ export const isCompiledKeyframesCallExpression = ( t.isIdentifier(node.callee) && node.callee.name === state.compiledImports?.keyframes; +/** + * Returns `true` if the node is using `cssMap` from `@compiled/react` as a call expression + * + * @param node {t.Node} The node that is being checked + * @param state {State} Plugin state + * @returns {boolean} Whether the node is a compiled cssMap + */ +export const isCompiledCSSMapCallExpression = ( + node: t.Node, + state: State +): node is t.CallExpression => + t.isCallExpression(node) && + t.isIdentifier(node.callee) && + node.callee.name === state.compiledImports?.cssMap; + /** * Returns `true` if the node is using `keyframes` from `@compiled/react` as a tagged template expression * diff --git a/packages/react/src/css-map/index.js.flow b/packages/react/src/css-map/index.js.flow new file mode 100644 index 0000000000..2f39ede368 --- /dev/null +++ b/packages/react/src/css-map/index.js.flow @@ -0,0 +1,10 @@ +/** + * Flowtype definitions for index + * Generated by Flowgen from a Typescript Definition + * Flowgen v1.20.1 + * @flow + */ +import type { CSSProps, CssObject } from '../types'; +declare export default function cssMap(_styles: { + [key: T]: CssObject | CssObject[], +}): { [key: T]: CSSProps }; diff --git a/packages/react/src/css-map/index.ts b/packages/react/src/css-map/index.ts new file mode 100644 index 0000000000..ac2233b8be --- /dev/null +++ b/packages/react/src/css-map/index.ts @@ -0,0 +1,8 @@ +import type { CSSProps, CssObject } from '../types'; +import { createSetupError } from '../utils/error'; + +export default function cssMap( + _styles: Record | CssObject[]> +): Record> { + throw createSetupError(); +} diff --git a/packages/react/src/index.js.flow b/packages/react/src/index.js.flow index d6183f0b23..1937810a72 100644 --- a/packages/react/src/index.js.flow +++ b/packages/react/src/index.js.flow @@ -10,3 +10,4 @@ declare export { keyframes } from './keyframes'; declare export { styled } from './styled'; declare export { ClassNames } from './class-names'; declare export { default as css } from './css'; +declare export { default as cssMap } from './css-map'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7c038675f8..8b114db02c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -9,6 +9,7 @@ export { keyframes } from './keyframes'; export { styled } from './styled'; export { ClassNames } from './class-names'; export { default as css } from './css'; +export { default as cssMap } from './css-map'; // Pass through the (classic) jsx runtime. // Compiled currently doesn't define its own and uses this purely to enable a local jsx namespace.