Skip to content

Commit 9857009

Browse files
authoredDec 3, 2023
Introduce createStrictAPI (#1566)
* feat: add create api exploration * fix: type violation when using xcess properties * feat: add css map types to create api * chore: add to test case * chore: another test case * chore: fix types * chore: expose cs * chore: fix * feat: adds spike code * feat: add xcss func type * chore: rename api * fix: pseudo support for xcss prop * chore: separate test cases * chore: fix test * chore: move api behind a module * feat: add support for custom module origins * chore: add assertions to xcss prop usage * chore: add assertions for css() * chore: add tests for cssMap() * feat: add support for absolute/pkg paths * chore: rename to import sources * chore: rename to strict * chore: update jsdoc * chore: add jsdoc * chore: stub * chore: rename * chore: fix tests * chore: fix build * chore: changeset * chore: use root path * chore: remove example tags * chore: update error message to give as much context as possible
1 parent 765b599 commit 9857009

File tree

22 files changed

+847
-31
lines changed

22 files changed

+847
-31
lines changed
 

‎.changeset/weak-numbers-turn.md

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@compiled/babel-plugin': patch
3+
'@compiled/react': patch
4+
---
5+
6+
Introduce new API `createStrictAPI` which returns a strict subset of Compiled APIs augmented by a type definition.
7+
This API does not change Compileds build time behavior — merely augmenting
8+
the returned API types which enforce:
9+
10+
- all APIs use object types
11+
- property values declared in the type definition must be used (else fallback to defaults)
12+
- a strict subset of pseudo states/selectors
13+
- unknown properties to be a type violation
14+
15+
To set up:
16+
17+
1. Declare the API in a module (either local or in a package):
18+
19+
```tsx
20+
import { createStrictAPI } from '@compiled/react';
21+
22+
// ./foo.ts
23+
const { css, cssMap, XCSSProp, cx } = createStrictAPI<{
24+
color: 'var(--ds-text)';
25+
'&:hover': { color: 'var(--ds-text-hover)' };
26+
}>();
27+
28+
// Expose APIs you want to support.
29+
export { css, cssMap, XCSSProp, cx };
30+
```
31+
32+
2. Configure Compiled to pick up this module:
33+
34+
```diff
35+
// .compiledcssrc
36+
{
37+
+ "importSources": ["./foo.ts"]
38+
}
39+
```
40+
41+
3. Use the module in your application code:
42+
43+
```tsx
44+
import { css } from './foo';
45+
46+
const styles = css({ color: 'var(--ds-text)' });
47+
48+
<div css={styles} />;
49+
```

‎babel.config.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
{
2020
"nonce": "\"k0Mp1lEd\"",
2121
"importReact": false,
22-
"optimizeCss": false
22+
"optimizeCss": false,
23+
"importSources": [
24+
"./packages/react/src/create-strict-api/__tests__/__fixtures__/strict-api",
25+
"@fixture/strict-api-test"
26+
]
2327
}
2428
]
2529
]

‎fixtures/strict-api-test/package.json

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "@fixture/strict-api-test",
3+
"version": "0.1.0",
4+
"private": true,
5+
"main": "./src/index.ts",
6+
"dependencies": {
7+
"@compiled/react": "*"
8+
}
9+
}

‎fixtures/strict-api-test/src/index.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { createStrictAPI } from '@compiled/react';
2+
3+
const { css, XCSSProp, cssMap, cx } = createStrictAPI<{
4+
'&:hover': {
5+
color: 'var(--ds-text-hover)';
6+
background: 'var(--ds-surface-hover)' | 'var(--ds-surface-sunken-hover)';
7+
};
8+
color: 'var(--ds-text)';
9+
background: 'var(--ds-surface)' | 'var(--ds-surface-sunken)';
10+
}>();
11+
12+
export { css, XCSSProp, cssMap, cx };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { transform } from '../test-utils';
2+
3+
describe('custom import source', () => {
4+
it('should pick up custom relative import source', () => {
5+
const actual = transform(
6+
`
7+
import { css } from '../bar/stub-api';
8+
9+
const styles = css({ color: 'red' });
10+
11+
<div css={styles} />
12+
`,
13+
{ filename: './foo/index.js', importSources: ['./bar/stub-api'] }
14+
);
15+
16+
expect(actual).toInclude('@compiled/react/runtime');
17+
});
18+
19+
it('should pick up custom absolute import source', () => {
20+
const actual = transform(
21+
`
22+
import { css } from '/bar/stub-api';
23+
24+
const styles = css({ color: 'red' });
25+
26+
<div css={styles} />
27+
`,
28+
{ filename: './foo/index.js', importSources: ['/bar/stub-api'] }
29+
);
30+
31+
expect(actual).toInclude('@compiled/react/runtime');
32+
});
33+
34+
it('should pick up custom package import source', () => {
35+
const actual = transform(
36+
`
37+
import { css } from '@af/compiled';
38+
39+
const styles = css({ color: 'red' });
40+
41+
<div css={styles} />
42+
`,
43+
{ filename: './foo/index.js', importSources: ['@af/compiled'] }
44+
);
45+
46+
expect(actual).toInclude('@compiled/react/runtime');
47+
});
48+
49+
it("should handle custom package sources that aren't found", () => {
50+
expect(() =>
51+
transform(
52+
`
53+
import { css } from '@af/compiled';
54+
55+
const styles = css({ color: 'red' });
56+
57+
<div css={styles} />
58+
`,
59+
{ filename: './foo/index.js', importSources: ['asdasd2323'] }
60+
)
61+
).not.toThrow();
62+
});
63+
64+
it('should throw error explaining resolution steps when using custom import source that hasnt been configured', () => {
65+
expect(() =>
66+
transform(
67+
`
68+
/** @jsxImportSource @compiled/react */
69+
import { css } from '@private/misconfigured';
70+
71+
const styles = css({ color: 'red' });
72+
73+
<div css={styles} />
74+
`,
75+
{ filename: '/foo/index.js', highlightCode: false }
76+
)
77+
).toThrowErrorMatchingInlineSnapshot(`
78+
"/foo/index.js: This CallExpression was unable to have its styles extracted — no Compiled APIs were found in scope, if you're using createStrictAPI make sure to configure importSources (5:23).
79+
3 | import { css } from '@private/misconfigured';
80+
4 |
81+
> 5 | const styles = css({ color: 'red' });
82+
| ^^^^^^^^^^^^^^^^^^^^^
83+
6 |
84+
7 | <div css={styles} />
85+
8 | "
86+
`);
87+
});
88+
});

