From 9857009f59f2089ca8c026745f3070b0e7b02e4c Mon Sep 17 00:00:00 2001 From: Michael Dougall <6801309+itsdouges@users.noreply.github.com> Date: Mon, 4 Dec 2023 10:30:16 +1100 Subject: [PATCH] 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 --- .changeset/weak-numbers-turn.md | 49 +++ babel.config.json | 6 +- fixtures/strict-api-test/package.json | 9 + fixtures/strict-api-test/src/index.ts | 12 + .../__tests__/custom-import-source.test.ts | 88 +++++ .../babel-plugin/src/__tests__/errors.test.ts | 2 +- packages/babel-plugin/src/babel-plugin.ts | 47 ++- .../src/styled/__tests__/behaviour.test.ts | 8 +- packages/babel-plugin/src/types.ts | 10 + .../babel-plugin/src/utils/css-builders.ts | 9 +- packages/react/package.json | 1 + packages/react/src/class-names/index.ts | 2 +- .../__tests__/__fixtures__/strict-api.ts | 13 + .../__tests__/index.test.tsx | 312 ++++++++++++++++++ .../__tests__/package.test.tsx | 21 ++ packages/react/src/create-strict-api/index.ts | 223 +++++++++++++ packages/react/src/css-map/index.ts | 8 +- packages/react/src/css/index.ts | 2 +- packages/react/src/index.ts | 1 + packages/react/src/types.ts | 10 +- packages/react/src/xcss-prop/index.ts | 43 ++- .../src/__tests__/compiled-loader.test.ts | 2 +- 22 files changed, 847 insertions(+), 31 deletions(-) create mode 100644 .changeset/weak-numbers-turn.md create mode 100644 fixtures/strict-api-test/package.json create mode 100644 fixtures/strict-api-test/src/index.ts create mode 100644 packages/babel-plugin/src/__tests__/custom-import-source.test.ts create mode 100644 packages/react/src/create-strict-api/__tests__/__fixtures__/strict-api.ts create mode 100644 packages/react/src/create-strict-api/__tests__/index.test.tsx create mode 100644 packages/react/src/create-strict-api/__tests__/package.test.tsx create mode 100644 packages/react/src/create-strict-api/index.ts diff --git a/.changeset/weak-numbers-turn.md b/.changeset/weak-numbers-turn.md new file mode 100644 index 000000000..cc9057f37 --- /dev/null +++ b/.changeset/weak-numbers-turn.md @@ -0,0 +1,49 @@ +--- +'@compiled/babel-plugin': patch +'@compiled/react': patch +--- + +Introduce new API `createStrictAPI` which returns a strict subset of Compiled APIs augmented by a type definition. +This API does not change Compileds build time behavior — merely augmenting +the returned API types which enforce: + +- all APIs use object types +- property values declared in the type definition must be used (else fallback to defaults) +- a strict subset of pseudo states/selectors +- unknown properties to be a type violation + +To set up: + +1. Declare the API in a module (either local or in a package): + +```tsx +import { createStrictAPI } from '@compiled/react'; + +// ./foo.ts +const { css, cssMap, XCSSProp, cx } = createStrictAPI<{ + color: 'var(--ds-text)'; + '&:hover': { color: 'var(--ds-text-hover)' }; +}>(); + +// Expose APIs you want to support. +export { css, cssMap, XCSSProp, cx }; +``` + +2. Configure Compiled to pick up this module: + +```diff +// .compiledcssrc +{ ++ "importSources": ["./foo.ts"] +} +``` + +3. Use the module in your application code: + +```tsx +import { css } from './foo'; + +const styles = css({ color: 'var(--ds-text)' }); + +
; +``` diff --git a/babel.config.json b/babel.config.json index f64b932d7..a99760c6c 100644 --- a/babel.config.json +++ b/babel.config.json @@ -19,7 +19,11 @@ { "nonce": "\"k0Mp1lEd\"", "importReact": false, - "optimizeCss": false + "optimizeCss": false, + "importSources": [ + "./packages/react/src/create-strict-api/__tests__/__fixtures__/strict-api", + "@fixture/strict-api-test" + ] } ] ] diff --git a/fixtures/strict-api-test/package.json b/fixtures/strict-api-test/package.json new file mode 100644 index 000000000..5da3a8d4c --- /dev/null +++ b/fixtures/strict-api-test/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fixture/strict-api-test", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "dependencies": { + "@compiled/react": "*" + } +} diff --git a/fixtures/strict-api-test/src/index.ts b/fixtures/strict-api-test/src/index.ts new file mode 100644 index 000000000..0723c545b --- /dev/null +++ b/fixtures/strict-api-test/src/index.ts @@ -0,0 +1,12 @@ +import { createStrictAPI } from '@compiled/react'; + +const { css, XCSSProp, cssMap, cx } = createStrictAPI<{ + '&:hover': { + color: 'var(--ds-text-hover)'; + background: 'var(--ds-surface-hover)' | 'var(--ds-surface-sunken-hover)'; + }; + color: 'var(--ds-text)'; + background: 'var(--ds-surface)' | 'var(--ds-surface-sunken)'; +}>(); + +export { css, XCSSProp, cssMap, cx }; diff --git a/packages/babel-plugin/src/__tests__/custom-import-source.test.ts b/packages/babel-plugin/src/__tests__/custom-import-source.test.ts new file mode 100644 index 000000000..e1f54eadd --- /dev/null +++ b/packages/babel-plugin/src/__tests__/custom-import-source.test.ts @@ -0,0 +1,88 @@ +import { transform } from '../test-utils'; + +describe('custom import source', () => { + it('should pick up custom relative import source', () => { + const actual = transform( + ` + import { css } from '../bar/stub-api'; + + const styles = css({ color: 'red' }); + +
+ `, + { filename: './foo/index.js', importSources: ['./bar/stub-api'] } + ); + + expect(actual).toInclude('@compiled/react/runtime'); + }); + + it('should pick up custom absolute import source', () => { + const actual = transform( + ` + import { css } from '/bar/stub-api'; + + const styles = css({ color: 'red' }); + +
+ `, + { filename: './foo/index.js', importSources: ['/bar/stub-api'] } + ); + + expect(actual).toInclude('@compiled/react/runtime'); + }); + + it('should pick up custom package import source', () => { + const actual = transform( + ` + import { css } from '@af/compiled'; + + const styles = css({ color: 'red' }); + +
+ `, + { filename: './foo/index.js', importSources: ['@af/compiled'] } + ); + + expect(actual).toInclude('@compiled/react/runtime'); + }); + + it("should handle custom package sources that aren't found", () => { + expect(() => + transform( + ` + import { css } from '@af/compiled'; + + const styles = css({ color: 'red' }); + +
+ `, + { filename: './foo/index.js', importSources: ['asdasd2323'] } + ) + ).not.toThrow(); + }); + + it('should throw error explaining resolution steps when using custom import source that hasnt been configured', () => { + expect(() => + transform( + ` + /** @jsxImportSource @compiled/react */ + import { css } from '@private/misconfigured'; + + const styles = css({ color: 'red' }); + +
+ `, + { filename: '/foo/index.js', highlightCode: false } + ) + ).toThrowErrorMatchingInlineSnapshot(` + "/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). + 3 | import { css } from '@private/misconfigured'; + 4 | + > 5 | const styles = css({ color: 'red' }); + | ^^^^^^^^^^^^^^^^^^^^^ + 6 | + 7 |
+ 8 | " + `); + }); +}); diff --git a/packages/babel-plugin/src/__tests__/errors.test.ts b/packages/babel-plugin/src/__tests__/errors.test.ts index 687d3bd45..c22ca2802 100644 --- a/packages/babel-plugin/src/__tests__/errors.test.ts +++ b/packages/babel-plugin/src/__tests__/errors.test.ts @@ -15,7 +15,7 @@ describe('error handling', () => {
{}} /> `); }).toThrowErrorMatchingInlineSnapshot(` - "unknown file: ArrowFunctionExpression isn't a supported CSS type - try using an object or string (4: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). 2 | import '@compiled/react'; 3 | > 4 |
{}} /> diff --git a/packages/babel-plugin/src/babel-plugin.ts b/packages/babel-plugin/src/babel-plugin.ts index 59009704e..d9f17477c 100644 --- a/packages/babel-plugin/src/babel-plugin.ts +++ b/packages/babel-plugin/src/babel-plugin.ts @@ -1,4 +1,4 @@ -import { basename } from 'path'; +import { basename, resolve, join, dirname } from 'path'; import { declare } from '@babel/helper-plugin-utils'; import jsxSyntax from '@babel/plugin-syntax-jsx'; @@ -30,7 +30,7 @@ import { visitXcssPropPath } from './xcss-prop'; const packageJson = require('../package.json'); const JSX_SOURCE_ANNOTATION_REGEX = /\*?\s*@jsxImportSource\s+([^\s]+)/; const JSX_ANNOTATION_REGEX = /\*?\s*@jsx\s+([^\s]+)/; -const COMPILED_MODULE = '@compiled/react'; +const DEFAULT_IMPORT_SOURCE = '@compiled/react'; let globalCache: Cache | undefined; @@ -41,6 +41,8 @@ export default declare((api) => { name: packageJson.name, inherits: jsxSyntax, pre(state) { + const rootPath = state.opts.root ?? this.cwd; + this.sheets = {}; this.cssMap = {}; let cache: Cache; @@ -59,12 +61,25 @@ export default declare((api) => { this.pathsToCleanup = []; this.pragma = {}; this.usesXcss = false; + this.importSources = [ + DEFAULT_IMPORT_SOURCE, + ...(this.opts.importSources + ? this.opts.importSources.map((origin) => { + if (origin[0] === '.') { + // We've found a relative path, transform it to be fully qualified. + return join(rootPath, origin); + } + + return origin; + }) + : []), + ]; if (typeof this.opts.resolver === 'object') { this.resolver = this.opts.resolver; } else if (typeof this.opts.resolver === 'string') { this.resolver = require(require.resolve(this.opts.resolver, { - paths: [state.opts.root ?? this.cwd], + paths: [rootPath], })); } @@ -80,7 +95,9 @@ export default declare((api) => { const jsxSourceMatches = JSX_SOURCE_ANNOTATION_REGEX.exec(comment.value); const jsxMatches = JSX_ANNOTATION_REGEX.exec(comment.value); - if (jsxSourceMatches && jsxSourceMatches[1] === COMPILED_MODULE) { + // jsxPragmas currently only run on the top-level compiled module, + // hence we don't interrogate this.importSources. + if (jsxSourceMatches && jsxSourceMatches[1] === DEFAULT_IMPORT_SOURCE) { // jsxImportSource pragma found - turn on CSS prop! state.compiledImports = {}; state.pragma.jsxImportSource = true; @@ -159,7 +176,27 @@ export default declare((api) => { }, }, ImportDeclaration(path, state) { - if (path.node.source.value !== COMPILED_MODULE) { + const userLandModule = path.node.source.value; + + const isCompiledModule = this.importSources.some((compiledModuleOrigin) => { + if (userLandModule === DEFAULT_IMPORT_SOURCE || compiledModuleOrigin === userLandModule) { + return true; + } + + if ( + state.filename && + userLandModule[0] === '.' && + userLandModule.endsWith(basename(compiledModuleOrigin)) + ) { + // Relative import that might be a match, resolve the relative path and compare. + const fullpath = resolve(dirname(state.filename), userLandModule); + return fullpath === compiledModuleOrigin; + } + + return false; + }); + + if (!isCompiledModule) { return; } diff --git a/packages/babel-plugin/src/styled/__tests__/behaviour.test.ts b/packages/babel-plugin/src/styled/__tests__/behaviour.test.ts index fb8836c19..a70dfeadc 100644 --- a/packages/babel-plugin/src/styled/__tests__/behaviour.test.ts +++ b/packages/babel-plugin/src/styled/__tests__/behaviour.test.ts @@ -183,9 +183,9 @@ describe('styled component behaviour', () => { it('creates a separate var name for positive and negative values of the same interpolation', () => { const actual = transform(` - import { styled } from '@compiled/react'; + import { styled } from '@compiled/react'; const random = Math.random; - + const LayoutRight = styled.aside\` margin-right: -\${random() * 5}px; margin-left: \${random() * 5}px; @@ -749,7 +749,9 @@ describe('styled component behaviour', () => { \${props => props.isShown && (props.isPrimary ? { color: 'blue' } : { color: 'green' })}; \`; `) - ).toThrow("ConditionalExpression isn't a supported CSS type"); + ).toThrow( + 'This ConditionalExpression was unable to have its styles extracted — try to define them statically using Compiled APIs instead' + ); }); it('should apply conditional CSS when using "key: value" in string form', () => { diff --git a/packages/babel-plugin/src/types.ts b/packages/babel-plugin/src/types.ts index 2f0b2fade..5d9257b2d 100644 --- a/packages/babel-plugin/src/types.ts +++ b/packages/babel-plugin/src/types.ts @@ -32,6 +32,11 @@ export interface PluginOptions { */ nonce?: string; + /** + * Custom module origins that Compiled should compile when using APIs from. + */ + importSources?: string[]; + /** * Callback fired at the end of the file pass when files have been included in the transformation. */ @@ -115,6 +120,11 @@ export interface State extends PluginPass { css?: string; }; + /** + * Modules that expose APIs to be compiled by Compiled. + */ + importSources: string[]; + /** * Details of pragmas that are currently enabled in the pass. */ diff --git a/packages/babel-plugin/src/utils/css-builders.ts b/packages/babel-plugin/src/utils/css-builders.ts index 758f1850a..ad0bcc869 100644 --- a/packages/babel-plugin/src/utils/css-builders.ts +++ b/packages/babel-plugin/src/utils/css-builders.ts @@ -953,8 +953,15 @@ export const buildCss = (node: t.Expression | t.Expression[], meta: Metadata): C return buildCss(node.arguments[0] as t.ObjectExpression, meta); } + const areCompiledAPIsEnabled = + meta.state.compiledImports && Object.keys(meta.state.compiledImports).length > 0; + + const errorMessage = areCompiledAPIsEnabled + ? 'try to define them statically using Compiled APIs instead' + : "no Compiled APIs were found in scope, if you're using createStrictAPI make sure to configure importSources"; + throw buildCodeFrameError( - `${node.type} isn't a supported CSS type - try using an object or string`, + `This ${node.type} was unable to have its styles extracted — ${errorMessage}`, node, meta.parentPath ); diff --git a/packages/react/package.json b/packages/react/package.json index c72f8a6b5..f2243a5cc 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -76,6 +76,7 @@ }, "devDependencies": { "@compiled/benchmark": "^1.1.0", + "@fixture/strict-api-test": "*", "@testing-library/react": "^12.1.5", "@types/jsdom": "^16.2.15", "@types/react-dom": "^17.0.22", diff --git a/packages/react/src/class-names/index.ts b/packages/react/src/class-names/index.ts index c836ef21f..51782e3db 100644 --- a/packages/react/src/class-names/index.ts +++ b/packages/react/src/class-names/index.ts @@ -19,7 +19,7 @@ export interface ClassNamesProps { } /** - * ## Class names + * ## Class Names * * Use a component where styles are not necessarily used on a JSX element. * For further details [read the documentation](https://compiledcssinjs.com/docs/api-class-names). diff --git a/packages/react/src/create-strict-api/__tests__/__fixtures__/strict-api.ts b/packages/react/src/create-strict-api/__tests__/__fixtures__/strict-api.ts new file mode 100644 index 000000000..d1a5d8fae --- /dev/null +++ b/packages/react/src/create-strict-api/__tests__/__fixtures__/strict-api.ts @@ -0,0 +1,13 @@ +import { createStrictAPI } from '@compiled/react'; + +const { css, XCSSProp, cssMap, cx } = createStrictAPI<{ + '&:hover': { + color: 'var(--ds-text-hover)'; + background: 'var(--ds-surface-hover)' | 'var(--ds-surface-sunken-hover)'; + }; + color: 'var(--ds-text)'; + background: 'var(--ds-surface)' | 'var(--ds-surface-sunken)'; + bkgrnd: 'red' | 'green'; +}>(); + +export { css, XCSSProp, cssMap, cx }; diff --git a/packages/react/src/create-strict-api/__tests__/index.test.tsx b/packages/react/src/create-strict-api/__tests__/index.test.tsx new file mode 100644 index 000000000..1a755eb8c --- /dev/null +++ b/packages/react/src/create-strict-api/__tests__/index.test.tsx @@ -0,0 +1,312 @@ +/** @jsxImportSource @compiled/react */ +import { render } from '@testing-library/react'; + +import { css, cssMap, XCSSProp } from './__fixtures__/strict-api'; + +describe('createStrictAPI()', () => { + describe('css()', () => { + it('should type error when circumventing the excess property check', () => { + const styles = css({ + color: 'var(--ds-text)', + accentColor: 'red', + // @ts-expect-error — Type 'string' is not assignable to type 'undefined'.ts(2322) + bkgrnd: 'red', + '&:hover': { + color: 'var(--ds-text-hover)', + // @ts-expect-error — Type 'string' is not assignable to type 'undefined'.ts(2322) + bkgrnd: 'red', + }, + }); + + const { getByTestId } = render(
); + + expect(getByTestId('div')).toHaveCompiledCss('color', 'var(--ds-text)'); + }); + + it('should constrain declared types for css() func', () => { + // @ts-expect-error — Type '"red"' is not assignable to type '"var(--ds-surface)" | "var(--ds-surface-sunken" | undefined'.ts(2322) + const styles = css({ background: 'red' }); + + const { getByTestId } = render(
); + + expect(getByTestId('div')).toHaveCompiledCss('background-color', 'red'); + }); + + it('should mark all properties as optional', () => { + const styles1 = css({}); + const styles2 = css({ '&:hover': {} }); + + const { getByTestId } = render(
); + + expect(getByTestId('div')).not.toHaveCompiledCss('color', 'red'); + }); + + it('should constrain pseudos', () => { + const styles = css({ + // @ts-expect-error — Type '"red"' is not assignable to type '"var(--ds-surface)" | "var(--ds-surface-sunken" | undefined'.ts(2322) + background: 'red', + '&:hover': { + // @ts-expect-error — Type '"red"' is not assignable to type '"var(--ds-surface)" | "var(--ds-surface-sunken" | undefined'.ts(2322) + background: 'red', + }, + }); + + const { getByTestId } = render(
); + + expect(getByTestId('div')).toHaveCompiledCss('background-color', 'red', { target: ':hover' }); + }); + + it('should allow valid properties inside pseudos that are different to root', () => { + const styles = css({ + background: 'var(--ds-surface)', + '&:hover': { + accentColor: 'red', + background: 'var(--ds-surface-hover)', + }, + }); + + const { getByTestId } = render(
); + + expect(getByTestId('div')).toHaveCompiledCss('background', 'var(--ds-surface-hover)', { + target: ':hover', + }); + }); + + it('should allow valid properties', () => { + const styles = css({ + background: 'var(--ds-surface)', + accentColor: 'red', + color: 'var(--ds-text)', + all: 'inherit', + '&:hover': { color: 'var(--ds-text-hover)' }, + '&:invalid': { color: 'orange' }, + }); + + const { getByTestId } = render(
); + + expect(getByTestId('div')).toHaveCompiledCss('all', 'inherit'); + }); + }); + + describe('cssMap()', () => { + it('should allow valid properties', () => { + const styles = cssMap({ + primary: { + background: 'var(--ds-surface)', + accentColor: 'red', + all: 'inherit', + '&:hover': { color: 'var(--ds-text-hover)' }, + '&:invalid': { color: 'orange' }, + }, + }); + + const { getByTestId } = render(
); + + expect(getByTestId('div')).toHaveCompiledCss('background', 'var(--ds-surface)'); + }); + + it('should allow valid properties inside pseudos that are different to root', () => { + const styles = cssMap({ + primary: { + background: 'var(--ds-surface)', + '&:hover': { + accentColor: 'red', + background: 'var(--ds-surface-hover)', + }, + }, + }); + + const { getByTestId } = render(
); + + expect(getByTestId('div')).toHaveCompiledCss('background', 'var(--ds-surface-hover)', { + target: ':hover', + }); + }); + + it('should type error invalid vales', () => { + const styles = cssMap({ + primary: { + // @ts-expect-error — Type '{ val: string; }' is not assignable to type 'Readonly> & PseudosDeclarations & EnforceSchema<{ background: "var(--ds-surface)" | "var(--ds-surface-sunken"; }>'. + val: 'ok', + }, + }); + + const { getByTestId } = render(
); + + expect(getByTestId('div')).toHaveCompiledCss('val', 'ok'); + }); + + it('should type error invalid values in pseudos', () => { + const styles = cssMap({ + primary: { + // @ts-expect-error — Type '"red"' is not assignable to type '"var(--ds-surface)" | "var(--ds-surface-sunken" | undefined'.ts(2322) + background: 'red', + '&:hover': { + // @ts-expect-error — Type 'string' is not assignable to type 'never'.ts(2322) + val: 'ok', + }, + }, + }); + + const { getByTestId } = render(
); + + expect(getByTestId('div')).toHaveCompiledCss('val', 'ok', { target: ':hover' }); + }); + }); + + describe('XCSSProp', () => { + it('should allow valid values', () => { + function Button({ xcss }: { xcss: ReturnType> }) { + return