Skip to content

Commit b6f3e41

Browse files
liamqmadddlr
andauthoredSep 20, 2023
tighten up cssMap type (#1502)
* Fix cssmap type * tighten up cssMap type * Add stronger type checking, selectors object, split at-rules * Remove template literal type from css map types * Remove pseudos with vendor prefixes and pseudos that use information from other elements * Add tests, minor refactor, improve error messages * Update flow types * Minor cleanup * Update VR tests * Minor cleanup: s/toThrowError/toThrow, improve comment --------- Co-authored-by: Grant Wong <gwong2@atlassian.com>
1 parent 2e503a2 commit b6f3e41

20 files changed

+1006
-76
lines changed
 

‎.changeset/red-yaks-relate.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@compiled/react': minor
3+
---
4+
5+
Change `cssMap` types to use stricter type checking and only allowing a limited subset of whitelisted selectors (e.g. `&:hover`); implement syntax for at-rules (e.g. `@media`); implement `selectors` key for non-whitelisted selectors.
Loading
Loading
Loading
Loading

‎examples/parcel/src/ui/css-map.jsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { css, cssMap } from '@compiled/react';
22

3+
const base = css({
4+
backgroundColor: 'blue',
5+
});
6+
37
const styles = cssMap({
48
danger: {
59
color: 'red',
@@ -9,4 +13,4 @@ const styles = cssMap({
913
},
1014
});
1115

12-
export default ({ variant, children }) => <div css={css(styles[variant])}>{children}</div>;
16+
export default ({ variant, children }) => <div css={[base, styles[variant]]}>{children}</div>;

‎examples/webpack/src/ui/css-map.jsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { css, cssMap } from '@compiled/react';
22

3+
const base = css({
4+
backgroundColor: 'blue',
5+
});
6+
37
const styles = cssMap({
48
danger: {
59
color: 'red',
@@ -9,4 +13,4 @@ const styles = cssMap({
913
},
1014
});
1115

12-
export default ({ variant, children }) => <div css={css(styles[variant])}>{children}</div>;
16+
export default ({ variant, children }) => <div css={[base, styles[variant]]}>{children}</div>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import type { TransformOptions } from '../../test-utils';
2+
import { transform as transformCode } from '../../test-utils';
3+
import { ErrorMessages } from '../../utils/css-map';
4+
5+
// Add an example element so we can check the raw CSS styles
6+
const EXAMPLE_USAGE = 'const Element = (variant) => <div css={styles[variant]} />;';
7+
8+
describe('css map advanced functionality (at rules, selectors object)', () => {
9+
const transform = (code: string, opts: TransformOptions = {}) =>
10+
transformCode(code, { pretty: false, ...opts });
11+
12+
it('should parse a mix of at rules and the selectors object', () => {
13+
const actual = transform(`
14+
import { cssMap } from '@compiled/react';
15+
16+
const styles = cssMap({
17+
success: {
18+
color: '#0b0',
19+
'&:hover': {
20+
color: '#060',
21+
},
22+
'@media': {
23+
'screen and (min-width: 500px)': {
24+
fontSize: '10vw',
25+
},
26+
},
27+
selectors: {
28+
span: {
29+
color: 'lightgreen',
30+
'&:hover': {
31+
color: '#090',
32+
},
33+
},
34+
},
35+
},
36+
danger: {
37+
color: 'red',
38+
'&:hover': {
39+
color: 'darkred',
40+
},
41+
'@media': {
42+
'screen and (min-width: 500px)': {
43+
fontSize: '20vw',
44+
},
45+
},
46+
selectors: {
47+
span: {
48+
color: 'orange',
49+
'&:hover': {
50+
color: 'pink',
51+
},
52+
},
53+
},
54+
},
55+
});
56+
57+
${EXAMPLE_USAGE}
58+
`);
59+
60+
expect(actual).toIncludeMultiple([
61+
// Styles from success variant
62+
'._syazjafr{color:#0b0}',
63+
'._30l3aebp:hover{color:#060}',
64+
'@media screen and (min-width:500px){._1takoyl8{font-size:10vw}}',
65+
'._1tjq1v9d span{color:lightgreen}',
66+
'._yzbcy77s span:hover{color:#090}',
67+
68+
// Styles from danger variant
69+
'._syaz5scu{color:red}',
70+
'._30l3qaj3:hover{color:darkred}',
71+
'@media screen and (min-width:500px){._1taki9ra{font-size:20vw}}',
72+
'._1tjqruxl span{color:orange}',
73+
'._yzbc32ev span:hover{color:pink}',
74+
75+
'const styles={success:"_syazjafr _30l3aebp _1takoyl8 _1tjq1v9d _yzbcy77s",danger:"_syaz5scu _30l3qaj3 _1taki9ra _1tjqruxl _yzbc32ev"}',
76+
]);
77+
});
78+
79+
it('should parse selectors object', () => {
80+
const actual = transform(`
81+
import { cssMap } from '@compiled/react';
82+
83+
const styles = cssMap({
84+
success: {
85+
color: '#0b0',
86+
'&:hover': {
87+
color: '#060',
88+
},
89+
},
90+
danger: {
91+
color: 'red',
92+
selectors: {
93+
'&:first-of-type': {
94+
color: 'lightgreen',
95+
'&:hover': {
96+
color: '#090',
97+
},
98+
},
99+
// Hover on child element
100+
'& :hover': {
101+
color: 'orange',
102+
},
103+
},
104+
},
105+
});
106+
107+
${EXAMPLE_USAGE}
108+
`);
109+
110+
expect(actual).toIncludeMultiple([
111+
// Styles from success variant
112+
'._syazjafr{color:#0b0}',
113+
'._30l3aebp:hover{color:#060}',
114+
115+
// Styles from danger variant
116+
'._syaz5scu{color:red}',
117+
'._pnmb1v9d:first-of-type{color:lightgreen}',
118+
'._p685y77s:first-of-type:hover{color:#090}',
119+
'._838lruxl :hover{color:orange}',
120+
121+
'const styles={success:"_syazjafr _30l3aebp",danger:"_syaz5scu _pnmb1v9d _p685y77s _838lruxl"}',
122+
]);
123+
});
124+
125+
it('should error if duplicate selectors passed (inside selectors object and outside)', () => {
126+
expect(() => {
127+
transform(`
128+
import { cssMap } from '@compiled/react';
129+
130+
const styles = cssMap({
131+
success: {
132+
color: '#0b0',
133+
'&:hover': {
134+
color: '#060',
135+
},
136+
selectors: {
137+
'&:hover': {
138+
color: '#ff0',
139+
},
140+
},
141+
},
142+
});
143+
`);
144+
}).toThrow(ErrorMessages.DUPLICATE_SELECTOR);
145+
});
146+
147+
it('should error if duplicate selectors passed using different formats (mixing an identifier and a string literal)', () => {
148+
expect(() => {
149+
transform(`
150+
import { cssMap } from '@compiled/react';
151+
152+
const styles = cssMap({
153+
success: {
154+
color: '#0b0',
155+
// This wouldn't pass the type-checking anyway
156+
div: {
157+
color: '#060',
158+
},
159+
selectors: {
160+
'div': {
161+
color: '#ff0',
162+
},
163+
},
164+
},
165+
});
166+
`);
167+
}).toThrow(ErrorMessages.DUPLICATE_SELECTOR);
168+
});
169+
170+
it('should error if selector targeting current element is passed without ampersand at front', () => {
171+
// :hover (by itself) is identical to &:hover, believe it or not!
172+
// This is due to the parent-orphaned-pseudos plugin in @compiled/css.
173+
expect(() => {
174+
transform(`
175+
import { cssMap } from '@compiled/react';
176+
177+
const styles = cssMap({
178+
success: {
179+
color: '#0b0',
180+
selectors: {
181+
':hover': {
182+
color: 'aquamarine',
183+
},
184+
},
185+
},
186+
});
187+
`);
188+
}).toThrow(ErrorMessages.USE_SELECTORS_WITH_AMPERSAND);
189+
});
190+
191+
it('should error if duplicate selectors passed using both the forms `&:hover` and `:hover`', () => {
192+
expect(() => {
193+
transform(`
194+
import { cssMap } from '@compiled/react';
195+
196+
const styles = cssMap({
197+
success: {
198+
color: '#0b0',
199+
'&:hover': {
200+
color: 'cyan',
201+
},
202+
selectors: {
203+
':hover': {
204+
color: 'aquamarine',
205+
},
206+
},
207+
},
208+
});
209+
`);
210+
}).toThrow(ErrorMessages.USE_SELECTORS_WITH_AMPERSAND);
211+
});
212+
213+
it('should not error if selector has same name as property', () => {
214+
const actual = transform(`
215+
import { cssMap } from '@compiled/react';
216+
217+
const styles = cssMap({
218+
success: {
219+
color: '#0b0',
220+
// All bets are off when we do not know what constitutes
221+
// a valid selector, so we give up in the selectors key
222+
selectors: {
223+
color: {
224+
color: 'pink',
225+
},
226+
fontSize: {
227+
background: 'blue',
228+
},
229+
},
230+
fontSize: '50px',
231+
},
232+
});
233+
234+
${EXAMPLE_USAGE}
235+
`);
236+
237+
expect(actual).toIncludeMultiple([
238+
'._syazjafr{color:#0b0}',
239+
'._14jq32ev color{color:pink}',
240+
'._1wsc13q2 fontSize{background-color:blue}',
241+
'._1wyb12am{font-size:50px}',
242+
243+
'const styles={success:"_syazjafr _1wyb12am _14jq32ev _1wsc13q2"}',
244+
]);
245+
});
246+
247+
it('should parse an at rule (@media)', () => {
248+
const permutations: string[] = [`screen`, `'screen'`];
249+
250+
for (const secondHalf of permutations) {
251+
const actual = transform(`
252+
import { cssMap } from '@compiled/react';
253+
254+
const styles = cssMap({
255+
success: {
256+
color: 'red',
257+
'@media': {
258+
'screen and (min-width: 500px)': {
259+
color: 'blue',
260+
},
261+
${secondHalf}: {
262+
color: 'pink',
263+
},
264+
},
265+
},
266+
});
267+
268+
${EXAMPLE_USAGE}
269+
`);
270+
271+
expect(actual).toIncludeMultiple([
272+
'._syaz5scu{color:red}',
273+
'@media screen and (min-width:500px){._1qhm13q2{color:blue}}',
274+
'@media screen{._434732ev{color:pink}}',
275+
276+
'const styles={success:"_syaz5scu _1qhm13q2 _434732ev"}',
277+
]);
278+
}
279+
});
280+
281+
it('should error if more than one selectors key passed', () => {
282+
expect(() => {
283+
transform(`
284+
import { cssMap } from '@compiled/react';
285+
286+
const styles = cssMap({
287+
success: {
288+
color: 'red',
289+
selectors: {
290+
'&:hover': {
291+
color: '#ff0',
292+
},
293+
},
294+
selectors: {
295+
'&:active': {
296+
color: '#0ff',
297+
},
298+
},
299+
},
300+
});
301+
`);
302+
}).toThrow(ErrorMessages.DUPLICATE_SELECTORS_BLOCK);
303+
});
304+
305+
it('should error if value of selectors key is not an object', () => {
306+
expect(() => {
307+
transform(`
308+
import { cssMap } from '@compiled/react';
309+
310+
const styles = cssMap({
311+
success: {
312+
color: 'red',
313+
selectors: 'blue',
314+
},
315+
});
316+
`);
317+
}).toThrow(ErrorMessages.SELECTORS_BLOCK_VALUE_TYPE);
318+
});
319+
});

‎packages/babel-plugin/src/css-map/__tests__/index.test.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { TransformOptions } from '../../test-utils';
22
import { transform as transformCode } from '../../test-utils';
3-
import { ErrorMessages } from '../index';
3+
import { ErrorMessages } from '../../utils/css-map';
44

5-
describe('css map', () => {
5+
describe('css map basic functionality', () => {
66
const transform = (code: string, opts: TransformOptions = {}) =>
77
transformCode(code, { pretty: false, ...opts });
88

@@ -93,6 +93,18 @@ describe('css map', () => {
9393
}).toThrow(ErrorMessages.NO_OBJECT_METHOD);
9494
});
9595

96+
it('should error out if empty object passed to variant', () => {
97+
expect(() => {
98+
transform(`
99+
import { css, cssMap } from '@compiled/react';
100+
101+
const styles = cssMap({
102+
danger: {}
103+
});
104+
`);
105+
}).toThrow(ErrorMessages.EMPTY_VARIANT_OBJECT);
106+
});
107+
96108
it('should error out if variant object is dynamic', () => {
97109
expect(() => {
98110
transform(`

‎packages/babel-plugin/src/css-map/index.ts

+5-42
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,10 @@ import * as t from '@babel/types';
44
import type { Metadata } from '../types';
55
import { buildCodeFrameError } from '../utils/ast';
66
import { buildCss } from '../utils/css-builders';
7+
import { ErrorMessages, createErrorMessage, errorIfNotValidObjectProperty } from '../utils/css-map';
78
import { transformCssItems } from '../utils/transform-css-items';
89

9-
// The messages are exported for testing.
10-
export enum ErrorMessages {
11-
NO_TAGGED_TEMPLATE = 'cssMap function cannot be used as a tagged template expression.',
12-
NUMBER_OF_ARGUMENT = 'cssMap function can only receive one argument.',
13-
ARGUMENT_TYPE = 'cssMap function can only receive an object.',
14-
DEFINE_MAP = 'CSS Map must be declared at the top-most scope of the module.',
15-
NO_SPREAD_ELEMENT = 'Spread element is not supported in CSS Map.',
16-
NO_OBJECT_METHOD = 'Object method is not supported in CSS Map.',
17-
STATIC_VARIANT_OBJECT = 'The variant object must be statically defined.',
18-
}
19-
20-
const createErrorMessage = (message: string): string => {
21-
return `
22-
${message}
23-
To correctly implement a CSS Map, follow the syntax below:
24-
25-
\`\`\`
26-
import { css, cssMap } from '@compiled/react';
27-
const borderStyleMap = cssMap({
28-
none: { borderStyle: 'none' },
29-
solid: { borderStyle: 'solid' },
30-
});
31-
const Component = ({ borderStyle }) => <div css={css(borderStyleMap[borderStyle])} />
32-
\`\`\`
33-
`;
34-
};
10+
import { mergeExtendedSelectorsIntoProperties } from './process-selectors';
3511

3612
/**
3713
* Takes `cssMap` function expression and then transforms it to a record of class names and sheets.
@@ -98,21 +74,7 @@ export const visitCssMapPath = (
9874
path.replaceWith(
9975
t.objectExpression(
10076
path.node.arguments[0].properties.map((property) => {
101-
if (t.isSpreadElement(property)) {
102-
throw buildCodeFrameError(
103-
createErrorMessage(ErrorMessages.NO_SPREAD_ELEMENT),
104-
property.argument,
105-
meta.parentPath
106-
);
107-
}
108-
109-
if (t.isObjectMethod(property)) {
110-
throw buildCodeFrameError(
111-
createErrorMessage(ErrorMessages.NO_OBJECT_METHOD),
112-
property.key,
113-
meta.parentPath
114-
);
115-
}
77+
errorIfNotValidObjectProperty(property, meta);
11678

11779
if (!t.isObjectExpression(property.value)) {
11880
throw buildCodeFrameError(
@@ -122,7 +84,8 @@ export const visitCssMapPath = (
12284
);
12385
}
12486

125-
const { css, variables } = buildCss(property.value, meta);
87+
const processedPropertyValue = mergeExtendedSelectorsIntoProperties(property.value, meta);
88+
const { css, variables } = buildCss(processedPropertyValue, meta);
12689

12790
if (variables.length) {
12891
throw buildCodeFrameError(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import * as t from '@babel/types';
2+
3+
import type { Metadata } from '../types';
4+
import { buildCodeFrameError } from '../utils/ast';
5+
import {
6+
ErrorMessages,
7+
createErrorMessage,
8+
errorIfNotValidObjectProperty,
9+
getKeyValue,
10+
hasExtendedSelectorsKey as propertyHasExtendedSelectorsKey,
11+
isAtRule,
12+
objectKeyIsLiteralValue,
13+
isPlainSelector,
14+
} from '../utils/css-map';
15+
16+
function* collapseAtRule(atRuleBlock: t.ObjectProperty, atRuleType: string, meta: Metadata) {
17+
if (!t.isObjectExpression(atRuleBlock.value)) {
18+
throw buildCodeFrameError(
19+
createErrorMessage(ErrorMessages.AT_RULE_VALUE_TYPE),
20+
atRuleBlock.value,
21+
meta.parentPath
22+
);
23+
}
24+
25+
for (const atRule of atRuleBlock.value.properties) {
26+
errorIfNotValidObjectProperty(atRule, meta);
27+
if (!objectKeyIsLiteralValue(atRule.key)) {
28+
throw buildCodeFrameError(
29+
createErrorMessage(ErrorMessages.STATIC_PROPERTY_KEY),
30+
atRule.key,
31+
meta.parentPath
32+
);
33+
}
34+
35+
const atRuleName = `${atRuleType} ${getKeyValue(atRule.key)}`;
36+
const newKey = t.identifier(atRuleName);
37+
yield { atRuleName, atRuleValue: { ...atRule, key: newKey } };
38+
}
39+
}
40+
41+
const getExtendedSelectors = (
42+
variantStyles: t.ObjectExpression,
43+
meta: Metadata
44+
): t.ObjectExpression['properties'] => {
45+
const extendedSelectorsFound = variantStyles.properties.filter(
46+
(value): value is t.ObjectProperty =>
47+
t.isObjectProperty(value) && propertyHasExtendedSelectorsKey(value)
48+
);
49+
50+
if (extendedSelectorsFound.length === 0) return [];
51+
if (extendedSelectorsFound.length > 1) {
52+
throw buildCodeFrameError(
53+
createErrorMessage(ErrorMessages.DUPLICATE_SELECTORS_BLOCK),
54+
extendedSelectorsFound[1],
55+
meta.parentPath
56+
);
57+
}
58+
59+
const extendedSelectors = extendedSelectorsFound[0];
60+
if (!t.isObjectExpression(extendedSelectors.value)) {
61+
throw buildCodeFrameError(
62+
createErrorMessage(ErrorMessages.SELECTORS_BLOCK_VALUE_TYPE),
63+
extendedSelectors.value,
64+
meta.parentPath
65+
);
66+
}
67+
68+
return extendedSelectors.value.properties;
69+
};
70+
71+
/**
72+
* Given an object defined within an variant passed to `cssMap`, convert this object to
73+
* a less idiosyncratic form that can be directly processed by `buildCss`.
74+
*
75+
* For example, if our object is this:
76+
*
77+
* {
78+
* color: 'blue',
79+
* '&:hover': {
80+
* color: 'yellow',
81+
* },
82+
* '@media': {
83+
* 'screen and (min-width: 500px)': { ... }
84+
* 'screen and (min-width: 700px)': { ... }
85+
* },
86+
* selectors: {
87+
* div: { color: 'orange' },
88+
* }
89+
* }
90+
*
91+
* This function will merge the two halves of the `@media` query (for example, to get
92+
* `@media screen and (min-width: 500px)`), and it will merge all of
93+
* the keys located in the value of `selectors`:
94+
*
95+
* {
96+
* color: 'blue',
97+
* '&:hover': {
98+
* color: 'yellow',
99+
* },
100+
* '@media screen and (min-width: 500px)': { ... }
101+
* '@media screen and (max-width: 700px)': { ... }
102+
* div: { color: 'orange' },
103+
* }
104+
*
105+
* @param variantStyles an object expression representing the value of the cssMap variant
106+
* @param meta metadata from Babel, used for error messages
107+
* @returns the processed object expression
108+
*/
109+
export const mergeExtendedSelectorsIntoProperties = (
110+
variantStyles: t.ObjectExpression,
111+
meta: Metadata
112+
): t.ObjectExpression => {
113+
const extendedSelectors = getExtendedSelectors(variantStyles, meta);
114+
const mergedProperties: t.ObjectProperty[] = [];
115+
const addedSelectors: Set<string> = new Set();
116+
117+
if (variantStyles.properties.length === 0) {
118+
throw buildCodeFrameError(
119+
createErrorMessage(ErrorMessages.EMPTY_VARIANT_OBJECT),
120+
variantStyles,
121+
meta.parentPath
122+
);
123+
}
124+
125+
for (const property of [...variantStyles.properties, ...extendedSelectors]) {
126+
// Covered by @compiled/eslint-plugin rule already,
127+
// this is just to make the type checker happy
128+
errorIfNotValidObjectProperty(property, meta);
129+
// Extract property.key into its own variable so we can do
130+
// type checking on it
131+
const propertyKey = property.key;
132+
133+
if (!objectKeyIsLiteralValue(propertyKey)) {
134+
throw buildCodeFrameError(
135+
createErrorMessage(ErrorMessages.STATIC_PROPERTY_KEY),
136+
property.key,
137+
meta.parentPath
138+
);
139+
}
140+
141+
if (isPlainSelector(getKeyValue(propertyKey))) {
142+
throw buildCodeFrameError(
143+
createErrorMessage(ErrorMessages.USE_SELECTORS_WITH_AMPERSAND),
144+
property.key,
145+
meta.parentPath
146+
);
147+
}
148+
149+
// we have already extracted the selectors object into the `extendedSelectors`
150+
// variable, so we can skip it now
151+
if (propertyHasExtendedSelectorsKey(property)) continue;
152+
153+
if (isAtRule(propertyKey)) {
154+
const atRuleType = getKeyValue(propertyKey);
155+
const atRules = collapseAtRule(property, atRuleType, meta);
156+
157+
for (const { atRuleName, atRuleValue } of atRules) {
158+
if (addedSelectors.has(atRuleName)) {
159+
throw buildCodeFrameError(
160+
createErrorMessage(ErrorMessages.DUPLICATE_AT_RULE),
161+
property.key,
162+
meta.parentPath
163+
);
164+
}
165+
mergedProperties.push(atRuleValue);
166+
addedSelectors.add(atRuleName);
167+
}
168+
} else {
169+
// If the property value is an object, we can be reasonably sure that
170+
// the key is a CSS selector and not a CSS property (this is just an
171+
// assumption, because we can never be 100% sure...)
172+
const isSelector = t.isObjectExpression(property.value);
173+
174+
if (isSelector) {
175+
const isDuplicateSelector = addedSelectors.has(getKeyValue(propertyKey));
176+
if (isDuplicateSelector) {
177+
throw buildCodeFrameError(
178+
createErrorMessage(ErrorMessages.DUPLICATE_SELECTOR),
179+
property.key,
180+
meta.parentPath
181+
);
182+
} else {
183+
addedSelectors.add(getKeyValue(propertyKey));
184+
}
185+
}
186+
187+
mergedProperties.push(property);
188+
}
189+
}
190+
191+
return { ...variantStyles, properties: mergedProperties };
192+
};

‎packages/babel-plugin/src/utils/ast.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ import * as t from '@babel/types';
77
* @param node
88
* @param parentPath
99
*/
10-
export const getPathOfNode = <TNode>(node: TNode, parentPath: NodePath): NodePath<TNode> => {
10+
export const getPathOfNode = (node: t.Node, parentPath: NodePath): NodePath => {
1111
let foundPath: NodePath | null = null;
1212

13+
if (t.isExpression(node)) {
14+
node = t.expressionStatement(node);
15+
}
16+
1317
traverse(
14-
t.expressionStatement(node as any),
18+
node,
1519
{
1620
enter(path) {
1721
foundPath = path;

‎packages/babel-plugin/src/utils/css-builders.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ const extractConditionalExpression = (node: t.ConditionalExpression, meta: Metad
345345

346346
const [consequentCss, alternateCss] = CONDITIONAL_PATHS.map((path) => {
347347
const pathNode = node[path];
348-
let cssOutput: CSSOutput | void;
348+
let cssOutput: CSSOutput | undefined;
349349

350350
if (
351351
t.isObjectExpression(pathNode) ||
+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import * as t from '@babel/types';
2+
3+
import type { Metadata } from '../types';
4+
import { buildCodeFrameError } from '../utils/ast';
5+
6+
export const EXTENDED_SELECTORS_KEY = 'selectors';
7+
8+
type ObjectKeyWithLiteralValue = t.Identifier | t.StringLiteral;
9+
10+
export const objectKeyIsLiteralValue = (
11+
key: t.ObjectProperty['key']
12+
): key is ObjectKeyWithLiteralValue => t.isIdentifier(key) || t.isStringLiteral(key);
13+
14+
export const getKeyValue = (key: ObjectKeyWithLiteralValue): string => {
15+
if (t.isIdentifier(key)) return key.name;
16+
else if (t.isStringLiteral(key)) return key.value;
17+
throw new Error(`Expected an identifier or a string literal, got type ${(key as any).type}`);
18+
};
19+
20+
export const isAtRule = (key: ObjectKeyWithLiteralValue): key is ObjectKeyWithLiteralValue =>
21+
getKeyValue(key).startsWith('@');
22+
23+
export const isPlainSelector = (selector: string): boolean => selector.startsWith(':');
24+
25+
export const hasExtendedSelectorsKey = (property: t.ObjectProperty): boolean =>
26+
objectKeyIsLiteralValue(property.key) && getKeyValue(property.key) === EXTENDED_SELECTORS_KEY;
27+
28+
export function errorIfNotValidObjectProperty(
29+
property: t.ObjectExpression['properties'][number],
30+
meta: Metadata
31+
): asserts property is t.ObjectProperty {
32+
if (t.isObjectMethod(property)) {
33+
throw buildCodeFrameError(
34+
createErrorMessage(ErrorMessages.NO_OBJECT_METHOD),
35+
property.key,
36+
meta.parentPath
37+
);
38+
} else if (t.isSpreadElement(property)) {
39+
throw buildCodeFrameError(
40+
createErrorMessage(ErrorMessages.NO_SPREAD_ELEMENT),
41+
property.argument,
42+
meta.parentPath
43+
);
44+
}
45+
}
46+
47+
// The messages are exported for testing.
48+
49+
export enum ErrorMessages {
50+
NO_TAGGED_TEMPLATE = 'cssMap function cannot be used as a tagged template expression.',
51+
NUMBER_OF_ARGUMENT = 'cssMap function can only receive one argument.',
52+
ARGUMENT_TYPE = 'cssMap function can only receive an object.',
53+
AT_RULE_VALUE_TYPE = 'Value of at-rule block must be an object.',
54+
SELECTORS_BLOCK_VALUE_TYPE = 'Value of `selectors` key must be an object.',
55+
DEFINE_MAP = 'CSS Map must be declared at the top-most scope of the module.',
56+
NO_SPREAD_ELEMENT = 'Spread element is not supported in CSS Map.',
57+
NO_OBJECT_METHOD = 'Object method is not supported in CSS Map.',
58+
STATIC_VARIANT_OBJECT = 'The variant object must be statically defined.',
59+
EMPTY_VARIANT_OBJECT = 'The variant object must not be empty.',
60+
DUPLICATE_AT_RULE = 'Cannot declare an at-rule more than once in CSS Map.',
61+
DUPLICATE_SELECTOR = 'Cannot declare a selector more than once in CSS Map.',
62+
DUPLICATE_SELECTORS_BLOCK = 'Duplicate `selectors` key found in cssMap; expected either zero `selectors` keys or one.',
63+
STATIC_PROPERTY_KEY = 'Property key may only be a static string.',
64+
SELECTOR_BLOCK_WRONG_PLACE = '`selector` key was defined in the wrong place.',
65+
USE_SELECTORS_WITH_AMPERSAND = 'This selector is applied to the parent element, and so you need to specify the ampersand symbol (&) directly before it. For example, `:hover` should be written as `&:hover`.',
66+
}
67+
68+
export const createErrorMessage = (message: string): string => {
69+
return `
70+
${message}
71+
72+
Check out our documentation for cssMap examples: https://compiledcssinjs.com/docs/api-cssmap
73+
`;
74+
};

‎packages/react/src/css-map/index.js.flow

+104-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,109 @@
44
* Flowgen v1.21.0
55
* @flow
66
*/
7-
import type { CSSProps, CssObject } from '../types';
7+
import type { Properties, AtRules } from 'csstype';
8+
import type { Pseudos } from './pseudos';
9+
/**
10+
* These are all the CSS props that will exist.
11+
* Only 'string' and 'number' are valid CSS values.
12+
* @example ```
13+
* const style: CssProps = {
14+
* color: 'red',
15+
* margin: 10,
16+
* };
17+
* ```
18+
*/
19+
declare type CssProps = $ReadOnly<Properties<string | number>>;
20+
declare type AllPseudos = $ObjMapi<
21+
{ [k: Pseudos]: any },
22+
<key>(key) => { ...CssProps, ...AllPseudos }
23+
>;
24+
declare type AtRuleSecondHalf = string;
25+
declare type WhitelistedAtRule = $ObjMapi<
26+
{ [k: AtRules]: any },
27+
<atRuleFirstHalf>(atRuleFirstHalf) => $ObjMapi<
28+
{ [k: AtRuleSecondHalf]: any },
29+
<atRuleSecondHalf>(atRuleSecondHalf) => {
30+
...CssProps,
31+
...AllPseudos,
32+
...WhitelistedAtRule,
33+
}
34+
>
35+
>;
36+
declare type WhitelistedSelector = { ...AllPseudos, ...WhitelistedAtRule };
37+
declare type ExtendedSelector = {
38+
...{
39+
[key: string]: CssProps | ExtendedSelector,
40+
},
41+
...{
42+
/**
43+
* Using `selectors` is not valid here - you cannot nest a `selectors` object
44+
* inside another `selectors` object.
45+
*/
46+
selectors?: empty,
47+
...
48+
},
49+
};
50+
declare type ExtendedSelectors = {
51+
/**
52+
* Provides a way to use selectors that have not been explicitly whitelisted
53+
* in cssMap.
54+
*
55+
* This does not provide any type-checking for the selectors (thus allowing
56+
* more expressive selectors), though this is more flexible and allows
57+
* nesting selectors in other selectors.
58+
*
59+
* A selector defined both outside of the `selectors` object and
60+
* inside the `selectors` object is a runtime error.
61+
*
62+
* Note that you cannot nest a `selectors` object inside another
63+
* `selectors` object.
64+
*
65+
* Only use if absolutely necessary.
66+
* @example ```
67+
* const myMap = cssMap({
68+
* danger: {
69+
* color: 'red',
70+
* '@media': {
71+
* '(min-width: 100px)': {
72+
* font-size: '1.5em',
73+
* },
74+
* },
75+
* '&:hover': {
76+
* color: 'pink',
77+
* },
78+
* selectors: {
79+
* '&:not(:active)': {
80+
* backgroundColor: 'yellow',
81+
* }
82+
* },
83+
* },
84+
* success: {
85+
* color: 'green',
86+
* '@media': {
87+
* '(min-width: 100px)': {
88+
* font-size: '1.3em',
89+
* },
90+
* },
91+
* '&:hover': {
92+
* color: '#8f8',
93+
* },
94+
* selectors: {
95+
* '&:not(:active)': {
96+
* backgroundColor: 'white',
97+
* }
98+
* },
99+
* },
100+
* });
101+
* ```
102+
*/
103+
selectors?: ExtendedSelector,
104+
...
105+
};
106+
declare type Variants<VariantName: string> = {
107+
[key: VariantName]: { ...CssProps, ...WhitelistedSelector, ...ExtendedSelectors },
108+
};
109+
declare type ReturnType<VariantName: string> = { [key: VariantName]: CssProps };
8110
/**
9111
* ## cssMap
10112
*
@@ -20,7 +122,4 @@ import type { CSSProps, CssObject } from '../types';
20122
* <Component borderStyle="solid" />
21123
* ```
22124
*/
23-
declare type returnType<T: string, P> = { [key: T]: CSSProps<P> };
24-
declare export default function cssMap<T: string, TProps>(_styles: {
25-
[key: T]: CssObject<TProps> | CssObject<TProps>[],
26-
}): $ReadOnly<returnType<T, TProps>>;
125+
declare export default function cssMap<T: string>(_styles: Variants<T>): $ReadOnly<ReturnType<T>>;

‎packages/react/src/css-map/index.ts

+104-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,108 @@
1-
import type { CSSProps, CssObject } from '../types';
1+
import type { Properties, AtRules } from 'csstype';
2+
23
import { createSetupError } from '../utils/error';
34

5+
import type { Pseudos } from './pseudos';
6+
7+
/**
8+
* These are all the CSS props that will exist.
9+
* Only 'string' and 'number' are valid CSS values.
10+
*
11+
* @example
12+
* ```
13+
* const style: CssProps = {
14+
* color: 'red',
15+
* margin: 10,
16+
* };
17+
* ```
18+
*/
19+
type CssProps = Readonly<Properties<string | number>>;
20+
21+
type AllPseudos = { [key in Pseudos]?: CssProps & AllPseudos };
22+
23+
// The `screen and (max-width: 768px)` part of `@media screen and (max-width: 768px)`.
24+
// Ideally we would do type checking to forbid this from containing the `@media` part,
25+
// but TypeScript doesn't provide a good way to do this.
26+
type AtRuleSecondHalf = string;
27+
type WhitelistedAtRule = {
28+
[atRuleFirstHalf in AtRules]?: {
29+
[atRuleSecondHalf in AtRuleSecondHalf]: CssProps & AllPseudos & WhitelistedAtRule;
30+
};
31+
};
32+
type WhitelistedSelector = AllPseudos & WhitelistedAtRule;
33+
34+
type ExtendedSelector = { [key: string]: CssProps | ExtendedSelector } & {
35+
/**
36+
* Using `selectors` is not valid here - you cannot nest a `selectors` object
37+
* inside another `selectors` object.
38+
*/
39+
selectors?: never;
40+
};
41+
42+
type ExtendedSelectors = {
43+
/**
44+
* Provides a way to use selectors that have not been explicitly whitelisted
45+
* in cssMap.
46+
*
47+
* This does not provide any type-checking for the selectors (thus allowing
48+
* more expressive selectors), though this is more flexible and allows
49+
* nesting selectors in other selectors.
50+
*
51+
* A selector defined both outside of the `selectors` object and
52+
* inside the `selectors` object is a runtime error.
53+
*
54+
* Note that you cannot nest a `selectors` object inside another
55+
* `selectors` object.
56+
*
57+
* Only use if absolutely necessary.
58+
*
59+
* @example
60+
* ```
61+
* const myMap = cssMap({
62+
* danger: {
63+
* color: 'red',
64+
* '@media': {
65+
* '(min-width: 100px)': {
66+
* font-size: '1.5em',
67+
* },
68+
* },
69+
* '&:hover': {
70+
* color: 'pink',
71+
* },
72+
* selectors: {
73+
* '&:not(:active)': {
74+
* backgroundColor: 'yellow',
75+
* }
76+
* },
77+
* },
78+
* success: {
79+
* color: 'green',
80+
* '@media': {
81+
* '(min-width: 100px)': {
82+
* font-size: '1.3em',
83+
* },
84+
* },
85+
* '&:hover': {
86+
* color: '#8f8',
87+
* },
88+
* selectors: {
89+
* '&:not(:active)': {
90+
* backgroundColor: 'white',
91+
* }
92+
* },
93+
* },
94+
* });
95+
* ```
96+
*/
97+
selectors?: ExtendedSelector;
98+
};
99+
100+
type Variants<VariantName extends string> = Record<
101+
VariantName,
102+
CssProps & WhitelistedSelector & ExtendedSelectors
103+
>;
104+
type ReturnType<VariantName extends string> = Record<VariantName, CssProps>;
105+
4106
/**
5107
* ## cssMap
6108
*
@@ -18,10 +120,7 @@ import { createSetupError } from '../utils/error';
18120
* <Component borderStyle="solid" />
19121
* ```
20122
*/
21-
type returnType<T extends string, P> = Record<T, CSSProps<P>>;
22123

23-
export default function cssMap<T extends string, TProps = unknown>(
24-
_styles: Record<T, CssObject<TProps> | CssObject<TProps>[]>
25-
): Readonly<returnType<T, TProps>> {
124+
export default function cssMap<T extends string>(_styles: Variants<T>): Readonly<ReturnType<T>> {
26125
throw createSetupError();
27126
}
+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Flowtype definitions for pseudos
3+
* Generated by Flowgen from a Typescript Definition
4+
* Flowgen v1.21.0
5+
* @flow
6+
*/
7+
export type Pseudos =
8+
| '&::after'
9+
| '&::backdrop'
10+
| '&::before'
11+
| '&::cue'
12+
| '&::cue-region'
13+
| '&::first-letter'
14+
| '&::first-line'
15+
| '&::grammar-error'
16+
| '&::marker'
17+
| '&::placeholder'
18+
| '&::selection'
19+
| '&::spelling-error'
20+
| '&::target-text'
21+
| '&::view-transition'
22+
| '&:active'
23+
| '&:autofill'
24+
| '&:blank'
25+
| '&:checked'
26+
| '&:default'
27+
| '&:defined'
28+
| '&:disabled'
29+
| '&:empty'
30+
| '&:enabled'
31+
| '&:first'
32+
| '&:focus'
33+
| '&:focus-visible'
34+
| '&:focus-within'
35+
| '&:fullscreen'
36+
| '&:hover'
37+
| '&:in-range'
38+
| '&:indeterminate'
39+
| '&:invalid'
40+
| '&:left'
41+
| '&:link'
42+
| '&:local-link'
43+
| '&:optional'
44+
| '&:out-of-range'
45+
| '&:paused'
46+
| '&:picture-in-picture'
47+
| '&:placeholder-shown'
48+
| '&:playing'
49+
| '&:read-only'
50+
| '&:read-write'
51+
| '&:required'
52+
| '&:right'
53+
| '&:target'
54+
| '&:user-invalid'
55+
| '&:user-valid'
56+
| '&:valid'
57+
| '&:visited';

‎packages/react/src/css-map/pseudos.ts

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// List of pseudo-classes and pseudo-elements are from csstype
2+
// but with & added in the front, so that we target the current element
3+
// (instead of a child element)
4+
5+
// We also exclude anything that requires providing an argument
6+
// (e.g. &:not(...) ), and anything that uses information from elements
7+
// outside of the current element (e.g. &:first-of-type)
8+
9+
export type Pseudos =
10+
| '&::after'
11+
| '&::backdrop'
12+
| '&::before'
13+
| '&::cue'
14+
| '&::cue-region'
15+
| '&::first-letter'
16+
| '&::first-line'
17+
| '&::grammar-error'
18+
| '&::marker'
19+
| '&::placeholder'
20+
| '&::selection'
21+
| '&::spelling-error'
22+
| '&::target-text'
23+
| '&::view-transition'
24+
| '&:active'
25+
| '&:autofill'
26+
| '&:blank'
27+
| '&:checked'
28+
| '&:default'
29+
| '&:defined'
30+
| '&:disabled'
31+
| '&:empty'
32+
| '&:enabled'
33+
| '&:first'
34+
| '&:focus'
35+
| '&:focus-visible'
36+
| '&:focus-within'
37+
| '&:fullscreen'
38+
| '&:hover'
39+
| '&:in-range'
40+
| '&:indeterminate'
41+
| '&:invalid'
42+
| '&:left'
43+
| '&:link'
44+
| '&:local-link'
45+
| '&:optional'
46+
| '&:out-of-range'
47+
| '&:paused'
48+
| '&:picture-in-picture'
49+
| '&:placeholder-shown'
50+
| '&:playing'
51+
| '&:read-only'
52+
| '&:read-write'
53+
| '&:required'
54+
| '&:right'
55+
| '&:target'
56+
| '&:user-invalid'
57+
| '&:user-valid'
58+
| '&:valid'
59+
| '&:visited';

‎scripts/flow-types.sh

+6-2
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,12 @@ generate() {
6565
# Refactor interface to object type to allow spreading
6666
sed -i.bak -E 's/export interface StyledProps \{/export type StyledProps = \{/g' "$file" && rm "$file.bak"
6767

68-
# Fix records to object type
69-
sed -i.bak -E 's/Record<(.+), (.+)>/{[key: \1]: \2}/g' "$file" && rm "$file.bak"
68+
# Fix records to object type. `s` flag and `-0777` makes perl
69+
# match multiline.
70+
#
71+
# Assumes that the first argument to Record does not contain
72+
# commas (otherwise this hacky script will break...)
73+
perl -i.bak -0777 -pe 's/Record<(.+?),(.+?)>;/{[key: $1]: $2}/gs' "$file" && rm "$file.bak"
7074

7175
# Change spread to allow correct type matching in flow
7276
sed -i.bak -E 's/\[key: string\]: CssFunction<TProps>,/...CSSProps<TProps>,\n[key: string]: CssFunction<TProps>,/g' "$file" && rm "$file.bak"

‎stories/css-map.tsx

+50-15
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cssMap } from '@compiled/react';
1+
import { cssMap, css } from '@compiled/react';
22
import { useState } from 'react';
33

44
export default {
@@ -7,21 +7,44 @@ export default {
77

88
const styles = cssMap({
99
success: {
10-
color: 'green',
11-
':hover': {
12-
color: 'DarkGreen',
10+
color: '#0b0',
11+
'&:hover': {
12+
color: '#060',
1313
},
14-
'@media (max-width: 800px)': {
15-
color: 'SpringGreen',
14+
// At-rules (@media, @screen, etc.)
15+
'@media': {
16+
'screen and (min-width: 500px)': {
17+
fontSize: '2rem',
18+
},
19+
},
20+
// Using the selectors object for any selectors
21+
// that we do not expressly support.
22+
selectors: {
23+
span: {
24+
color: 'lightgreen',
25+
'&:hover': {
26+
color: '#090',
27+
},
28+
},
1629
},
1730
},
1831
danger: {
1932
color: 'red',
20-
':hover': {
33+
'&:hover': {
2134
color: 'DarkRed',
2235
},
23-
'@media (max-width: 800px)': {
24-
color: 'Crimson',
36+
'@media': {
37+
'screen and (min-width: 500px)': {
38+
fontSize: '2.5rem',
39+
},
40+
},
41+
selectors: {
42+
span: {
43+
color: 'orange',
44+
'&:hover': {
45+
color: 'pink',
46+
},
47+
},
2548
},
2649
},
2750
});
@@ -32,31 +55,43 @@ export const DynamicVariant = (): JSX.Element => {
3255
return (
3356
<>
3457
<div
35-
css={{
58+
css={css({
3659
'> *': {
3760
margin: '5px',
3861
},
39-
}}>
62+
})}>
4063
<button onClick={() => setVariant('success')}>success</button>
4164
<button onClick={() => setVariant('danger')}>danger</button>
42-
<div css={styles[variant]}>hello world</div>
65+
<div css={styles[variant]}>
66+
hello <span>hello!</span> world
67+
</div>
4368
</div>
4469
</>
4570
);
4671
};
4772

4873
export const VariantAsProp = (): JSX.Element => {
4974
const Component = ({ variant }: { variant: keyof typeof styles }) => (
50-
<div css={styles[variant]}>hello world</div>
75+
<div css={styles[variant]}>
76+
hello <span>hello!</span> world
77+
</div>
5178
);
5279
return <Component variant={'success'} />;
5380
};
5481

5582
export const MergeStyles = (): JSX.Element => {
56-
return <div css={[styles.danger, { backgroundColor: 'green' }]}>hello world</div>;
83+
return (
84+
<div css={[styles.danger, css({ backgroundColor: 'green' })]}>
85+
hello <span>hello!</span> world
86+
</div>
87+
);
5788
};
5889

5990
export const ConditionalStyles = (): JSX.Element => {
6091
const isDanger = true;
61-
return <div css={styles[isDanger ? 'danger' : 'success']}>hello world</div>;
92+
return (
93+
<div css={styles[isDanger ? 'danger' : 'success']}>
94+
hello <span>hello!</span> world
95+
</div>
96+
);
6297
};

0 commit comments

Comments
 (0)
Please sign in to comment.