Skip to content

Commit

Permalink
CSS Map alternative compilation approach (#1496)
Browse files Browse the repository at this point in the history
* Add CSS Map

* Update packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts

Co-authored-by: Jake Lane <[email protected]>

* Update packages/babel-plugin/src/css-prop/__tests__/css-map.test.ts

Co-authored-by: Jake Lane <[email protected]>

* Remove console.log

* Update function name

* Add CSS Map to Parcel example

* Refactor error handling

* Add doc to cssMap

* Regenerate cssMap Flow types

* Update test

* Update error messages

* Implement alternative compilation method

* Update packages/babel-plugin/src/types.ts

Co-authored-by: Jake Lane <[email protected]>

* Add integration tests, storybooks, and vr tests for CSS Map

* Add changeset

---------

Co-authored-by: Jake Lane <[email protected]>
  • Loading branch information
liamqma and JakeLane authored Aug 29, 2023
1 parent 39daf9e commit 4a2174c
Show file tree
Hide file tree
Showing 24 changed files with 663 additions and 2 deletions.
8 changes: 8 additions & 0 deletions .changeset/friendly-sloths-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@compiled/babel-plugin': minor
'@compiled/webpack-app': minor
'@compiled/parcel-app': minor
'@compiled/react': minor
---

Implement the `cssMap` API to enable library users to dynamically choose a varied set of CSS rules.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions examples/parcel/src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import '@compiled/react';

import { primary } from './constants';
import Annotated from './ui/annotated';
import CSSMap from './ui/css-map';
import {
CustomFileExtensionStyled,
customFileExtensionCss,
Expand All @@ -29,5 +30,6 @@ export const App = () => (
<React.Suspense fallback={<div>Loading...</div>}>
<AsyncComponent />
</React.Suspense>
<CSSMap variant="danger">CSS Map</CSSMap>
</>
);
12 changes: 12 additions & 0 deletions examples/parcel/src/ui/css-map.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { css, cssMap } from '@compiled/react';

const styles = cssMap({
danger: {
color: 'red',
},
success: {
color: 'green',
},
});

export default ({ variant, children }) => <div css={css(styles[variant])}>{children}</div>;
2 changes: 2 additions & 0 deletions examples/webpack/src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,5 +24,6 @@ export const App = () => (
<CustomFileExtensionStyled>Custom File Extension Styled</CustomFileExtensionStyled>
<div css={customFileExtensionCss}>Custom File Extension CSS</div>
<Annotated />
<CSSMap variant="danger">CSS Map</CSSMap>
</>
);
12 changes: 12 additions & 0 deletions examples/webpack/src/ui/css-map.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { css, cssMap } from '@compiled/react';

const styles = cssMap({
danger: {
color: 'red',
},
success: {
color: 'green',
},
});

export default ({ variant, children }) => <div css={css(styles[variant])}>{children}</div>;
10 changes: 9 additions & 1 deletion packages/babel-plugin/src/babel-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as t from '@babel/types';
import { unique, preserveLeadingComments } from '@compiled/utils';

import { visitClassNamesPath } from './class-names';
import { visitCssMapPath } from './css-map';
import { visitCssPropPath } from './css-prop';
import { visitStyledPath } from './styled';
import type { State } from './types';
Expand All @@ -20,6 +21,7 @@ import {
isCompiledKeyframesTaggedTemplateExpression,
isCompiledStyledCallExpression,
isCompiledStyledTaggedTemplateExpression,
isCompiledCSSMapCallExpression,
} from './utils/is-compiled';
import { normalizePropsUsage } from './utils/normalize-props-usage';

Expand All @@ -39,6 +41,7 @@ export default declare<State>((api) => {
inherits: jsxSyntax,
pre() {
this.sheets = {};
this.cssMap = {};
let cache: Cache;

if (this.opts.cache === true) {
Expand Down Expand Up @@ -150,7 +153,7 @@ export default declare<State>((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) &&
Expand All @@ -171,6 +174,11 @@ export default declare<State>((api) => {
path: NodePath<t.TaggedTemplateExpression> | NodePath<t.CallExpression>,
state: State
) {
if (isCompiledCSSMapCallExpression(path.node, state)) {
visitCssMapPath(path, { context: 'root', state, parentPath: path });
return;
}

const hasStyles =
isCompiledCSSTaggedTemplateExpression(path.node, state) ||
isCompiledStyledTaggedTemplateExpression(path.node, state) ||
Expand Down
135 changes: 135 additions & 0 deletions packages/babel-plugin/src/css-map/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { TransformOptions } from '../../test-utils';
import { transform as transformCode } from '../../test-utils';
import { ErrorMessages } from '../index';

describe('css map', () => {
const transform = (code: string, opts: TransformOptions = {}) =>
transformCode(code, { pretty: false, ...opts });

const styles = `{
danger: {
color: 'red',
backgroundColor: 'red'
},
success: {
color: 'green',
backgroundColor: 'green'
}
}`;

it('should transform css map', () => {
const actual = transform(`
import { cssMap } from '@compiled/react';
const styles = cssMap(${styles});
`);

expect(actual).toInclude(
'const styles={danger:"_syaz5scu _bfhk5scu",success:"_syazbf54 _bfhkbf54"};'
);
});

it('should error out if variants are not defined at the top-most scope of the module.', () => {
expect(() => {
transform(`
import { cssMap } from '@compiled/react';
const styles = {
map1: cssMap(${styles}),
}
`);
}).toThrow(ErrorMessages.DEFINE_MAP);

expect(() => {
transform(`
import { cssMap } from '@compiled/react';
const styles = () => cssMap(${styles})
`);
}).toThrow(ErrorMessages.DEFINE_MAP);
});

it('should error out if cssMap receives more than one argument', () => {
expect(() => {
transform(`
import { cssMap } from '@compiled/react';
const styles = cssMap(${styles}, ${styles})
`);
}).toThrow(ErrorMessages.NUMBER_OF_ARGUMENT);
});

it('should error out if cssMap does not receive an object', () => {
expect(() => {
transform(`
import { cssMap } from '@compiled/react';
const styles = cssMap('color: red')
`);
}).toThrow(ErrorMessages.ARGUMENT_TYPE);
});

it('should error out if spread element is used', () => {
expect(() => {
transform(`
import { css, cssMap } from '@compiled/react';
const styles = cssMap({
...base
});
`);
}).toThrow(ErrorMessages.NO_SPREAD_ELEMENT);
});

it('should error out if object method is used', () => {
expect(() => {
transform(`
import { css, cssMap } from '@compiled/react';
const styles = cssMap({
danger() {}
});
`);
}).toThrow(ErrorMessages.NO_OBJECT_METHOD);
});

it('should error out if variant object is dynamic', () => {
expect(() => {
transform(`
import { css, cssMap } from '@compiled/react';
const styles = cssMap({
danger: otherStyles
});
`);
}).toThrow(ErrorMessages.STATIC_VARIANT_OBJECT);
});

it('should error out if styles include runtime variables', () => {
expect(() => {
transform(`
import { css, cssMap } from '@compiled/react';
const styles = cssMap({
danger: {
color: canNotBeStaticallyEvulated
}
});
`);
}).toThrow(ErrorMessages.STATIC_VARIANT_OBJECT);
});

it('should error out if styles include conditional CSS', () => {
expect(() => {
transform(`
import { css, cssMap } from '@compiled/react';
const styles = cssMap({
danger: {
color: canNotBeStaticallyEvulated ? 'red' : 'blue'
}
});
`);
}).toThrow(ErrorMessages.STATIC_VARIANT_OBJECT);
});
});
153 changes: 153 additions & 0 deletions packages/babel-plugin/src/css-map/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { NodePath } from '@babel/core';
import * as t from '@babel/types';

import type { Metadata } from '../types';
import { buildCodeFrameError } from '../utils/ast';
import { buildCss } from '../utils/css-builders';
import { transformCssItems } from '../utils/transform-css-items';

// The messages are exported for testing.
export enum ErrorMessages {
NO_TAGGED_TEMPLATE = 'cssMap function cannot be used as a tagged template expression.',
NUMBER_OF_ARGUMENT = 'cssMap function can only receive one argument.',
ARGUMENT_TYPE = 'cssMap function can only receive an object.',
DEFINE_MAP = 'CSS Map must be declared at the top-most scope of the module.',
NO_SPREAD_ELEMENT = 'Spread element is not supported in CSS Map.',
NO_OBJECT_METHOD = 'Object method is not supported in CSS Map.',
STATIC_VARIANT_OBJECT = 'The variant object must be statically defined.',
}

const createErrorMessage = (message: string): string => {
return `
${message}
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 }) => <div css={css(borderStyleMap[borderStyle])} />
\`\`\`
`;
};

/**
* Takes `cssMap` function expression and then transforms it to a record of class names and sheets.
*
* For example:
* ```
* const styles = cssMap({
* none: { color: 'red' },
* solid: { color: 'green' },
* });
* ```
* gets transformed to
* ```
* const styles = {
* danger: "_syaz5scu",
* success: "_syazbf54",
* };
* ```
*
* @param path {NodePath} The path to be evaluated.
* @param meta {Metadata} Useful metadata that can be used during the transformation
*/
export const visitCssMapPath = (
path: NodePath<t.CallExpression> | NodePath<t.TaggedTemplateExpression>,
meta: Metadata
): void => {
// We don't support tagged template expressions.
if (t.isTaggedTemplateExpression(path.node)) {
throw buildCodeFrameError(
createErrorMessage(ErrorMessages.DEFINE_MAP),
path.node,
meta.parentPath
);
}

// We need to ensure CSS Map is declared at the top-most scope of the module.
if (!t.isVariableDeclarator(path.parent) || !t.isIdentifier(path.parent.id)) {
throw buildCodeFrameError(
createErrorMessage(ErrorMessages.DEFINE_MAP),
path.node,
meta.parentPath
);
}

// We need to ensure cssMap receives only one argument.
if (path.node.arguments.length !== 1) {
throw buildCodeFrameError(
createErrorMessage(ErrorMessages.NUMBER_OF_ARGUMENT),
path.node,
meta.parentPath
);
}

// We need to ensure the argument is an objectExpression.
if (!t.isObjectExpression(path.node.arguments[0])) {
throw buildCodeFrameError(
createErrorMessage(ErrorMessages.ARGUMENT_TYPE),
path.node,
meta.parentPath
);
}

const totalSheets: string[] = [];
path.replaceWith(
t.objectExpression(
path.node.arguments[0].properties.map((property) => {
if (t.isSpreadElement(property)) {
throw buildCodeFrameError(
createErrorMessage(ErrorMessages.NO_SPREAD_ELEMENT),
property.argument,
meta.parentPath
);
}

if (t.isObjectMethod(property)) {
throw buildCodeFrameError(
createErrorMessage(ErrorMessages.NO_OBJECT_METHOD),
property.key,
meta.parentPath
);
}

if (!t.isObjectExpression(property.value)) {
throw buildCodeFrameError(
createErrorMessage(ErrorMessages.STATIC_VARIANT_OBJECT),
property.value,
meta.parentPath
);
}

const { css, variables } = buildCss(property.value, meta);

if (variables.length) {
throw buildCodeFrameError(
createErrorMessage(ErrorMessages.STATIC_VARIANT_OBJECT),
property.value,
meta.parentPath
);
}

const { sheets, classNames } = transformCssItems(css, meta);
totalSheets.push(...sheets);

if (classNames.length !== 1) {
throw buildCodeFrameError(
createErrorMessage(ErrorMessages.STATIC_VARIANT_OBJECT),
property,
meta.parentPath
);
}

return t.objectProperty(property.key, classNames[0]);
})
)
);

// We store sheets in the meta state so that we can use it later to generate Compiled component.
meta.state.cssMap[path.parent.id.name] = totalSheets;
};
Loading

0 comments on commit 4a2174c

Please sign in to comment.