‎packages/babel-plugin/src/__tests__/errors.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe('error handling', () => {
1515
<div css={() => {}} />
1616
`);
1717
}).toThrowErrorMatchingInlineSnapshot(`
18-
"unknown file: ArrowFunctionExpression isn't a supported CSS type - try using an object or string (4:18).
18+
"unknown file: This ArrowFunctionExpression was unable to have its styles extracted — no Compiled APIs were found in scope, if you're using createStrictAPI make sure to configure importSources (4:18).
1919
2 | import '@compiled/react';
2020
3 |
2121
> 4 | <div css={() => {}} />

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

+42-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { basename } from 'path';
1+
import { basename, resolve, join, dirname } from 'path';
22

33
import { declare } from '@babel/helper-plugin-utils';
44
import jsxSyntax from '@babel/plugin-syntax-jsx';
@@ -30,7 +30,7 @@ import { visitXcssPropPath } from './xcss-prop';
3030
const packageJson = require('../package.json');
3131
const JSX_SOURCE_ANNOTATION_REGEX = /\*?\s*@jsxImportSource\s+([^\s]+)/;
3232
const JSX_ANNOTATION_REGEX = /\*?\s*@jsx\s+([^\s]+)/;
33-
const COMPILED_MODULE = '@compiled/react';
33+
const DEFAULT_IMPORT_SOURCE = '@compiled/react';
3434

3535
let globalCache: Cache | undefined;
3636

@@ -41,6 +41,8 @@ export default declare<State>((api) => {
4141
name: packageJson.name,
4242
inherits: jsxSyntax,
4343
pre(state) {
44+
const rootPath = state.opts.root ?? this.cwd;
45+
4446
this.sheets = {};
4547
this.cssMap = {};
4648
let cache: Cache;
@@ -59,12 +61,25 @@ export default declare<State>((api) => {
5961
this.pathsToCleanup = [];
6062
this.pragma = {};
6163
this.usesXcss = false;
64+
this.importSources = [
65+
DEFAULT_IMPORT_SOURCE,
66+
...(this.opts.importSources
67+
? this.opts.importSources.map((origin) => {
68+
if (origin[0] === '.') {
69+
// We've found a relative path, transform it to be fully qualified.
70+
return join(rootPath, origin);
71+
}
72+
73+
return origin;
74+
})
75+
: []),
76+
];
6277

6378
if (typeof this.opts.resolver === 'object') {
6479
this.resolver = this.opts.resolver;
6580
} else if (typeof this.opts.resolver === 'string') {
6681
this.resolver = require(require.resolve(this.opts.resolver, {
67-
paths: [state.opts.root ?? this.cwd],
82+
paths: [rootPath],
6883
}));
6984
}
7085

@@ -80,7 +95,9 @@ export default declare<State>((api) => {
8095
const jsxSourceMatches = JSX_SOURCE_ANNOTATION_REGEX.exec(comment.value);
8196
const jsxMatches = JSX_ANNOTATION_REGEX.exec(comment.value);
8297

83-
if (jsxSourceMatches && jsxSourceMatches[1] === COMPILED_MODULE) {
98+
// jsxPragmas currently only run on the top-level compiled module,
99+
// hence we don't interrogate this.importSources.
100+
if (jsxSourceMatches && jsxSourceMatches[1] === DEFAULT_IMPORT_SOURCE) {
84101
// jsxImportSource pragma found - turn on CSS prop!
85102
state.compiledImports = {};
86103
state.pragma.jsxImportSource = true;
@@ -159,7 +176,27 @@ export default declare<State>((api) => {
159176
},
160177
},
161178
ImportDeclaration(path, state) {
162-
if (path.node.source.value !== COMPILED_MODULE) {
179+
const userLandModule = path.node.source.value;
180+
181+
const isCompiledModule = this.importSources.some((compiledModuleOrigin) => {
182+
if (userLandModule === DEFAULT_IMPORT_SOURCE || compiledModuleOrigin === userLandModule) {
183+
return true;
184+
}
185+
186+
if (
187+
state.filename &&
188+
userLandModule[0] === '.' &&
189+
userLandModule.endsWith(basename(compiledModuleOrigin))
190+
) {
191+
// Relative import that might be a match, resolve the relative path and compare.
192+
const fullpath = resolve(dirname(state.filename), userLandModule);
193+
return fullpath === compiledModuleOrigin;
194+
}
195+
196+
return false;
197+
});
198+
199+
if (!isCompiledModule) {
163200
return;
164201
}
165202

‎packages/babel-plugin/src/styled/__tests__/behaviour.test.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,9 @@ describe('styled component behaviour', () => {
183183

184184
it('creates a separate var name for positive and negative values of the same interpolation', () => {
185185
const actual = transform(`
186-
import { styled } from '@compiled/react';
186+
import { styled } from '@compiled/react';
187187
const random = Math.random;
188-
188+
189189
const LayoutRight = styled.aside\`
190190
margin-right: -\${random() * 5}px;
191191
margin-left: \${random() * 5}px;
@@ -749,7 +749,9 @@ describe('styled component behaviour', () => {
749749
\${props => props.isShown && (props.isPrimary ? { color: 'blue' } : { color: 'green' })};
750750
\`;
751751
`)
752-
).toThrow("ConditionalExpression isn't a supported CSS type");
752+
).toThrow(
753+
'This ConditionalExpression was unable to have its styles extracted — try to define them statically using Compiled APIs instead'
754+
);
753755
});
754756

755757
it('should apply conditional CSS when using "key: value" in string form', () => {

‎packages/babel-plugin/src/types.ts

+10
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export interface PluginOptions {
3232
*/
3333
nonce?: string;
3434

35+
/**
36+
* Custom module origins that Compiled should compile when using APIs from.
37+
*/
38+
importSources?: string[];
39+
3540
/**
3641
* Callback fired at the end of the file pass when files have been included in the transformation.
3742
*/
@@ -115,6 +120,11 @@ export interface State extends PluginPass {
115120
css?: string;
116121
};
117122

123+
/**
124+
* Modules that expose APIs to be compiled by Compiled.
125+
*/
126+
importSources: string[];
127+
118128
/**
119129
* Details of pragmas that are currently enabled in the pass.
120130
*/

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

+8-1
Original file line numberDiff line numberDiff line change
@@ -953,8 +953,15 @@ export const buildCss = (node: t.Expression | t.Expression[], meta: Metadata): C
953953
return buildCss(node.arguments[0] as t.ObjectExpression, meta);
954954
}
955955

956+
const areCompiledAPIsEnabled =
957+
meta.state.compiledImports && Object.keys(meta.state.compiledImports).length > 0;
958+
959+
const errorMessage = areCompiledAPIsEnabled
960+
? 'try to define them statically using Compiled APIs instead'
961+
: "no Compiled APIs were found in scope, if you're using createStrictAPI make sure to configure importSources";
962+
956963
throw buildCodeFrameError(
957-
`${node.type} isn't a supported CSS type - try using an object or string`,
964+
`This ${node.type} was unable to have its styles extracted — ${errorMessage}`,
958965
node,
959966
meta.parentPath
960967
);

‎packages/react/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
},
7777
"devDependencies": {
7878
"@compiled/benchmark": "^1.1.0",
79+
"@fixture/strict-api-test": "*",
7980
"@testing-library/react": "^12.1.5",
8081
"@types/jsdom": "^16.2.15",
8182
"@types/react-dom": "^17.0.22",

‎packages/react/src/class-names/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export interface ClassNamesProps<TProps> {
1919
}
2020

2121
/**
22-
* ## Class names
22+
* ## Class Names
2323
*
2424
* Use a component where styles are not necessarily used on a JSX element.
2525
* For further details [read the documentation](https://compiledcssinjs.com/docs/api-class-names).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createStrictAPI } from '@compiled/react';
2+
3+
const { css, XCSSProp, cssMap, cx } = createStrictAPI<{
4+
'&:hover': {
5+
color: 'var(--ds-text-hover)';
6+
background: 'var(--ds-surface-hover)' | 'var(--ds-surface-sunken-hover)';
7+
};
8+
color: 'var(--ds-text)';
9+
background: 'var(--ds-surface)' | 'var(--ds-surface-sunken)';
10+
bkgrnd: 'red' | 'green';
11+
}>();
12+
13+
export { css, XCSSProp, cssMap, cx };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
/** @jsxImportSource @compiled/react */
2+
import { render } from '@testing-library/react';
3+
4+
import { css, cssMap, XCSSProp } from './__fixtures__/strict-api';
5+
6+
describe('createStrictAPI()', () => {
7+
describe('css()', () => {
8+
it('should type error when circumventing the excess property check', () => {
9+
const styles = css({
10+
color: 'var(--ds-text)',
11+
accentColor: 'red',
12+
// @ts-expect-error — Type 'string' is not assignable to type 'undefined'.ts(2322)
13+
bkgrnd: 'red',
14+
'&:hover': {
15+
color: 'var(--ds-text-hover)',
16+
// @ts-expect-error — Type 'string' is not assignable to type 'undefined'.ts(2322)
17+
bkgrnd: 'red',
18+
},
19+
});
20+
21+
const { getByTestId } = render(<div css={styles} data-testid="div" />);
22+
23+
expect(getByTestId('div')).toHaveCompiledCss('color', 'var(--ds-text)');
24+
});
25+
26+
it('should constrain declared types for css() func', () => {
27+
// @ts-expect-error — Type '"red"' is not assignable to type '"var(--ds-surface)" | "var(--ds-surface-sunken" | undefined'.ts(2322)
28+
const styles = css({ background: 'red' });
29+
30+
const { getByTestId } = render(<div css={styles} data-testid="div" />);
31+
32+
expect(getByTestId('div')).toHaveCompiledCss('background-color', 'red');
33+
});
34+
35+
it('should mark all properties as optional', () => {
36+
const styles1 = css({});
37+
const styles2 = css({ '&:hover': {} });
38+
39+
const { getByTestId } = render(<div css={[styles1, styles2]} data-testid="div" />);
40+
41+
expect(getByTestId('div')).not.toHaveCompiledCss('color', 'red');
42+
});
43+
44+
it('should constrain pseudos', () => {
45+
const styles = css({
46+
// @ts-expect-error — Type '"red"' is not assignable to type '"var(--ds-surface)" | "var(--ds-surface-sunken" | undefined'.ts(2322)
47+
background: 'red',
48+
'&:hover': {
49+
// @ts-expect-error — Type '"red"' is not assignable to type '"var(--ds-surface)" | "var(--ds-surface-sunken" | undefined'.ts(2322)
50+
background: 'red',
51+
},
52+
});
53+
54+
const { getByTestId } = render(<div css={styles} data-testid="div" />);
55+
56+
expect(getByTestId('div')).toHaveCompiledCss('background-color', 'red', { target: ':hover' });
57+
});
58+
59+
it('should allow valid properties inside pseudos that are different to root', () => {
60+
const styles = css({
61+
background: 'var(--ds-surface)',
62+
'&:hover': {
63+
accentColor: 'red',
64+
background: 'var(--ds-surface-hover)',
65+
},
66+
});
67+
68+
const { getByTestId } = render(<div css={styles} data-testid="div" />);
69+
70+
expect(getByTestId('div')).toHaveCompiledCss('background', 'var(--ds-surface-hover)', {
71+
target: ':hover',
72+
});
73+
});
74+
75+
it('should allow valid properties', () => {
76+
const styles = css({
77+
background: 'var(--ds-surface)',
78+
accentColor: 'red',
79+
color: 'var(--ds-text)',
80+
all: 'inherit',
81+
'&:hover': { color: 'var(--ds-text-hover)' },
82+
'&:invalid': { color: 'orange' },
83+
});
84+
85+
const { getByTestId } = render(<div css={styles} data-testid="div" />);
86+
87+
expect(getByTestId('div')).toHaveCompiledCss('all', 'inherit');
88+
});
89+
});
90+
91+
describe('cssMap()', () => {
92+
it('should allow valid properties', () => {
93+
const styles = cssMap({
94+
primary: {
95+
background: 'var(--ds-surface)',
96+
accentColor: 'red',
97+
all: 'inherit',
98+
'&:hover': { color: 'var(--ds-text-hover)' },
99+
'&:invalid': { color: 'orange' },
100+
},
101+
});
102+
103+
const { getByTestId } = render(<div css={styles.primary} data-testid="div" />);
104+
105+
expect(getByTestId('div')).toHaveCompiledCss('background', 'var(--ds-surface)');
106+
});
107+
108+
it('should allow valid properties inside pseudos that are different to root', () => {
109+
const styles = cssMap({
110+
primary: {
111+
background: 'var(--ds-surface)',
112+
'&:hover': {
113+
accentColor: 'red',
114+
background: 'var(--ds-surface-hover)',
115+
},
116+
},
117+
});
118+
119+
const { getByTestId } = render(<div css={styles.primary} data-testid="div" />);
120+
121+
expect(getByTestId('div')).toHaveCompiledCss('background', 'var(--ds-surface-hover)', {
122+
target: ':hover',
123+
});
124+
});
125+
126+
it('should type error invalid vales', () => {
127+
const styles = cssMap({
128+
primary: {
129+
// @ts-expect-error — Type '{ val: string; }' is not assignable to type 'Readonly<Properties<string | number, string & {}>> & PseudosDeclarations & EnforceSchema<{ background: "var(--ds-surface)" | "var(--ds-surface-sunken"; }>'.
130+
val: 'ok',
131+
},
132+
});
133+
134+
const { getByTestId } = render(<div css={styles.primary} data-testid="div" />);
135+
136+
expect(getByTestId('div')).toHaveCompiledCss('val', 'ok');
137+
});
138+
139+
it('should type error invalid values in pseudos', () => {
140+
const styles = cssMap({
141+
primary: {
142+
// @ts-expect-error — Type '"red"' is not assignable to type '"var(--ds-surface)" | "var(--ds-surface-sunken" | undefined'.ts(2322)
143+
background: 'red',
144+
'&:hover': {
145+
// @ts-expect-error — Type 'string' is not assignable to type 'never'.ts(2322)
146+
val: 'ok',
147+
},
148+
},
149+
});
150+
151+
const { getByTestId } = render(<div css={styles.primary} data-testid="div" />);
152+
153+
expect(getByTestId('div')).toHaveCompiledCss('val', 'ok', { target: ':hover' });
154+
});
155+
});
156+
157+
describe('XCSSProp', () => {
158+
it('should allow valid values', () => {
159+
function Button({ xcss }: { xcss: ReturnType<typeof XCSSProp<'background', never>> }) {
160+
return <button data-testid="button" className={xcss} />;
161+
}
162+
163+
const { getByTestId } = render(<Button xcss={{ background: 'var(--ds-surface)' }} />);
164+
165+
expect(getByTestId('button')).toHaveCompiledCss('background', 'var(--ds-surface)');
166+
});
167+
168+
it('should type error for invalid known values', () => {
169+
function Button({ xcss }: { xcss: ReturnType<typeof XCSSProp<'background', never>> }) {
170+
return <button data-testid="button" className={xcss} />;
171+
}
172+
173+
const { getByTestId } = render(
174+
<Button
175+
xcss={{
176+
// @ts-expect-error — Type '"red"' is not assignable to type '"var(--ds-surface)" | "var(--ds-surface-sunken" | CompiledPropertyDeclarationReference | undefined'.ts(2322)
177+
background: 'red',
178+
// @ts-expect-error — Type '{ background: string; }' is not assignable to type 'undefined'.ts(2322)
179+
'&::after': {
180+
background: 'red',
181+
},
182+
}}
183+
/>
184+
);
185+
186+
expect(getByTestId('button')).toHaveCompiledCss('background-color', 'red');
187+
});
188+
189+
it('should type error for invalid unknown values', () => {
190+
function Button({ xcss }: { xcss: ReturnType<typeof XCSSProp<'background', never>> }) {
191+
return <button data-testid="button" className={xcss} />;
192+
}
193+
194+
const { getByTestId } = render(
195+
<Button
196+
xcss={{
197+
// @ts-expect-error — Type '{ asd: number; }' is not assignable to type 'Internal$XCSSProp<"background", never, { background: "var(--ds-surface)" | "var(--ds-surface-sunken"; }, PickObjects<{ background: "var(--ds-surface)" | "var(--ds-surface-sunken"; }>, never>'.
198+
asd: 0,
199+
}}
200+
/>
201+
);
202+
203+
expect(getByTestId('button')).toHaveCompiledCss('asd', '0');
204+
});
205+
206+
it('should type error for unsupported known pseudos', () => {
207+
function Button({ xcss }: { xcss: ReturnType<typeof XCSSProp<'background', never>> }) {
208+
return <button data-testid="button" className={xcss} />;
209+
}
210+
const { getByTestId } = render(
211+
<Button
212+
xcss={{
213+
// @ts-expect-error — Object literal may only specify known properties, and '':hover'' does not exist in type
214+
':hover': {
215+
color: 'red',
216+
},
217+
}}
218+
/>
219+
);
220+
221+
expect(getByTestId('button')).toHaveCompiledCss('color', 'red', { target: ':hover' });
222+
});
223+
224+
it('should type error for unsupported unknown pseudos', () => {
225+
function Button({ xcss }: { xcss: ReturnType<typeof XCSSProp<'background', never>> }) {
226+
return <button data-testid="button" className={xcss} />;
227+
}
228+
229+
const { getByTestId } = render(
230+
<Button
231+
xcss={{
232+
// @ts-expect-error — Object literal may only specify known properties, and '':hover'' does not exist in type
233+
':asd': {
234+
color: 'red',
235+
},
236+
}}
237+
/>
238+
);
239+
240+
expect(getByTestId('button')).toHaveCompiledCss('color', 'red', { target: ':asd' });
241+
});
242+
243+
it('should type error for invalid known values in pseudos', () => {
244+
function Button({ xcss }: { xcss: ReturnType<typeof XCSSProp<'color', '&:hover'>> }) {
245+
return <button data-testid="button" className={xcss} />;
246+
}
247+
248+
const { getByTestId } = render(
249+
<Button
250+
xcss={{
251+
'&:hover': {
252+
// @ts-expect-error — Type '"red"' is not assignable to type 'CompiledPropertyDeclarationReference | "var(--ds-text)" | undefined'.ts(2322)
253+
color: 'red',
254+
},
255+
}}
256+
/>
257+
);
258+
259+
expect(getByTestId('button')).toHaveCompiledCss('color', 'red', { target: ':hover' });
260+
});
261+
262+
it('should type error for invalid unknown values in pseudos', () => {
263+
function Button({ xcss }: { xcss: ReturnType<typeof XCSSProp<'color', '&:hover'>> }) {
264+
return <button data-testid="button" className={xcss} />;
265+
}
266+
267+
const { getByTestId } = render(
268+
<Button
269+
xcss={{
270+
'&:hover': {
271+
// @ts-expect-error — Type '{ asd: string; }' is not assignable to type 'MarkAsRequired<XCSSItem<"color", { color: "var(--ds-text)"; }>, never>'.
272+
asd: 'red',
273+
},
274+
}}
275+
/>
276+
);
277+
278+
expect(getByTestId('button')).toHaveCompiledCss('asd', 'red', { target: ':hover' });
279+
});
280+
281+
it('should enforce required properties', () => {
282+
function Button({
283+
xcss,
284+
}: {
285+
xcss: ReturnType<
286+
typeof XCSSProp<
287+
'background',
288+
never,
289+
{ requiredProperties: 'background'; requiredPseudos: never }
290+
>
291+
>;
292+
}) {
293+
return <button data-testid="button" className={xcss} />;
294+
}
295+
296+
const { getByTestId } = render(
297+
<Button
298+
// @ts-expect-error — Type '{}' is not assignable to type 'Internal$XCSSProp<"background", never, EnforceSchema<{ background: "var(--ds-surface)" | "var(--ds-surface-sunken"; }>, object, { requiredProperties: "background"; requiredPseudos: never; }>'.ts(2322)
299+
xcss={{}}
300+
/>
301+
);
302+
303+
expect(getByTestId('button')).not.toHaveCompiledCss('color', 'red');
304+
});
305+
});
306+
307+
it('should throw when calling XCSSProp directly', () => {
308+
expect(() => {
309+
XCSSProp();
310+
}).toThrow();
311+
});
312+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/** @jsxImportSource @compiled/react */
2+
// eslint-disable-next-line import/no-extraneous-dependencies
3+
import { css } from '@fixture/strict-api-test';
4+
import { render } from '@testing-library/react';
5+
6+
describe('createStrictAPI()', () => {
7+
describe('css()', () => {
8+
it('should type error when circumventing the excess property check', () => {
9+
const styles = css({
10+
color: 'var(--ds-text)',
11+
'&:hover': {
12+
color: 'var(--ds-text-hover)',
13+
},
14+
});
15+
16+
const { getByTestId } = render(<div css={styles} data-testid="div" />);
17+
18+
expect(getByTestId('div')).toHaveCompiledCss('color', 'var(--ds-text)');
19+
});
20+
});
21+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import type { StrictCSSProperties, CSSPseudos } from '../types';
2+
import { createSetupError } from '../utils/error';
3+
import { type CompiledStyles, cx, type Internal$XCSSProp } from '../xcss-prop';
4+
5+
type PseudosDeclarations = {
6+
[Q in CSSPseudos]?: StrictCSSProperties;
7+
};
8+
9+
type EnforceSchema<TObject> = {
10+
[P in keyof TObject]?: P extends keyof CompiledSchema
11+
? TObject[P] extends Record<string, unknown>
12+
? EnforceSchema<TObject[P]>
13+
: TObject[P]
14+
: never;
15+
};
16+
17+
type PickObjects<TObject> = {
18+
[P in keyof TObject]: TObject[P] extends Record<string, unknown> ? TObject[P] : never;
19+
};
20+
21+
interface CompiledAPI<TSchema> {
22+
/**
23+
* ## CSS
24+
*
25+
* Creates styles that are statically typed and useable with other Compiled APIs.
26+
* For further details [read the documentation](https://compiledcssinjs.com/docs/api-css).
27+
*
28+
* @example
29+
* ```
30+
* const redText = css({
31+
* color: 'red',
32+
* });
33+
*
34+
* <div css={redText} />
35+
* ```
36+
*/
37+
css(
38+
styles: StrictCSSProperties & PseudosDeclarations & EnforceSchema<TSchema>
39+
): StrictCSSProperties;
40+
/**
41+
* ## CSS Map
42+
*
43+
* Creates a collection of named styles that are statically typed and useable with other Compiled APIs.
44+
* For further details [read the documentation](https://compiledcssinjs.com/docs/api-cssmap).
45+
*
46+
* @example
47+
* ```
48+
* const styles = cssMap({
49+
* none: { borderStyle: 'none' },
50+
* solid: { borderStyle: 'solid' },
51+
* });
52+
*
53+
* <div css={styles.solid} />
54+
* ```
55+
*/
56+
cssMap<
57+
TStyles extends Record<
58+
string,
59+
StrictCSSProperties & PseudosDeclarations & EnforceSchema<TSchema>
60+
>
61+
>(
62+
styles: TStyles
63+
): {
64+
readonly [P in keyof TStyles]: CompiledStyles<TStyles[P]>;
65+
};
66+
/**
67+
* ## CX
68+
*
69+
* Use in conjunction with the {@link XCSSProp} to concatenate and conditionally apply
70+
* declared styles. Can only be used with the {@link cssMap} and {@link XCSSProp} APIs.
71+
*
72+
* @example
73+
* ```
74+
* const styles = cssMap({
75+
* text: { color: 'var(--ds-text)' },
76+
* primary: { color: 'var(--ds-text-brand)' },
77+
* });
78+
*
79+
* <Component xcss={cx(isPrimary && styles.text, !isPrimary && styles.primary)} />
80+
* ```
81+
*/
82+
cx: typeof cx;
83+
/**
84+
* ## XCSSProp
85+
*
86+
* Declare styles your component takes with all other styles marked as violations
87+
* by the TypeScript compiler. There are two primary use cases for xcss prop:
88+
*
89+
* - safe style overrides
90+
* - inverting style declarations
91+
*
92+
* Interverting style declarations is interesting for platform teams as
93+
* it means products only pay for styles they use as they're now the ones who declare
94+
* the styles!
95+
*
96+
* The {@link XCSSProp} type has generics two of which must be defined — use to explicitly
97+
* set want you to maintain as API. Use {@link XCSSAllProperties} and {@link XCSSAllPseudos}
98+
* to enable all properties and pseudos.
99+
*
100+
* The third generic is used to declare what properties and pseudos should be required.
101+
*
102+
* ```tsx
103+
* interface MyComponentProps {
104+
* // Color is accepted, all other properties / pseudos are considered violations.
105+
* xcss?: ReturnType<typeof XCSSProp<'color', never>>;
106+
*
107+
* // Only backgrond color and hover pseudo is accepted.
108+
* xcss?: ReturnType<typeof XCSSProp<'backgroundColor', '&:hover'>>;
109+
*
110+
* // All properties are accepted, all pseudos are considered violations.
111+
* xcss?: ReturnType<typeof XCSSProp<XCSSAllProperties, never>>;
112+
*
113+
* // All properties are accepted, only the hover pseudo is accepted.
114+
* xcss?: ReturnType<typeof XCSSProp<XCSSAllProperties, '&:hover'>>;
115+
*
116+
* // The xcss prop is required as well as the color property. No pseudos are required.
117+
* xcss: ReturnType<
118+
* typeof XCSSProp<
119+
* XCSSAllProperties,
120+
* '&:hover',
121+
* { requiredProperties: 'color', requiredPseudos: never }
122+
* >
123+
* >;
124+
* }
125+
*
126+
* function MyComponent({ xcss }: MyComponentProps) {
127+
* return <div css={{ color: 'var(--ds-text-danger)' }} className={xcss} />
128+
* }
129+
* ```
130+
*
131+
* The xcss prop works with static inline objects and the [cssMap](https://compiledcssinjs.com/docs/api-cssmap) API.
132+
*
133+
* ```jsx
134+
* // Declared as an inline object
135+
* <Component xcss={{ color: 'var(--ds-text)' }} />
136+
*
137+
* // Declared with the cssMap API
138+
* const styles = cssMap({ text: { color: 'var(--ds-text)' } });
139+
* <Component xcss={styles.text} />
140+
* ```
141+
*
142+
* To concatenate and conditonally apply styles use the {@link cssMap} and {@link cx} functions.
143+
*/
144+
XCSSProp<
145+
TAllowedProperties extends keyof StrictCSSProperties,
146+
TAllowedPseudos extends CSSPseudos,
147+
TRequiredProperties extends {
148+
requiredProperties: TAllowedProperties;
149+
requiredPseudos: TAllowedPseudos;
150+
} = never
151+
>(): Internal$XCSSProp<
152+
TAllowedProperties,
153+
TAllowedPseudos,
154+
TSchema,
155+
PickObjects<TSchema>,
156+
TRequiredProperties
157+
>;
158+
}
159+
160+
type CompiledSchema = StrictCSSProperties & PseudosDeclarations;
161+
162+
/**
163+
* ## Create Strict API
164+
*
165+
* Returns a strict subset of Compiled APIs augmented by a type definition.
166+
* This API does not change Compileds build time behavior — merely augmenting
167+
* the returned API types which enforce:
168+
*
169+
* - all APIs use object types
170+
* - property values declared in the type definition must be used (else fallback to defaults)
171+
* - a strict subset of pseudo states/selectors
172+
* - unknown properties to be a type violation
173+
*
174+
* To set up:
175+
*
176+
* 1. Declare the API in a module (either local or in a package):
177+
*
178+
* @example
179+
* ```tsx
180+
* // ./foo.ts
181+
* const { css } = createStrictAPI<{
182+
* color: 'var(--ds-text)',
183+
* '&:hover': { color: 'var(--ds-text-hover)' }
184+
* }>();
185+
*
186+
* export { css };
187+
* ```
188+
*
189+
* 2. Configure Compiled to pick up this module:
190+
*
191+
* @example
192+
* ```diff
193+
* // .compiledcssrc
194+
* {
195+
* + "importSources": ["./foo.ts"]
196+
* }
197+
* ```
198+
*
199+
* 3. Use the module in your application code:
200+
*
201+
* @example
202+
* ```tsx
203+
* import { css } from './foo';
204+
*
205+
* const styles = css({ color: 'var(--ds-text)' });
206+
*
207+
* <div css={styles} />
208+
* ```
209+
*/
210+
export function createStrictAPI<TSchema extends CompiledSchema>(): CompiledAPI<TSchema> {
211+
return {
212+
css() {
213+
throw createSetupError();
214+
},
215+
cssMap() {
216+
throw createSetupError();
217+
},
218+
cx,
219+
XCSSProp() {
220+
throw createSetupError();
221+
},
222+
};
223+
}

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

+3-5
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ type ExtendedSelectors = {
8484
};
8585

8686
/**
87-
* ## cssMap
87+
* ## CSS Map
8888
*
89-
* Creates a collection of named CSS rules that are statically typed and useable with other Compiled APIs.
89+
* Creates a collection of named styles that are statically typed and useable with other Compiled APIs.
9090
* For further details [read the documentation](https://compiledcssinjs.com/docs/api-cssmap).
9191
*
9292
* @example
@@ -96,9 +96,7 @@ type ExtendedSelectors = {
9696
* solid: { borderStyle: 'solid' },
9797
* });
9898
*
99-
* const Component = ({ borderStyle }) => <div css={styles[borderStyle]} />
100-
*
101-
* <Component borderStyle="solid" />
99+
* <div css={styles.solid} />
102100
* ```
103101
*/
104102
export default function cssMap<

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createSetupError } from '../utils/error';
66
/**
77
* ## CSS
88
*
9-
* Define styles that are statically typed and useable with other Compiled APIs.
9+
* Create styles that are statically typed and useable with other Compiled APIs.
1010
* For further details [read the documentation](https://compiledcssinjs.com/docs/api-css).
1111
*
1212
* ### Style with objects

‎packages/react/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export { styled } from './styled';
1010
export { ClassNames } from './class-names';
1111
export { default as css } from './css';
1212
export { default as cssMap } from './css-map';
13+
export { createStrictAPI } from './create-strict-api';
1314
export { type XCSSAllProperties, type XCSSAllPseudos, type XCSSProp, cx } from './xcss-prop';
1415

1516
// Pass through the (classic) jsx runtime.

‎packages/react/src/types.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,15 @@ export type CSSPseudos =
9595
| '&:visited';
9696

9797
/**
98-
* The xcss prop must be given all known available properties even
99-
* if it takes a subset of them. This is ensure the (lack-of an)
98+
* The XCSSProp must be given all known available properties even
99+
* if it takes a subset of them. This ensures the (lack-of an)
100100
* excess property check doesn't enable makers to circumvent the
101101
* system and pass in values they shouldn't.
102102
*/
103103
export type CSSProperties = Readonly<CSS.Properties<string | number>>;
104+
105+
/**
106+
* A stricter subset of the {@link CSSProperties} type that excludes
107+
* vendor and obsolete properties.
108+
*/
109+
export type StrictCSSProperties = Readonly<CSS.StandardProperties & CSS.SvgProperties>;

‎packages/react/src/xcss-prop/index.ts

+33-10
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,30 @@ import type * as CSS from 'csstype';
33
import { ac } from '../runtime';
44
import type { CSSPseudos, CSSProperties } from '../types';
55

6-
type XCSSItem<TStyleDecl extends keyof CSSProperties> = {
6+
type MarkAsRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
7+
8+
type XCSSItem<TStyleDecl extends keyof CSSProperties, TCompiledTypedProperty> = {
79
[Q in keyof CSSProperties]: Q extends TStyleDecl
8-
? CompiledPropertyDeclarationReference | string | number
10+
?
11+
| CompiledPropertyDeclarationReference
12+
| (Q extends keyof TCompiledTypedProperty ? TCompiledTypedProperty[Q] : CSSProperties[Q])
913
: never;
1014
};
1115

1216
type XCSSPseudos<
1317
TAllowedProperties extends keyof CSSProperties,
1418
TAllowedPseudos extends CSSPseudos,
15-
TRequiredProperties extends { requiredProperties: TAllowedProperties }
19+
TRequiredProperties extends { requiredProperties: TAllowedProperties },
20+
TCompiledTypedPseudo
1621
> = {
1722
[Q in CSSPseudos]?: Q extends TAllowedPseudos
18-
? MarkAsRequired<XCSSItem<TAllowedProperties>, TRequiredProperties['requiredProperties']>
23+
? MarkAsRequired<
24+
XCSSItem<
25+
TAllowedProperties,
26+
Q extends keyof TCompiledTypedPseudo ? TCompiledTypedPseudo[Q] : object
27+
>,
28+
TRequiredProperties['requiredProperties']
29+
>
1930
: never;
2031
};
2132

@@ -69,7 +80,7 @@ export type XCSSAllProperties = keyof CSSProperties;
6980
export type XCSSAllPseudos = CSSPseudos;
7081

7182
/**
72-
* ## xcss prop
83+
* ## XCSSProp
7384
*
7485
* Declare styles your component takes with all other styles marked as violations
7586
* by the TypeScript compiler. There are two primary use cases for xcss prop:
@@ -132,21 +143,33 @@ export type XCSSProp<
132143
requiredProperties: TAllowedProperties;
133144
requiredPseudos: TAllowedPseudos;
134145
} = never
146+
> = Internal$XCSSProp<TAllowedProperties, TAllowedPseudos, object, object, TRequiredProperties>;
147+
148+
export type Internal$XCSSProp<
149+
TAllowedProperties extends keyof CSSProperties,
150+
TAllowedPseudos extends CSSPseudos,
151+
TCompiledTypedProperty,
152+
TCompiledTypedPseudo,
153+
TRequiredProperties extends {
154+
requiredProperties: TAllowedProperties;
155+
requiredPseudos: TAllowedPseudos;
156+
}
135157
> =
136-
| (MarkAsRequired<XCSSItem<TAllowedProperties>, TRequiredProperties['requiredProperties']> &
158+
| (MarkAsRequired<
159+
XCSSItem<TAllowedProperties, TCompiledTypedProperty>,
160+
TRequiredProperties['requiredProperties']
161+
> &
137162
MarkAsRequired<
138-
XCSSPseudos<TAllowedProperties, TAllowedPseudos, TRequiredProperties>,
163+
XCSSPseudos<TAllowedProperties, TAllowedPseudos, TRequiredProperties, TCompiledTypedPseudo>,
139164
TRequiredProperties['requiredPseudos']
140165
> &
141166
BlockedRules)
142167
| false
143168
| null
144169
| undefined;
145170

146-
type MarkAsRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
147-
148171
/**
149-
* ## cx
172+
* ## CX
150173
*
151174
* Use in conjunction with the {@link XCSSProp} to concatenate and conditionally apply
152175
* declared styles. Can only be used with the `cssMap()` and {@link XCSSProp} APIs.

‎packages/webpack-loader/src/__tests__/compiled-loader.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ describe.each<'development' | 'production'>(['development', 'production'])(
7979
await expect(bundle(join(fixturesPath, 'compiled-error.tsx'))).rejects.toEqual([
8080
expect.objectContaining({
8181
message: expect.stringContaining(
82-
"BooleanLiteral isn't a supported CSS type - try using an object or string"
82+
'This BooleanLiteral was unable to have its styles extracted — try to define them statically using Compiled APIs instead'
8383
),
8484
}),
8585
]);

0 commit comments

Comments
 (0)
Please sign in to comment.