Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CSS Map alternative compilation approach #1496

Merged
merged 18 commits into from
Aug 29, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the key is not in the css map?

Copy link
Collaborator Author

@liamqma liamqma Aug 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then class name will be undefined and stripped out by ax. The idea is to reply on type safety to detect problems, instead of build-time errors.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, I assume that should help reduce the impact on build time

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
Loading