diff options
author | Sebastian <sebasjm@gmail.com> | 2021-08-23 16:46:06 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2021-08-23 16:48:30 -0300 |
commit | 38acabfa6089ab8ac469c12b5f55022fb96935e5 (patch) | |
tree | 453dbf70000cc5e338b06201af1eaca8343f8f73 /@linaria/packages/babel | |
parent | f26125e039143b92dc0d84e7775f508ab0cdcaa8 (diff) | |
download | node-vendor-master.tar.gz node-vendor-master.tar.bz2 node-vendor-master.zip |
Diffstat (limited to '@linaria/packages/babel')
65 files changed, 7045 insertions, 0 deletions
diff --git a/@linaria/packages/babel/CHANGELOG.md b/@linaria/packages/babel/CHANGELOG.md new file mode 100644 index 0000000..fc18483 --- /dev/null +++ b/@linaria/packages/babel/CHANGELOG.md @@ -0,0 +1,55 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [3.0.0-beta.7](https://github.com/callstack/linaria/compare/v3.0.0-beta.6...v3.0.0-beta.7) (2021-06-24) + + +### Bug Fixes + +* **shaker:** fix undefined imports in some cases ([#333](https://github.com/callstack/linaria/issues/333), [#761](https://github.com/callstack/linaria/issues/761)) ([#787](https://github.com/callstack/linaria/issues/787)) ([e374072](https://github.com/callstack/linaria/commit/e3740727447b2867a2cfe40f763bc88e72eb2503)) + + + + + +# [3.0.0-beta.5](https://github.com/callstack/linaria/compare/v3.0.0-beta.4...v3.0.0-beta.5) (2021-05-31) + + +### Bug Fixes + +* **shaker:** typescript enums support ([#761](https://github.com/callstack/linaria/issues/761)) ([#764](https://github.com/callstack/linaria/issues/764)) ([6907e22](https://github.com/callstack/linaria/commit/6907e2280a2ab8ee014b5d02b1169714ccac9d66)) + + + + + +# [3.0.0-beta.4](https://github.com/callstack/linaria/compare/v3.0.0-beta.3...v3.0.0-beta.4) (2021-05-07) + +**Note:** Version bump only for package @linaria/babel-preset + + + + + +# [3.0.0-beta.3](https://github.com/callstack/linaria/compare/v3.0.0-beta.2...v3.0.0-beta.3) (2021-04-20) + +**Note:** Version bump only for package @linaria/babel-preset + + + + + +# [3.0.0-beta.2](https://github.com/callstack/linaria/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2021-04-11) + + +### Bug Fixes + +* Import custom identifier called css with error "Cannot find module 'linaria'" [#739](https://github.com/callstack/linaria/issues/739) ([#740](https://github.com/callstack/linaria/issues/740)) ([07fb381](https://github.com/callstack/linaria/commit/07fb38131c9dec406dcca72f45638561c815e824)) +* loadOptions text regex ([#728](https://github.com/callstack/linaria/issues/728)) ([34ca3e5](https://github.com/callstack/linaria/commit/34ca3e5f211b65c14c2bf4efabb7065f7109da23)) + + +### Features + +* **babel:** expose CSS extraction from AST logic ([#737](https://github.com/callstack/linaria/issues/737)) ([f049a11](https://github.com/callstack/linaria/commit/f049a119ef70346340676ab6a397ad6358e5f39b)) diff --git a/@linaria/packages/babel/README.md b/@linaria/packages/babel/README.md new file mode 100644 index 0000000..0d75b37 --- /dev/null +++ b/@linaria/packages/babel/README.md @@ -0,0 +1,35 @@ +<p align="center"> + <img alt="Linaria" src="https://raw.githubusercontent.com/callstack/linaria/HEAD/website/assets/linaria-logo@2x.png" width="496"> +</p> + +<p align="center"> +Zero-runtime CSS in JS library. +</p> + +--- + +### 📖 Please refer to the [GitHub](https://github.com/callstack/linaria#readme) for full documentation. + +## Features + +- Write CSS in JS, but with **zero runtime**, CSS is extracted to CSS files during build +- Familiar **CSS syntax** with Sass like nesting +- Use **dynamic prop based styles** with the React bindings, uses CSS variables behind the scenes +- Easily find where the style was defined with **CSS sourcemaps** +- **Lint your CSS** in JS with [stylelint](https://github.com/stylelint/stylelint) +- Use **JavaScript for logic**, no CSS preprocessor needed +- Optionally use any **CSS preprocessor** such as Sass or PostCSS + +**[Why use Linaria](../../docs/BENEFITS.md)** + +## Installation + +```sh +npm install @linaria/core @linaria/react @linaria/babel-preset @linaria/shaker +``` + +or + +```sh +yarn add @linaria/core @linaria/react @linaria/babel-preset @linaria/shaker +``` diff --git a/@linaria/packages/babel/__fixtures__/complex-component.js b/@linaria/packages/babel/__fixtures__/complex-component.js new file mode 100644 index 0000000..0c9ae15 --- /dev/null +++ b/@linaria/packages/babel/__fixtures__/complex-component.js @@ -0,0 +1,21 @@ +// Dead code in this file should be ignored + +import deadDep from 'unknown-dependency'; +import { styled } from '@linaria/react'; + +export const deadValue = deadDep(); + +const objects = { font: { fontSize: 12 }, box: { border: '1px solid red' } }; +const foo = (k) => { + const { [k]: obj } = objects; + return obj; +}; + +objects.font.fontWeight = 'bold'; + +export const whiteColor = '#fff'; + +export const Title = styled.h1` + ${foo('font')} + ${foo('box')} +`; diff --git a/@linaria/packages/babel/__fixtures__/components-library.js b/@linaria/packages/babel/__fixtures__/components-library.js new file mode 100644 index 0000000..487648e --- /dev/null +++ b/@linaria/packages/babel/__fixtures__/components-library.js @@ -0,0 +1,16 @@ +import { styled } from '@linaria/react'; + +export const T1 = styled.h1` + background: #111; +`; +export const T2 = styled.h2` + background: #222; +`; +export const T3 = styled.h3` + ${T2} { + background: #333; + } +`; +export default styled.p` + background: #333; +`; diff --git a/@linaria/packages/babel/__fixtures__/enums.ts b/@linaria/packages/babel/__fixtures__/enums.ts new file mode 100644 index 0000000..aa87847 --- /dev/null +++ b/@linaria/packages/babel/__fixtures__/enums.ts @@ -0,0 +1,3 @@ +export enum Colors { + BLUE = '#27509A', +} diff --git a/@linaria/packages/babel/__fixtures__/escape-character.js b/@linaria/packages/babel/__fixtures__/escape-character.js new file mode 100644 index 0000000..c7f7deb --- /dev/null +++ b/@linaria/packages/babel/__fixtures__/escape-character.js @@ -0,0 +1,7 @@ +import { styled } from '@linaria/react'; + +const selectors = ['a', 'b']; + +export const Block = styled.div` + ${selectors.map((c) => String.raw`${c} { content: "\u000A"; }`).join('\n')}; +`; diff --git a/@linaria/packages/babel/__fixtures__/sample-asset.png b/@linaria/packages/babel/__fixtures__/sample-asset.png new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/@linaria/packages/babel/__fixtures__/sample-asset.png diff --git a/@linaria/packages/babel/__fixtures__/sample-data.json b/@linaria/packages/babel/__fixtures__/sample-data.json new file mode 100644 index 0000000..c623402 --- /dev/null +++ b/@linaria/packages/babel/__fixtures__/sample-data.json @@ -0,0 +1,10 @@ +{ + "name": "Luke Skywalker", + "height": "172", + "mass": "77", + "hair_color": "blond", + "skin_color": "fair", + "eye_color": "blue", + "birth_year": "19BBY", + "gender": "male" +} diff --git a/@linaria/packages/babel/__fixtures__/sample-script.js b/@linaria/packages/babel/__fixtures__/sample-script.js new file mode 100644 index 0000000..888cae3 --- /dev/null +++ b/@linaria/packages/babel/__fixtures__/sample-script.js @@ -0,0 +1 @@ +module.exports = 42; diff --git a/@linaria/packages/babel/__fixtures__/sample-typescript.tsx b/@linaria/packages/babel/__fixtures__/sample-typescript.tsx new file mode 100644 index 0000000..d33d509 --- /dev/null +++ b/@linaria/packages/babel/__fixtures__/sample-typescript.tsx @@ -0,0 +1 @@ +export default 27; diff --git a/@linaria/packages/babel/__fixtures__/slugify.ts b/@linaria/packages/babel/__fixtures__/slugify.ts new file mode 100644 index 0000000..bc69242 --- /dev/null +++ b/@linaria/packages/babel/__fixtures__/slugify.ts @@ -0,0 +1,3 @@ +import slugify from '../src/utils/slugify'; + +export default slugify; diff --git a/@linaria/packages/babel/__fixtures__/ts-data.ts b/@linaria/packages/babel/__fixtures__/ts-data.ts new file mode 100644 index 0000000..222fe53 --- /dev/null +++ b/@linaria/packages/babel/__fixtures__/ts-data.ts @@ -0,0 +1,4 @@ +export enum TestEnum { + FirstValue, + SecondValue, +} diff --git a/@linaria/packages/babel/__tests__/__snapshots__/babel.test.ts.snap b/@linaria/packages/babel/__tests__/__snapshots__/babel.test.ts.snap new file mode 100644 index 0000000..84be71a --- /dev/null +++ b/@linaria/packages/babel/__tests__/__snapshots__/babel.test.ts.snap @@ -0,0 +1,617 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`does not include styles if not referenced anywhere 1`] = ` +"import { css } from '@linaria/core'; +import { styled } from '@linaria/react'; +const Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"Title\\", + class: \\"t17gu1mi\\" +}); +const title = \\"tccybe9\\";" +`; + +exports[`does not include styles if not referenced anywhere 2`] = `Object {}`; + +exports[`does not output CSS if none present 1`] = ` +"const number = 42; +const title = String.raw\`This is something\`;" +`; + +exports[`does not output CSS if none present 2`] = `Object {}`; + +exports[`does not output CSS property when value is a blank string 1`] = ` +"import { css } from '@linaria/core'; +export const title = \\"t17gu1mi\\";" +`; + +exports[`does not output CSS property when value is a blank string 2`] = ` + +CSS: + +.t17gu1mi { + font-size: ; + margin: 6px; +} + +Dependencies: NA + +`; + +exports[`evaluates and inlines expressions in scope 1`] = ` +"import { styled } from '@linaria/react'; +const color = 'blue'; +export const Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"Title\\", + class: \\"t17gu1mi\\" +});" +`; + +exports[`evaluates and inlines expressions in scope 2`] = ` + +CSS: + +.t17gu1mi { + color: blue; + width: 33.333333333333336%; +} + +Dependencies: NA + +`; + +exports[`handles css template literal in JSX element 1`] = ` +"import { css } from '@linaria/core'; +<Title class={\\"t17gu1mi\\"} />;" +`; + +exports[`handles css template literal in JSX element 2`] = ` + +CSS: + +.t17gu1mi { font-size: 14px; } + +Dependencies: NA + +`; + +exports[`handles css template literal in object property 1`] = ` +"import { css } from '@linaria/core'; +const components = { + title: \\"t17gu1mi\\" +};" +`; + +exports[`handles css template literal in object property 2`] = ` + +CSS: + +.t17gu1mi { + font-size: 14px; + } + +Dependencies: NA + +`; + +exports[`handles fn passed in as classNameSlug 1`] = ` +"import { styled } from '@linaria/react'; +export const Title = /*#__PURE__*/styled('h1')({ + name: \\"Title\\", + class: \\"t17gu1mi_42_Title\\" +});" +`; + +exports[`handles fn passed in as classNameSlug 2`] = ` + +CSS: + +.t17gu1mi_42_Title { + font-size: 14px; +} + +Dependencies: NA + +`; + +exports[`handles interpolation followed by unit 1`] = ` +"import { styled } from '@linaria/react'; +export const Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"Title\\", + class: \\"t17gu1mi\\", + vars: { + \\"t17gu1mi-0\\": [size, \\"em\\"], + \\"t17gu1mi-1\\": [shadow, \\"px\\"], + \\"t17gu1mi-2\\": [size, \\"px\\"], + \\"t17gu1mi-3\\": [props => props.width, \\"vw\\"], + \\"t17gu1mi-4\\": [props => { + if (true) { + return props.height; + } else { + return 200; + } + }, \\"px\\"], + \\"t17gu1mi-5\\": [unit, \\"fr\\"], + \\"t17gu1mi-7\\": [function (props) { + return 200; + }, \\"px\\"] + } +});" +`; + +exports[`handles interpolation followed by unit 2`] = ` + +CSS: + +.t17gu1mi { + font-size: var(--t17gu1mi-0); + text-shadow: black 1px var(--t17gu1mi-1), white -2px -2px; + margin: var(--t17gu1mi-2); + width: calc(2 * var(--t17gu1mi-3)); + height: var(--t17gu1mi-4); + grid-template-columns: var(--t17gu1mi-5) 1fr 1fr var(--t17gu1mi-5); + border-radius: var(--t17gu1mi-7) +} + +Dependencies: NA + +`; + +exports[`handles nested blocks 1`] = ` +"import { styled } from '@linaria/react'; +export const Button = /*#__PURE__*/styled(\\"button\\")({ + name: \\"Button\\", + class: \\"b17gu1mi\\", + vars: { + \\"b17gu1mi-0\\": [regular] + } +});" +`; + +exports[`handles nested blocks 2`] = ` + +CSS: + +.b17gu1mi { + font-family: var(--b17gu1mi-0); + + &:hover { + border-color: blue; + } + + @media (max-width: 200px) { + width: 100%; + } +} + +Dependencies: NA + +`; + +exports[`handles objects with enums as keys 1`] = ` +"import { css } from '@linaria/core'; +import { TestEnum } from './ts-data.ts'; +export const object = { + [TestEnum.FirstValue]: \\"t17gu1mi\\", + [TestEnum.SecondValue]: \\"tccybe9\\" +};" +`; + +exports[`handles objects with enums as keys 2`] = ` + +CSS: + +.t17gu1mi {} +.tccybe9 {} + +Dependencies: NA + +`; + +exports[`handles objects with numeric keys 1`] = ` +"import { css } from '@linaria/core'; +export const object = { + stringKey: \\"s17gu1mi\\", + 42: \\"_ccybe9\\" +};" +`; + +exports[`handles objects with numeric keys 2`] = ` + +CSS: + +.s17gu1mi {} +._ccybe9 {} + +Dependencies: NA + +`; + +exports[`includes unreferenced styles for :global 1`] = ` +"import { css } from '@linaria/core'; +import { styled } from '@linaria/react'; +const a = \\"a17gu1mi\\"; +const B = /*#__PURE__*/styled(\\"div\\")({ + name: \\"B\\", + class: \\"bccybe9\\" +});" +`; + +exports[`includes unreferenced styles for :global 2`] = ` + +CSS: + +.a17gu1mi { + :global() { + .title { + font-size: 14px; + } + } +} +.bccybe9 { + :global(.title) { + font-size: 14px; + } +} + +Dependencies: NA + +`; + +exports[`inlines array styles as CSS string 1`] = ` +"import { styled } from '@linaria/react'; +const styles = [{ + flex: 1 +}, { + display: 'block', + height: 24 +}]; +export const Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"Title\\", + class: \\"t17gu1mi\\" +});" +`; + +exports[`inlines array styles as CSS string 2`] = ` + +CSS: + +.t17gu1mi { + flex: 1; display: block; height: 24px; +} + +Dependencies: NA + +`; + +exports[`inlines object styles as CSS string 1`] = ` +"import { styled } from '@linaria/react'; +const cover = { + '--color-primaryText': '#222', + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + opacity: 1, + minHeight: 420, + '&.shouldNotBeChanged': { + borderColor: '#fff' + }, + '@media (min-width: 200px)': { + WebkitOpacity: .8, + MozOpacity: .8, + msOpacity: .8, + OOpacity: .8, + WebkitBorderRadius: 2, + MozBorderRadius: 2, + msBorderRadius: 2, + OBorderRadius: 2, + WebkitTransition: '400ms', + MozTransition: '400ms', + OTransition: '400ms', + msTransition: '400ms' + } +}; +export const Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"Title\\", + class: \\"t17gu1mi\\" +});" +`; + +exports[`inlines object styles as CSS string 2`] = ` + +CSS: + +.t17gu1mi { + --color-primaryText: #222; position: absolute; top: 0; right: 0; bottom: 0; left: 0; opacity: 1; min-height: 420px; &.shouldNotBeChanged { border-color: #fff; } @media (min-width: 200px) { -webkit-opacity: 0.8; -moz-opacity: 0.8; -ms-opacity: 0.8; -o-opacity: 0.8; -webkit-border-radius: 2px; -moz-border-radius: 2px; -ms-border-radius: 2px; -o-border-radius: 2px; -webkit-transition: 400ms; -moz-transition: 400ms; -o-transition: 400ms; -ms-transition: 400ms; } +} + +Dependencies: NA + +`; + +exports[`outputs valid CSS classname 1`] = ` +"import { styled } from '@linaria/react'; +export const ΩPage$Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"\\\\u03A9Page$Title\\", + class: \\"\\\\u03C917gu1mi\\" +});" +`; + +exports[`outputs valid CSS classname 2`] = ` + +CSS: + +.ω17gu1mi { + font-size: 14px; +} + +Dependencies: NA + +`; + +exports[`prevents class name collision 1`] = ` +"import { styled } from '@linaria/react'; +export const Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"Title\\", + class: \\"t17gu1mi\\", + vars: { + \\"t17gu1mi-0\\": [size, \\"px\\"], + \\"t17gu1mi-1\\": [props => props.color] + } +}); + +function Something() { + const Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"Title\\", + class: \\"tccybe9\\", + vars: { + \\"tccybe9-0\\": [regular] + } + }); + return <Title />; +}" +`; + +exports[`prevents class name collision 2`] = ` + +CSS: + +.t17gu1mi { + font-size: var(--t17gu1mi-0); + color: var(--t17gu1mi-1) +} +.tccybe9 { + font-family: var(--tccybe9-0); + } + +Dependencies: NA + +`; + +exports[`replaces unknown expressions with CSS custom properties 1`] = ` +"import { styled } from '@linaria/react'; +export const Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"Title\\", + class: \\"t17gu1mi\\", + vars: { + \\"t17gu1mi-0\\": [size, \\"px\\"], + \\"t17gu1mi-1\\": [props => props.color] + } +});" +`; + +exports[`replaces unknown expressions with CSS custom properties 2`] = ` + +CSS: + +.t17gu1mi { + font-size: var(--t17gu1mi-0); + color: var(--t17gu1mi-1); +} + +Dependencies: NA + +`; + +exports[`supports both css and styled tags 1`] = ` +"import { css } from '@linaria/core'; +import { styled } from '@linaria/react'; +export const Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"Title\\", + class: \\"t17gu1mi\\" +}); +export const title = \\"tccybe9\\";" +`; + +exports[`supports both css and styled tags 2`] = ` + +CSS: + +.t17gu1mi { + font-size: 14px; +} +.tccybe9 { + color: blue; +} + +Dependencies: NA + +`; + +exports[`throws when contains dynamic expression without evaluate: true in css tag 1`] = ` +"<<DIRNAME>>/app/index.js: The CSS cannot contain JavaScript expressions when using the 'css' tag. To evaluate the expressions at build time, pass 'evaluate: true' to the babel plugin. + 2 | + 3 | const title = css\` +> 4 | font-size: \${size}px; + | ^^^^ + 5 | \`;" +`; + +exports[`transpiles css template literal 1`] = ` +"import { css } from '@linaria/core'; +export const title = \\"t17gu1mi\\";" +`; + +exports[`transpiles css template literal 2`] = ` + +CSS: + +.t17gu1mi { + font-size: 14px; +} + +Dependencies: NA + +`; + +exports[`transpiles renamed styled import 1`] = ` +"import { styled as custom } from '@linaria/react'; +export const Title = /*#__PURE__*/custom('h1')({ + name: \\"Title\\", + class: \\"t17gu1mi\\" +});" +`; + +exports[`transpiles renamed styled import 2`] = ` + +CSS: + +.t17gu1mi { + font-size: 14px; +} + +Dependencies: NA + +`; + +exports[`transpiles styled template literal with function and component 1`] = ` +"import { styled } from '@linaria/react'; + +const Heading = () => null; + +export const Title = /*#__PURE__*/styled(Heading)({ + name: \\"Title\\", + class: \\"t17gu1mi\\" +});" +`; + +exports[`transpiles styled template literal with function and component 2`] = ` + +CSS: + +.t17gu1mi { + font-size: 14px; +} + +Dependencies: NA + +`; + +exports[`transpiles styled template literal with function and tag 1`] = ` +"import { styled } from '@linaria/react'; +export const Title = /*#__PURE__*/styled('h1')({ + name: \\"Title\\", + class: \\"t17gu1mi\\" +});" +`; + +exports[`transpiles styled template literal with function and tag 2`] = ` + +CSS: + +.t17gu1mi { + font-size: 14px; +} + +Dependencies: NA + +`; + +exports[`transpiles styled template literal with object 1`] = ` +"import { styled } from '@linaria/react'; +export const Title = /*#__PURE__*/styled(\\"h1\\")({ + name: \\"Title\\", + class: \\"t17gu1mi\\" +});" +`; + +exports[`transpiles styled template literal with object 2`] = ` + +CSS: + +.t17gu1mi { + font-size: 14px; +} + +Dependencies: NA + +`; + +exports[`uses string passed in as classNameSlug 1`] = ` +"import { styled } from '@linaria/react'; +export const Title = /*#__PURE__*/styled('h1')({ + name: \\"Title\\", + class: \\"testSlug\\" +});" +`; + +exports[`uses string passed in as classNameSlug 2`] = ` + +CSS: + +.testSlug { + font-size: 14px; +} + +Dependencies: NA + +`; + +exports[`uses the same custom property for the same expression 1`] = ` +"import { styled } from '@linaria/react'; +export const Box = /*#__PURE__*/styled(\\"div\\")({ + name: \\"Box\\", + class: \\"b17gu1mi\\", + vars: { + \\"b17gu1mi-0\\": [props => props.size, \\"px\\"] + } +});" +`; + +exports[`uses the same custom property for the same expression 2`] = ` + +CSS: + +.b17gu1mi { + height: var(--b17gu1mi-0); + width: var(--b17gu1mi-0); +} + +Dependencies: NA + +`; + +exports[`uses the same custom property for the same identifier 1`] = ` +"import { styled } from '@linaria/react'; +export const Box = /*#__PURE__*/styled(\\"div\\")({ + name: \\"Box\\", + class: \\"b17gu1mi\\", + vars: { + \\"b17gu1mi-0\\": [size, \\"px\\"] + } +});" +`; + +exports[`uses the same custom property for the same identifier 2`] = ` + +CSS: + +.b17gu1mi { + height: var(--b17gu1mi-0); + width: var(--b17gu1mi-0); +} + +Dependencies: NA + +`; diff --git a/@linaria/packages/babel/__tests__/__snapshots__/dynamic-import-noop.test.js.snap b/@linaria/packages/babel/__tests__/__snapshots__/dynamic-import-noop.test.js.snap new file mode 100644 index 0000000..02d59d1 --- /dev/null +++ b/@linaria/packages/babel/__tests__/__snapshots__/dynamic-import-noop.test.js.snap @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`replaces dynamic imports with a noop 1`] = ` +"({ + then: () => undefined, + catch: () => undefined +}).then(foo => foo.init());" +`; diff --git a/@linaria/packages/babel/__tests__/__snapshots__/transform.test.ts.snap b/@linaria/packages/babel/__tests__/__snapshots__/transform.test.ts.snap new file mode 100644 index 0000000..77636ca --- /dev/null +++ b/@linaria/packages/babel/__tests__/__snapshots__/transform.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`doesn't rewrite an absolute path in url() declarations 1`] = ` +".tpyglzj{background-image:url(/assets/test.jpg);} +" +`; + +exports[`rewrites a relative path in url() declarations 1`] = ` +".tpyglzj{background-image:url(../assets/test.jpg);background-image:url(\\"../assets/test.jpg\\");background-image:url('../assets/test.jpg');} +" +`; + +exports[`rewrites multiple relative paths in url() declarations 1`] = ` +"@font-face{font-family:Test;src:url(../assets/font.woff2) format(\\"woff2\\"),url(../assets/font.woff) format(\\"woff\\");} +" +`; diff --git a/@linaria/packages/babel/__tests__/babel.test.ts b/@linaria/packages/babel/__tests__/babel.test.ts new file mode 100644 index 0000000..99cc816 --- /dev/null +++ b/@linaria/packages/babel/__tests__/babel.test.ts @@ -0,0 +1,524 @@ +import { join } from 'path'; +import { transformAsync } from '@babel/core'; +import dedent from 'dedent'; +import stripAnsi from 'strip-ansi'; +import type { StrictOptions } from '../src'; +import serializer from '../__utils__/linaria-snapshot-serializer'; + +expect.addSnapshotSerializer(serializer); + +const transpile = async ( + input: string, + opts: Partial<StrictOptions> = { evaluate: false } +) => + (await transformAsync(input, { + babelrc: false, + presets: [[require.resolve('../src'), opts]], + plugins: ['@babel/plugin-syntax-jsx'], + filename: join(__dirname, 'app/index.js'), + configFile: false, + }))!; + +it('transpiles styled template literal with object', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Title = styled.h1\` + font-size: 14px; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('uses string passed in as classNameSlug', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Title = styled('h1')\` + font-size: 14px; + \`; +`, + { classNameSlug: 'testSlug' } + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('handles fn passed in as classNameSlug', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Title = styled('h1')\` + font-size: 14px; + \`; +`, + { + classNameSlug: (hash, title) => { + return `${hash}_${7 * 6}_${title}`; + }, + } + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('transpiles styled template literal with function and tag', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Title = styled('h1')\` + font-size: 14px; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('transpiles renamed styled import', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled as custom } from '@linaria/react'; + + export const Title = custom('h1')\` + font-size: 14px; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('transpiles styled template literal with function and component', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + const Heading = () => null; + + export const Title = styled(Heading)\` + font-size: 14px; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('outputs valid CSS classname', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const ΩPage$Title = styled.h1\` + font-size: 14px; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('evaluates and inlines expressions in scope', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const color = 'blue'; + + export const Title = styled.h1\` + color: ${'${color}'}; + width: ${'${100 / 3}'}%; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('inlines object styles as CSS string', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const cover = { + '--color-primaryText': '#222', + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + opacity: 1, + minHeight: 420, + + '&.shouldNotBeChanged': { + borderColor: '#fff', + }, + + '@media (min-width: 200px)': { + WebkitOpacity: .8, + MozOpacity: .8, + msOpacity: .8, + OOpacity: .8, + WebkitBorderRadius: 2, + MozBorderRadius: 2, + msBorderRadius: 2, + OBorderRadius: 2, + WebkitTransition: '400ms', + MozTransition: '400ms', + OTransition: '400ms', + msTransition: '400ms', + } + }; + + export const Title = styled.h1\` + ${'${cover}'} + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('inlines array styles as CSS string', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const styles = [ + { flex: 1 }, + { display: 'block', height: 24 }, + ]; + + export const Title = styled.h1\` + ${'${styles}'} + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('replaces unknown expressions with CSS custom properties', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Title = styled.h1\` + font-size: ${'${size}'}px; + color: ${'${props => props.color}'}; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('handles interpolation followed by unit', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Title = styled.h1\` + font-size: ${'${size}'}em; + text-shadow: black 1px ${'${shadow}'}px, white -2px -2px; + margin: ${'${size}'}px; + width: calc(2 * ${'${props => props.width}'}vw); + height: ${'${props => { if (true) { return props.height } else { return 200 } }}'}px; + grid-template-columns: ${'${unit}'}fr 1fr 1fr ${'${unit}'}fr; + border-radius: ${'${function(props) { return 200 }}'}px + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('uses the same custom property for the same identifier', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Box = styled.div\` + height: ${'${size}'}px; + width: ${'${size}'}px; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('uses the same custom property for the same expression', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Box = styled.div\` + height: ${'${props => props.size}'}px; + width: ${'${props => props.size}'}px; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('handles nested blocks', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Button = styled.button\` + font-family: ${'${regular}'}; + + &:hover { + border-color: blue; + } + + @media (max-width: 200px) { + width: 100%; + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('prevents class name collision', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Title = styled.h1\` + font-size: ${'${size}'}px; + color: ${'${props => props.color}'} + \`; + + function Something() { + const Title = styled.h1\` + font-family: ${'${regular}'}; + \`; + + return <Title />; + } + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('does not output CSS if none present', async () => { + const { code, metadata } = await transpile( + dedent` + const number = 42; + + const title = String.raw\`This is something\`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('does not output CSS property when value is a blank string', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + + export const title = css\` + font-size: ${''}; + margin: 6px; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('transpiles css template literal', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + + export const title = css\` + font-size: 14px; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('handles css template literal in object property', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + + const components = { + title: css\` + font-size: 14px; + \` + }; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('handles css template literal in JSX element', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + + <Title class={css\` font-size: 14px; \`} /> + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('throws when contains dynamic expression without evaluate: true in css tag', async () => { + expect.assertions(1); + + try { + await transpile( + dedent` + import { css } from '@linaria/core'; + + const title = css\` + font-size: ${'${size}'}px; + \`; + ` + ); + } catch (e) { + expect( + stripAnsi(e.message.replace(__dirname, '<<DIRNAME>>')) + ).toMatchSnapshot(); + } +}); + +it('supports both css and styled tags', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + import { styled } from '@linaria/react'; + + export const Title = styled.h1\` + font-size: 14px; + \`; + + export const title = css\` + color: blue; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('does not include styles if not referenced anywhere', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + import { styled } from '@linaria/react'; + + const Title = styled.h1\` + font-size: 14px; + \`; + + const title = css\` + color: blue; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('includes unreferenced styles for :global', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + import { styled } from '@linaria/react'; + + const a = css\` + :global() { + .title { + font-size: 14px; + } + } + \`; + + const B = styled.div\` + :global(.title) { + font-size: 14px; + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('handles objects with numeric keys', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + + export const object = { + stringKey: css\`\`, + 42: css\`\`, + } + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); + +it('handles objects with enums as keys', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + import { TestEnum } from './ts-data.ts'; + + export const object = { + [TestEnum.FirstValue]: css\`\`, + [TestEnum.SecondValue]: css\`\`, + } + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); +}); diff --git a/@linaria/packages/babel/__tests__/depsGraph.test.ts b/@linaria/packages/babel/__tests__/depsGraph.test.ts new file mode 100644 index 0000000..000bd30 --- /dev/null +++ b/@linaria/packages/babel/__tests__/depsGraph.test.ts @@ -0,0 +1,356 @@ +/* eslint-disable no-template-curly-in-string */ + +import dedent from 'dedent'; +import * as babel from '@babel/core'; +import buildDepsGraph from '../../shaker/src/graphBuilder'; + +function _build(literal: TemplateStringsArray, ...placeholders: string[]) { + const code = dedent(literal, ...placeholders); + return { + ast: babel.parseSync(code, { filename: 'source.js' })!, + code, + }; +} + +function _buildGraph(literal: TemplateStringsArray, ...placeholders: string[]) { + const { ast } = _build(literal, ...placeholders); + return buildDepsGraph(ast); +} + +describe('VariableDeclaration', () => { + it('Identifier', () => { + const graph = _buildGraph` + const a = 42; + `; + + const deps = graph.getDependenciesByBinding('0:a'); + expect(deps).toMatchObject([ + { + type: 'VariableDeclarator', + }, + { + type: 'NumericLiteral', + value: 42, + }, + ]); + + expect(graph.findDependencies({ name: 'a' })).toContainEqual(deps[0]); + }); +}); + +describe('scopes', () => { + it('BlockStatement', () => { + const graph = _buildGraph` + let a = 42; + { + const a = "21"; + } + + a = 21; + `; + + const deps0 = graph.getDependenciesByBinding('0:a'); + expect(deps0).toMatchObject([ + { + type: 'VariableDeclarator', + }, + { + type: 'NumericLiteral', + value: 42, + }, + { + type: 'Identifier', + start: 4, + }, + { + type: 'Identifier', + start: 35, + }, + { + type: 'AssignmentExpression', + }, + { + type: 'NumericLiteral', + value: 21, + }, + ]); + + const deps1 = graph.getDependenciesByBinding('1:a'); + expect(deps1).toMatchObject([ + { + type: 'VariableDeclarator', + }, + { + type: 'StringLiteral', + value: '21', + }, + ]); + + expect(graph.findDependencies({ name: 'a' })).toHaveLength(8); + }); + + it('Function', () => { + const graph = _buildGraph` + const a = (arg1, arg2, arg3) => arg1 + arg2 + arg3; + `; + + const aDeps = graph.getDependenciesByBinding('0:a'); + expect(aDeps).toMatchObject([ + { + type: 'VariableDeclarator', + }, + { + type: 'ArrowFunctionExpression', + }, + ]); + + expect(graph.getDependenciesByBinding('1:arg1')).toHaveLength(3); + expect(graph.getDependentsByBinding('1:arg1')).toMatchObject([ + { + // arg1 in the binary expression + type: 'Identifier', + name: 'arg1', + start: 32, + }, + { + // arg1 + arg2 + type: 'BinaryExpression', + right: { + name: 'arg2', + }, + }, + { + // (arg1 + arg2) + arg3, because of it is the whole function body + type: 'BinaryExpression', + right: { + name: 'arg3', + }, + }, + ]); + + expect(graph.getDependenciesByBinding('1:arg2')).toMatchObject([ + { + type: 'ArrowFunctionExpression', + }, + { + type: 'Identifier', + name: 'arg2', + start: 17, + }, + { + type: 'BinaryExpression', + start: 32, + }, + ]); + + expect(graph.getDependenciesByBinding('1:arg3')).toMatchObject([ + { + type: 'ArrowFunctionExpression', + }, + { + type: 'Identifier', + name: 'arg3', + start: 23, + }, + { + type: 'BinaryExpression', + start: 32, + }, + ]); + }); +}); + +describe('AssignmentExpression', () => { + it('Identifier', () => { + const graph = _buildGraph` + let a = 42; + a = 24; + `; + + const deps = graph.getDependenciesByBinding('0:a'); + expect(deps).toMatchObject([ + { + type: 'VariableDeclarator', + }, + { + type: 'NumericLiteral', + value: 42, + }, + { + type: 'Identifier', + name: 'a', + start: 4, + }, + { + type: 'Identifier', + name: 'a', + start: 12, + }, + { + type: 'AssignmentExpression', + }, + { + type: 'NumericLiteral', + value: 24, + }, + ]); + + expect(graph.findDependents({ value: 42 })).toHaveLength(1); + expect(graph.findDependents({ value: 24 })).toHaveLength(1); + }); + + it('MemberExpression', () => { + const graph = _buildGraph` + const a = {}; + a.foo.bar = 42; + `; + + expect(graph.getDependenciesByBinding('0:a')).toMatchObject([ + { + type: 'VariableDeclarator', + }, + { + type: 'ObjectExpression', + properties: [], + }, + { + type: 'Identifier', + name: 'a', + start: 6, + }, + { + type: 'Identifier', + name: 'a', + start: 14, + }, + { + type: 'MemberExpression', + property: { + name: 'foo', + }, + }, + ]); + + expect(graph.findDependents({ value: 42 })).toMatchObject([ + { + type: 'MemberExpression', + property: { + name: 'bar', + }, + }, + ]); + }); +}); + +it('SequenceExpression', () => { + const graph = _buildGraph` + const color1 = 'blue'; + let local = ''; + const color2 = (true, local = color1, () => local); + `; + + const seqDeps = graph.findDependencies({ + type: 'SequenceExpression', + }); + expect(seqDeps).toMatchObject([ + { + type: 'ArrowFunctionExpression', + }, + { + id: { + name: 'color2', + }, + type: 'VariableDeclarator', + }, + ]); + + const fnDeps = graph.findDependencies({ + type: 'ArrowFunctionExpression', + }); + expect(fnDeps).toMatchObject([ + { + body: { + name: 'local', + type: 'Identifier', + }, + + type: 'ArrowFunctionExpression', + }, + { + name: 'local', + type: 'Identifier', + }, + { + type: 'SequenceExpression', + }, + ]); + + const localDeps = graph.getDependenciesByBinding('0:local'); + expect(localDeps).toMatchObject([ + { + type: 'VariableDeclarator', + }, + { + type: 'StringLiteral', + value: '', + }, + { + type: 'Identifier', + name: 'local', + start: 27, + }, + { + type: 'Identifier', + name: 'local', + start: 61, + }, + { + type: 'AssignmentExpression', + }, + { + type: 'Identifier', + name: 'color1', + }, + { + type: 'Identifier', + name: 'local', + start: 27, + }, + { + type: 'ArrowFunctionExpression', + }, + ]); + + const bool = { type: 'BooleanLiteral' }; + expect(graph.findDependents(bool)).toHaveLength(0); + expect(graph.findDependencies(bool)).toHaveLength(1); +}); + +it('MemberExpression', () => { + const graph = _buildGraph` + const key = 'blue'; + const obj = { blue: '#00F' }; + const blue = obj[key]; + `; + + const memberExprDeps = graph.findDependencies({ + type: 'MemberExpression', + }); + + expect(memberExprDeps).toMatchObject([ + { + type: 'Identifier', + name: 'obj', + }, + { + type: 'Identifier', + name: 'key', + }, + { + type: 'VariableDeclarator', + id: { + name: 'blue', + }, + }, + ]); +}); diff --git a/@linaria/packages/babel/__tests__/dynamic-import-noop.test.js b/@linaria/packages/babel/__tests__/dynamic-import-noop.test.js new file mode 100644 index 0000000..04f0452 --- /dev/null +++ b/@linaria/packages/babel/__tests__/dynamic-import-noop.test.js @@ -0,0 +1,15 @@ +import { transformAsync } from '@babel/core'; + +it('replaces dynamic imports with a noop', async () => { + const { code } = await transformAsync( + `import('./foo').then(foo => foo.init())`, + { + plugins: [require.resolve('../src/dynamic-import-noop')], + filename: 'source.js', + configFile: false, + babelrc: false, + } + ); + + expect(code).toMatchSnapshot(); +}); diff --git a/@linaria/packages/babel/__tests__/evaluators/__snapshots__/extractor.test.ts.snap b/@linaria/packages/babel/__tests__/evaluators/__snapshots__/extractor.test.ts.snap new file mode 100644 index 0000000..7ee2296 --- /dev/null +++ b/@linaria/packages/babel/__tests__/evaluators/__snapshots__/extractor.test.ts.snap @@ -0,0 +1,237 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`keeps objects as is 1`] = ` +"{ + const fill1 = (top = 0, left = 0, right = 0, bottom = 0) => ({ + position: 'absolute', + top, + right, + bottom, + left + }); + + { + const fill2 = (top = 0, left = 0, right = 0, bottom = 0) => { + return { + position: 'absolute', + top, + right, + bottom, + left + }; + }; + + { + exports.__linariaPreval = [fill1, fill2]; + } + } +}" +`; + +exports[`keeps only code which is related to \`a\` 1`] = ` +"{ + const { + whiteColor: color, + anotherColor + } = require('…'); + + { + const a = color || anotherColor; + { + exports.__linariaPreval = [a]; + } + } +}" +`; + +exports[`keeps only code which is related to \`anotherColor\` 1`] = ` +"{ + const { + whiteColor: color, + anotherColor + } = require('…'); + + { + exports.__linariaPreval = [anotherColor]; + } +}" +`; + +exports[`keeps only code which is related to \`color\` 1`] = ` +"{ + const { + whiteColor: color, + anotherColor + } = require('…'); + + { + exports.__linariaPreval = [color]; + } +}" +`; + +exports[`removes all 1`] = ` +"{ + exports.__linariaPreval = []; +}" +`; + +exports[`shakes assignment patterns 1`] = ` +"{ + const [identifier = 1] = [2]; + { + const [{ ...object + } = {}] = [{ + a: 1, + b: 2 + }]; + { + const [[...array] = []] = [[1, 2, 3, 4]]; + { + const obj = { + member: null + }; + { + exports.__linariaPreval = [identifier, object, array, obj]; + } + } + } + } +}" +`; + +exports[`shakes es5 exports 1`] = ` +"\\"use strict\\"; + +Object.defineProperty(exports, \\"__esModule\\", { + value: true +}); +exports.redColor = 'red'; +Object.defineProperty(exports, \\"blueColor\\", { + enumerable: true, + get: function get() { + return 'blue'; + } +}); +Object.defineProperty(exports, \\"greenColor\\", { + enumerable: true, + get: function get() { + return 'green'; + } +});" +`; + +exports[`shakes exports 1`] = ` +"{ + var _ = require(\\"\\\\u2026\\"); + + { + const a = _.whiteColor; + { + exports.__linariaPreval = [a]; + } + } +}" +`; + +exports[`shakes imports 1`] = ` +"{ + var _ = _interopRequireWildcard(require(\\"\\\\u2026\\")); + + { + function _getRequireWildcardCache(nodeInterop) { + if (typeof WeakMap !== \\"function\\") return null; + var cacheBabelInterop = new WeakMap(); + var cacheNodeInterop = new WeakMap(); + return (_getRequireWildcardCache = function (nodeInterop) { + return nodeInterop ? cacheNodeInterop : cacheBabelInterop; + })(nodeInterop); + } + + { + function _interopRequireWildcard(obj, nodeInterop) { + if (!nodeInterop && obj && obj.__esModule) { + return obj; + } + + if (obj === null || typeof obj !== \\"object\\" && typeof obj !== \\"function\\") { + return { + default: obj + }; + } + + var cache = _getRequireWildcardCache(nodeInterop); + + if (cache && cache.has(obj)) { + return cache.get(obj); + } + + var newObj = {}; + var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; + + for (var key in obj) { + if (key !== \\"default\\" && Object.prototype.hasOwnProperty.call(obj, key)) { + var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; + + if (desc && (desc.get || desc.set)) { + Object.defineProperty(newObj, key, desc); + } else { + newObj[key] = obj[key]; + } + } + } + + newObj.default = obj; + + if (cache) { + cache.set(obj, newObj); + } + + return newObj; + } + + { + exports.__linariaPreval = [_.whiteColor, _.default]; + } + } + } +}" +`; + +exports[`shakes sequence expression 1`] = ` +"{ + var _ = require(\\"\\\\u2026\\"); + + { + const color1 = () => 'blue'; + + { + let local = ''; + { + const color2 = () => local; + + { + exports.__linariaPreval = [color2]; + } + } + } + } +}" +`; + +exports[`should keep member expression key 1`] = ` +"{ + const key = 'blue'; + { + const obj = { + blue: '#00F' + }; + { + const blue = obj[key]; + { + exports.__linariaPreval = [blue]; + } + } + } +}" +`; diff --git a/@linaria/packages/babel/__tests__/evaluators/__snapshots__/preeval.test.ts.snap b/@linaria/packages/babel/__tests__/evaluators/__snapshots__/preeval.test.ts.snap new file mode 100644 index 0000000..93660fa --- /dev/null +++ b/@linaria/packages/babel/__tests__/evaluators/__snapshots__/preeval.test.ts.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`handles locally named import 1`] = ` +"import { styled as custom } from '@linaria/react'; +const Component = +/*linaria styled ci72cjv Component Component_ci72cjv*/ +custom.div\`\`;" +`; + +exports[`hoists exports 1`] = ` +"\\"use strict\\"; + +var _foo = require(\\"./foo\\"); + +Object.defineProperty(exports, \\"__esModule\\", { + value: true +}); +Object.defineProperty(exports, \\"foo\\", { + enumerable: true, + get: function get() { + return _foo.foo; + } +}); +Object.defineProperty(exports, \\"bar\\", { + enumerable: true, + get: function get() { + return _foo.bar; + } +});" +`; + +exports[`preserves classNames 1`] = ` +"import { styled } from '@linaria/react'; +const Component = +/*linaria styled ci72cjv Component Component_ci72cjv*/ +styled.div\`\`;" +`; + +exports[`replaces class component 1`] = ` +"import React from 'react'; + +function Component() { + return <></>; +}" +`; + +exports[`replaces constant 1`] = ` +"import React from 'react'; +const tag = <></>; + +const Component = props => tag;" +`; + +exports[`replaces functional component 1`] = ` +"import React from 'react'; + +const Component = () => { + return <></>; +};" +`; diff --git a/@linaria/packages/babel/__tests__/evaluators/__snapshots__/shaker.test.ts.snap b/@linaria/packages/babel/__tests__/evaluators/__snapshots__/shaker.test.ts.snap new file mode 100644 index 0000000..92b9b9e --- /dev/null +++ b/@linaria/packages/babel/__tests__/evaluators/__snapshots__/shaker.test.ts.snap @@ -0,0 +1,213 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`keeps objects as is 1`] = ` +"\\"use strict\\"; + +var fill1 = function fill1() { + var top = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + var left = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var right = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + var bottom = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; + return { + position: 'absolute', + top: top, + right: right, + bottom: bottom, + left: left + }; +}; + +var fill2 = function fill2() { + var top = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + var left = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var right = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + var bottom = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; + return { + position: 'absolute', + top: top, + right: right, + bottom: bottom, + left: left + }; +}; + +exports.__linariaPreval = [fill1, fill2];" +`; + +exports[`keeps only code which is related to \`a\` 1`] = ` +"\\"use strict\\"; + +var _require = require('…'), + color = _require.whiteColor, + anotherColor = _require.anotherColor; + +var a = color || anotherColor; +color.green = '#0f0'; +exports.__linariaPreval = [a];" +`; + +exports[`keeps only code which is related to \`anotherColor\` 1`] = ` +"\\"use strict\\"; + +var _require = require('…'), + anotherColor = _require.anotherColor; + +exports.__linariaPreval = [anotherColor];" +`; + +exports[`keeps only code which is related to \`color\` 1`] = ` +"\\"use strict\\"; + +var _require = require('…'), + color = _require.whiteColor; + +color.green = '#0f0'; +exports.__linariaPreval = [color];" +`; + +exports[`keeps only the last assignment of each exported variable 1`] = ` +"\\"use strict\\"; + +var bar = function bar() { + return 'hello world'; +}; + +exports.bar = bar; +var foo = exports.bar(); +exports.__linariaPreval = [foo];" +`; + +exports[`keeps reused exports 1`] = ` +"\\"use strict\\"; + +var bar = function bar() { + return 'hello world'; +}; + +exports.bar = bar; +var foo = exports.bar(); +exports.__linariaPreval = [foo];" +`; + +exports[`removes all 1`] = ` +"\\"use strict\\"; + +exports.__linariaPreval = [];" +`; + +exports[`shakes assignment patterns 1`] = ` +"\\"use strict\\"; + +var _interopRequireDefault = require(\\"@babel/runtime/helpers/interopRequireDefault\\"); + +var _toArray2 = _interopRequireDefault(require(\\"@babel/runtime/helpers/toArray\\")); + +var _extends2 = _interopRequireDefault(require(\\"@babel/runtime/helpers/extends\\")); + +var _ = 2, + identifier = _ === void 0 ? 1 : _; +var _a$b = { + a: 1, + b: 2 +}; +_a$b = _a$b === void 0 ? {} : _a$b; +var object = (_extends2.default)({}, _a$b); +var _ref = [1, 2, 3, 4]; +_ref = _ref === void 0 ? [] : _ref; + +var _ref2 = (_toArray2.default)(_ref), + array = _ref2.slice(0); + +var obj = { + member: null +}; +var _2 = 1; +obj.member = _2 === void 0 ? 42 : _2; +exports.__linariaPreval = [identifier, object, array, obj];" +`; + +exports[`shakes es5 exports 1`] = ` +"\\"use strict\\"; + +Object.defineProperty(exports, \\"__esModule\\", { + value: true +}); +exports.redColor = 'red'; +exports['yellowColor'] = 'yellow'; +Object.defineProperty(exports, \\"greenColor\\", { + enumerable: true, + get: function get() { + return 'green'; + } +});" +`; + +exports[`shakes exports 1`] = ` +"\\"use strict\\"; + +var _ = require(\\"\\\\u2026\\"); + +Object.defineProperty(exports, \\"__esModule\\", { + value: true +}); +var a = _.whiteColor; +exports.__linariaPreval = [a];" +`; + +exports[`shakes for-in statements 1`] = ` +"\\"use strict\\"; + +var obj1 = { + a: 1, + b: 2 +}; +var obj2 = {}; + +for (var key in obj1) { + obj2[key] = obj1[key]; +} + +exports.__linariaPreval = [obj2];" +`; + +exports[`shakes imports 1`] = ` +"\\"use strict\\"; + +var _typeof = require(\\"@babel/runtime/helpers/typeof\\"); + +Object.defineProperty(exports, \\"__esModule\\", { + value: true +}); + +var _ = _interopRequireWildcard(require(\\"\\\\u2026\\")); + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== \\"function\\") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || _typeof(obj) !== \\"object\\" && typeof obj !== \\"function\\") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== \\"default\\" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +exports.__linariaPreval = [_.whiteColor, _.default];" +`; + +exports[`shakes sequence expression 1`] = ` +"\\"use strict\\"; + +var color1 = (function () { + return 'blue'; +}); +var local = ''; +var color2 = (local = color1(), function () { + return local; +}); +exports.__linariaPreval = [color2];" +`; + +exports[`should keep member expression key 1`] = ` +"\\"use strict\\"; + +var key = 'blue'; +var obj = { + blue: '#00F' +}; +var blue = obj[key]; +exports.__linariaPreval = [blue];" +`; diff --git a/@linaria/packages/babel/__tests__/evaluators/extractor.test.ts b/@linaria/packages/babel/__tests__/evaluators/extractor.test.ts new file mode 100644 index 0000000..b764a25 --- /dev/null +++ b/@linaria/packages/babel/__tests__/evaluators/extractor.test.ts @@ -0,0 +1,214 @@ +import path from 'path'; +import dedent from 'dedent'; +import type { TransformOptions } from '@babel/core'; +import exctract from '../../../extractor/src'; + +function getFileName() { + return path.resolve(__dirname, `../__fixtures__/test.js`); +} + +function _shake(opts?: TransformOptions, only: string[] = ['__linariaPreval']) { + return ( + literal: TemplateStringsArray, + ...placeholders: string[] + ): [string, Map<string, string[]>] => { + const code = dedent(literal, ...placeholders); + const [shaken, deps] = exctract( + getFileName(), + { + babelOptions: opts || {}, + displayName: true, + evaluate: true, + rules: [ + { + action: require('../../../extractor/src').default, + }, + { + test: /\/node_modules\//, + action: 'ignore', + }, + ], + }, + code, + only + ); + + return [shaken, deps!]; + }; +} + +it('removes all', () => { + const [shaken] = _shake()` + const { whiteColor: color, anotherColor } = require('…'); + const a = color || anotherColor; + color.green = '#0f0'; + + exports.__linariaPreval = []; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('keeps only code which is related to `color`', () => { + const [shaken] = _shake()` + const { whiteColor: color, anotherColor } = require('…'); + const wrap = ''; + const a = color || anotherColor; + color.green = '#0f0'; + module.exports = { color, anotherColor }; + exports.__linariaPreval = [color]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('keeps only code which is related to `anotherColor`', () => { + const [shaken] = _shake()` + const { whiteColor: color, anotherColor } = require('…'); + const a = color || anotherColor; + color.green = '#0f0'; + exports.__linariaPreval = [anotherColor]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('keeps only code which is related to `a`', () => { + const [shaken] = _shake()` + const { whiteColor: color, anotherColor } = require('…'); + const a = color || anotherColor; + color.green = '#0f0'; + exports.__linariaPreval = [a]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes imports', () => { + const [shaken] = _shake()` + import { unrelatedImport } from '…'; + import { whiteColor as color, anotherColor } from '…'; + import defaultColor from '…'; + import anotherDefaultColor from '…'; + import '…'; + require('…'); + export default color; + exports.__linariaPreval = [color, defaultColor]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('should keep member expression key', () => { + const [shaken] = _shake()` + const key = 'blue'; + const obj = { blue: '#00F' }; + const blue = obj[key]; + exports.__linariaPreval = [blue]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes exports', () => { + const [shaken] = _shake()` + import { whiteColor as color, anotherColor } from '…'; + export const a = color; + export { redColor } from "…"; + export { anotherColor }; + exports.__linariaPreval = [a]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes es5 exports', () => { + const [shaken] = _shake(undefined, ['redColor', 'greenColor'])` + "use strict"; + Object.defineProperty(exports, "__esModule", { + value: true + }); + exports.redColor = 'red'; + Object.defineProperty(exports, "blueColor", { + enumerable: true, + get: function get() { + return 'blue'; + } + }); + Object.defineProperty(exports, "greenColor", { + enumerable: true, + get: function get() { + return 'green'; + } + }); + `; + + expect(shaken).toMatchSnapshot(); +}); + +// TODO: this test will be disabled until the shaker is fully implemented +// eslint-disable-next-line jest/no-disabled-tests +it.skip('should throw away any side effects', () => { + const [shaken] = _shake()` + const objects = { key: { fontSize: 12 } }; + const foo = (k) => { + const obj = objects[k]; + console.log('side effect'); + return obj; + }; + exports.__linariaPreval = [foo]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('keeps objects as is', () => { + const [shaken] = _shake()` + const fill1 = (top = 0, left = 0, right = 0, bottom = 0) => ({ + position: 'absolute', + top, + right, + bottom, + left, + }); + + const fill2 = (top = 0, left = 0, right = 0, bottom = 0) => { + return { + position: 'absolute', + top, + right, + bottom, + left, + }; + }; + + exports.__linariaPreval = [fill1, fill2]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes sequence expression', () => { + const [shaken] = _shake()` + import { external } from '…'; + const color1 = (external, () => 'blue'); + let local = ''; + const color2 = (local = color1(), () => local); + exports.__linariaPreval = [color2]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes assignment patterns', () => { + const [shaken] = _shake()` + const [identifier = 1] = [2]; + const [{...object} = {}] = [{ a: 1, b: 2 }]; + const [[...array] = []] = [[1,2,3,4]]; + const obj = { member: null }; + ([obj.member = 42] = [1]); + exports.__linariaPreval = [identifier, object, array, obj]; + `; + + expect(shaken).toMatchSnapshot(); +}); diff --git a/@linaria/packages/babel/__tests__/evaluators/preeval.test.ts b/@linaria/packages/babel/__tests__/evaluators/preeval.test.ts new file mode 100644 index 0000000..307abd7 --- /dev/null +++ b/@linaria/packages/babel/__tests__/evaluators/preeval.test.ts @@ -0,0 +1,121 @@ +import { join } from 'path'; +import { transformAsync } from '@babel/core'; +import dedent from 'dedent'; +import serializer from '../../__utils__/linaria-snapshot-serializer'; +import type { StrictOptions } from '../../src'; + +expect.addSnapshotSerializer(serializer); + +const options: Partial<StrictOptions> = { + displayName: true, + evaluate: true, +}; + +const transpile = async (input: string) => + (await transformAsync(input, { + babelrc: false, + presets: [[require.resolve('@linaria/preeval'), options]], + plugins: [ + '@babel/plugin-proposal-class-properties', + '@babel/plugin-syntax-jsx', + ], + filename: join(__dirname, 'app/index.js'), + configFile: false, + }))!; + +it('preserves classNames', async () => { + const { code } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const Component = styled.div\`\`; + ` + ); + + expect(code).toMatchSnapshot(); +}); + +it('handles locally named import', async () => { + const { code } = await transpile( + dedent` + import { styled as custom } from '@linaria/react'; + + const Component = custom.div\`\`; + ` + ); + + expect(code).toMatchSnapshot(); +}); + +it('replaces functional component', async () => { + const div = '<div>{props.children}</div>'; + const { code } = await transpile( + dedent` + import React from 'react'; + + const Component = (props) => ${div}; + ` + ); + + expect(code).toMatchSnapshot(); +}); + +it('replaces class component', async () => { + const div = '<div>{props.children}</div>'; + const { code } = await transpile( + dedent` + import React from 'react'; + + class Component extends React.PureComponent { + render() { + return ${div}; + } + } + ` + ); + + expect(code).toMatchSnapshot(); +}); + +it('replaces constant', async () => { + const div = '<div>{props.children}</div>'; + const { code } = await transpile( + dedent` + import React from 'react'; + + const tag = ${div}; + + const Component = (props) => tag; + ` + ); + + expect(code).toMatchSnapshot(); +}); + +it('hoists exports', async () => { + const { code } = await transpile( + dedent` + "use strict"; + + Object.defineProperty(exports, "__esModule", { + value: true + }); + Object.defineProperty(exports, "foo", { + enumerable: true, + get: function get() { + return _foo.foo; + } + }); + Object.defineProperty(exports, "bar", { + enumerable: true, + get: function get() { + return _foo.bar; + } + }); + + var _foo = require("./foo"); + ` + ); + + expect(code).toMatchSnapshot(); +}); diff --git a/@linaria/packages/babel/__tests__/evaluators/shaker.test.ts b/@linaria/packages/babel/__tests__/evaluators/shaker.test.ts new file mode 100644 index 0000000..1208065 --- /dev/null +++ b/@linaria/packages/babel/__tests__/evaluators/shaker.test.ts @@ -0,0 +1,259 @@ +import path from 'path'; +import dedent from 'dedent'; +import type { TransformOptions } from '@babel/core'; +import shake from '../../../shaker/src'; + +function getFileName() { + return path.resolve(__dirname, `../__fixtures__/test.js`); +} + +function _shake(opts?: TransformOptions, only: string[] = ['__linariaPreval']) { + return ( + literal: TemplateStringsArray, + ...placeholders: string[] + ): [string, Map<string, string[]>] => { + const code = dedent(literal, ...placeholders); + const [shaken, deps] = shake( + getFileName(), + { + babelOptions: opts || {}, + displayName: true, + evaluate: true, + rules: [ + { + action: require('../../../extractor/src').default, + }, + { + test: /\/node_modules\//, + action: 'ignore', + }, + ], + }, + code, + only + ); + + return [shaken, deps!]; + }; +} + +it('removes all', () => { + const [shaken] = _shake()` + const { whiteColor: color, anotherColor } = require('…'); + const a = color || anotherColor; + color.green = '#0f0'; + + exports.__linariaPreval = []; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('keeps only code which is related to `color`', () => { + const [shaken] = _shake()` + const { whiteColor: color, anotherColor } = require('…'); + const wrap = ''; + const a = color || anotherColor; + color.green = '#0f0'; + module.exports = { color, anotherColor }; + exports.__linariaPreval = [color]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('keeps only code which is related to `anotherColor`', () => { + const [shaken] = _shake()` + const { whiteColor: color, anotherColor } = require('…'); + const a = color || anotherColor; + color.green = '#0f0'; + exports.__linariaPreval = [anotherColor]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('keeps only code which is related to `a`', () => { + const [shaken] = _shake()` + const { whiteColor: color, anotherColor } = require('…'); + const a = color || anotherColor; + color.green = '#0f0'; + exports.__linariaPreval = [a]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes imports', () => { + const [shaken] = _shake()` + import { unrelatedImport } from '…'; + import { whiteColor as color, anotherColor } from '…'; + import defaultColor from '…'; + import anotherDefaultColor from '…'; + import '…'; + require('…'); + export default color; + exports.__linariaPreval = [color, defaultColor]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('should keep member expression key', () => { + const [shaken] = _shake()` + const key = 'blue'; + const obj = { blue: '#00F' }; + const blue = obj[key]; + exports.__linariaPreval = [blue]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes exports', () => { + const [shaken] = _shake()` + import { whiteColor as color, anotherColor } from '…'; + export const a = color; + export { redColor } from "…"; + export { anotherColor }; + exports.__linariaPreval = [a]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes es5 exports', () => { + const [shaken] = _shake(undefined, ['redColor', 'greenColor', 'yellowColor'])` + "use strict"; + Object.defineProperty(exports, "__esModule", { + value: true + }); + exports.redColor = 'red'; + exports['yellowColor'] = 'yellow'; + exports['pinkColor'] = 'pink'; + Object.defineProperty(exports, "blueColor", { + enumerable: true, + get: function get() { + return 'blue'; + } + }); + Object.defineProperty(exports, "greenColor", { + enumerable: true, + get: function get() { + return 'green'; + } + }); + `; + + expect(shaken).toMatchSnapshot(); +}); + +// TODO: this test will be disabled until the shaker is fully implemented +// eslint-disable-next-line jest/no-disabled-tests +it.skip('should throw away any side effects', () => { + const [shaken] = _shake()` + const objects = { key: { fontSize: 12 } }; + const foo = (k) => { + const obj = objects[k]; + console.log('side effect'); + return obj; + }; + exports.__linariaPreval = [foo]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('keeps objects as is', () => { + const [shaken] = _shake()` + const fill1 = (top = 0, left = 0, right = 0, bottom = 0) => ({ + position: 'absolute', + top, + right, + bottom, + left, + }); + + const fill2 = (top = 0, left = 0, right = 0, bottom = 0) => { + return { + position: 'absolute', + top, + right, + bottom, + left, + }; + }; + + exports.__linariaPreval = [fill1, fill2]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes sequence expression', () => { + const [shaken] = _shake()` + import { external } from '…'; + const color1 = (external, () => 'blue'); + let local = ''; + const color2 = (local = color1(), () => local); + exports.__linariaPreval = [color2]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes assignment patterns', () => { + const [shaken] = _shake()` + const [identifier = 1] = [2]; + const [{...object} = {}] = [{ a: 1, b: 2 }]; + const [[...array] = []] = [[1,2,3,4]]; + const obj = { member: null }; + ([obj.member = 42] = [1]); + exports.__linariaPreval = [identifier, object, array, obj]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('shakes for-in statements', () => { + const [shaken] = _shake()` + const obj1 = { a: 1, b: 2 }; + const obj2 = {}; + for (const key in obj1) { + obj2[key] = obj1[key]; + } + exports.__linariaPreval = [obj2]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('keeps reused exports', () => { + const [shaken] = _shake()` + const bar = function() { + return 'hello world'; + }; + exports.bar = bar; + + const foo = exports.bar(); + exports.__linariaPreval = [foo]; + `; + + expect(shaken).toMatchSnapshot(); +}); + +it('keeps only the last assignment of each exported variable', () => { + const [shaken] = _shake()` + const bar = function() { + return 'hello world'; + }; + + exports.bar = "bar"; + exports.bar = bar; + + const foo = exports.bar(); + exports.__linariaPreval = [foo]; + `; + + expect(shaken).toMatchSnapshot(); +}); diff --git a/@linaria/packages/babel/__tests__/module.test.ts b/@linaria/packages/babel/__tests__/module.test.ts new file mode 100644 index 0000000..1012b93 --- /dev/null +++ b/@linaria/packages/babel/__tests__/module.test.ts @@ -0,0 +1,320 @@ +import path from 'path'; +import dedent from 'dedent'; +import * as babel from '@babel/core'; +import { Module } from '../src'; +import type { Evaluator, StrictOptions } from '../src'; + +beforeEach(() => Module.invalidate()); + +const evaluator: Evaluator = (filename, options, text) => { + const { code } = babel.transformSync(text, { + filename: filename, + })!; + return [code!, null]; +}; + +function getFileName() { + return path.resolve(__dirname, `../__fixtures__/test.js`); +} + +const options: StrictOptions = { + displayName: false, + evaluate: true, + rules: [ + { + action: evaluator, + }, + { + test: /\/node_modules\//, + action: 'ignore', + }, + ], + babelOptions: {}, +}; + +beforeEach(() => Module.invalidateEvalCache()); + +it('creates module for JS files', () => { + const filename = '/foo/bar/test.js'; + const mod = new Module(filename, options); + + mod.evaluate('module.exports = () => 42'); + + expect(mod.exports()).toBe(42); + expect(mod.id).toBe(filename); + expect(mod.filename).toBe(filename); +}); + +it('requires JS files', () => { + const mod = new Module(getFileName(), options); + + mod.evaluate(dedent` + const answer = require('./sample-script'); + + module.exports = 'The answer is ' + answer; + `); + + expect(mod.exports).toBe('The answer is 42'); +}); + +it('requires JSON files', () => { + const mod = new Module(getFileName(), options); + + mod.evaluate(dedent` + const data = require('./sample-data.json'); + + module.exports = 'Our saviour, ' + data.name; + `); + + expect(mod.exports).toBe('Our saviour, Luke Skywalker'); +}); + +it('imports JS files', () => { + const mod = new Module(getFileName(), options); + + mod.evaluate(dedent` + import answer from './sample-script'; + + export const result = 'The answer is ' + answer; + `); + + expect(mod.exports.result).toBe('The answer is 42'); +}); + +it('imports TypeScript files', () => { + const mod = new Module( + path.resolve(__dirname, '../__fixtures__/test.ts'), + options + ); + + mod.evaluate(dedent` + import answer from './sample-typescript'; + + export const result = 'The answer is ' + answer; + `); + + expect(mod.exports.result).toBe('The answer is 27'); +}); + +it('imports JSON files', () => { + const mod = new Module(getFileName(), options); + + mod.evaluate(dedent` + import data from './sample-data.json'; + + const result = 'Our saviour, ' + data.name; + + export default result; + `); + + expect(mod.exports.default).toBe('Our saviour, Luke Skywalker'); +}); + +it('returns module from the cache', () => { + /* eslint-disable no-self-compare */ + + const filename = getFileName(); + const mod = new Module(filename, options); + const id = './sample-data.json'; + + expect(mod.require(id) === mod.require(id)).toBe(true); + + expect( + new Module(filename, options).require(id) === + new Module(filename, options).require(id) + ).toBe(true); +}); + +it('clears modules from the cache', () => { + const filename = getFileName(); + const id = './sample-data.json'; + + const result = new Module(filename, options).require(id); + + expect(result === new Module(filename, options).require(id)).toBe(true); + + Module.invalidate(); + + expect(result === new Module(filename, options).require(id)).toBe(false); +}); + +it('exports the path for non JS/JSON files', () => { + const mod = new Module(getFileName(), options); + + expect(mod.require('./sample-asset.png')).toBe('./sample-asset.png'); +}); + +it('returns module when requiring mocked builtin node modules', () => { + const mod = new Module(getFileName(), options); + + expect(mod.require('path')).toBe(require('path')); +}); + +it('returns null when requiring empty builtin node modules', () => { + const mod = new Module(getFileName(), options); + + expect(mod.require('fs')).toBe(null); +}); + +it('throws when requiring unmocked builtin node modules', () => { + const mod = new Module(getFileName(), options); + + expect(() => mod.require('perf_hooks')).toThrow( + 'Unable to import "perf_hooks". Importing Node builtins is not supported in the sandbox.' + ); +}); + +it('has access to the global object', () => { + const mod = new Module(getFileName(), options); + + expect(() => + mod.evaluate(dedent` + new global.Set(); + `) + ).not.toThrow(); +}); + +it("doesn't have access to the process object", () => { + const mod = new Module(getFileName(), options); + + expect(() => + mod.evaluate(dedent` + module.exports = process.abort(); + `) + ).toThrow('process.abort is not a function'); +}); + +it('has access to NODE_ENV', () => { + const mod = new Module(getFileName(), options); + + mod.evaluate(dedent` + module.exports = process.env.NODE_ENV; + `); + + expect(mod.exports).toBe(process.env.NODE_ENV); +}); + +it('has require.resolve available', () => { + const mod = new Module(getFileName(), options); + + mod.evaluate(dedent` + module.exports = require.resolve('./sample-script'); + `); + + expect(mod.exports).toBe( + path.resolve(path.dirname(mod.filename), 'sample-script.js') + ); +}); + +it('has require.ensure available', () => { + const mod = new Module(getFileName(), options); + + expect(() => + mod.evaluate(dedent` + require.ensure(['./sample-script']); + `) + ).not.toThrow(); +}); + +it('has __filename available', () => { + const mod = new Module(getFileName(), options); + + mod.evaluate(dedent` + module.exports = __filename; + `); + + expect(mod.exports).toBe(mod.filename); +}); + +it('has __dirname available', () => { + const mod = new Module(getFileName(), options); + + mod.evaluate(dedent` + module.exports = __dirname; + `); + + expect(mod.exports).toBe(path.dirname(mod.filename)); +}); + +it('has setTimeout, clearTimeout available', () => { + const mod = new Module(getFileName(), options); + + expect(() => + mod.evaluate(dedent` + const x = setTimeout(() => { + console.log('test'); + },0); + + clearTimeout(x); + `) + ).not.toThrow(); +}); + +it('has setInterval, clearInterval available', () => { + const mod = new Module(getFileName(), options); + + expect(() => + mod.evaluate(dedent` + const x = setInterval(() => { + console.log('test'); + }, 1000); + + clearInterval(x); + `) + ).not.toThrow(); +}); + +it('has setImmediate, clearImmediate available', () => { + const mod = new Module(getFileName(), options); + + expect(() => + mod.evaluate(dedent` + const x = setImmediate(() => { + console.log('test'); + }); + + clearImmediate(x); + `) + ).not.toThrow(); +}); + +it('has global objects available without referencing global', () => { + const mod = new Module(getFileName(), options); + + expect(() => + mod.evaluate(dedent` + const x = new Set(); + `) + ).not.toThrow(); +}); + +it('changes resolve behaviour on overriding _resolveFilename', () => { + const originalResolveFilename = Module._resolveFilename; + + Module._resolveFilename = (id) => (id === 'foo' ? 'bar' : id); + + const mod = new Module(getFileName(), options); + + mod.evaluate(dedent` + module.exports = [ + require.resolve('foo'), + require.resolve('test'), + ]; + `); + + // Restore old behavior + Module._resolveFilename = originalResolveFilename; + + expect(mod.exports).toEqual(['bar', 'test']); +}); + +it('correctly processes export declarations in strict mode', () => { + const filename = '/foo/bar/test.js'; + const mod = new Module(filename, options); + + mod.evaluate('"use strict"; exports = module.exports = () => 42'); + + expect(mod.exports()).toBe(42); + expect(mod.id).toBe(filename); + expect(mod.filename).toBe(filename); +}); diff --git a/@linaria/packages/babel/__tests__/transform.test.ts b/@linaria/packages/babel/__tests__/transform.test.ts new file mode 100644 index 0000000..c830ad9 --- /dev/null +++ b/@linaria/packages/babel/__tests__/transform.test.ts @@ -0,0 +1,237 @@ +/* eslint-disable no-template-curly-in-string */ + +import path from 'path'; +import dedent from 'dedent'; +import transform, { transformUrl } from '../src/transform'; +import evaluator from '../../extractor/src'; + +const outputFilename = './.linaria-cache/test.css'; + +const rules = [ + { + test: () => true, + action: evaluator, + }, +]; + +describe('transformUrl', () => { + type TransformUrlArgs = Parameters<typeof transformUrl>; + const dataset: Record<string, TransformUrlArgs> = { + '../assets/test.jpg': [ + './assets/test.jpg', + './.linaria-cache/test.css', + './test.js', + ], + '../a/b/test.jpg': [ + '../a/b/test.jpg', + './.linaria-cache/test.css', + './a/test.js', + ], + }; + + it('should work with posix paths', () => { + for (const result of Object.keys(dataset)) { + expect(transformUrl(...dataset[result])).toBe(result); + } + }); + + it('should work with win32 paths', () => { + const toWin32 = (p: string) => p.split(path.posix.sep).join(path.win32.sep); + const win32Dataset = Object.keys(dataset).reduce( + (acc, key) => ({ + ...acc, + [key]: [ + dataset[key][0], + toWin32(dataset[key][1]), + toWin32(dataset[key][2]), + path.win32, + ] as TransformUrlArgs, + }), + {} as Record<string, TransformUrlArgs> + ); + + for (const result of Object.keys(win32Dataset)) { + expect(transformUrl(...win32Dataset[result])).toBe(result); + } + }); +}); + +it('rewrites a relative path in url() declarations', async () => { + const { cssText } = await transform( + dedent` + import { css } from '@linaria/core'; + + export const title = css\` + background-image: url(./assets/test.jpg); + background-image: url("./assets/test.jpg"); + background-image: url('./assets/test.jpg'); + \`; + `, + { + filename: './test.js', + outputFilename: './.linaria-cache/test.css', + pluginOptions: { + rules, + }, + } + ); + + expect(cssText).toMatchSnapshot(); +}); + +it('rewrites multiple relative paths in url() declarations', async () => { + const { cssText } = await transform( + dedent` + import { css } from '@linaria/core'; + + export const title = css\` + @font-face { + font-family: Test; + src: url(./assets/font.woff2) format("woff2"), url(./assets/font.woff) format("woff"); + } + \`; + `, + { + filename: './test.js', + outputFilename, + pluginOptions: { + rules, + }, + } + ); + + expect(cssText).toMatchSnapshot(); +}); + +it("doesn't rewrite an absolute path in url() declarations", async () => { + const { cssText } = await transform( + dedent` + import { css } from '@linaria/core'; + + export const title = css\` + background-image: url(/assets/test.jpg); + \`; + `, + { + filename: './test.js', + outputFilename, + pluginOptions: { + rules, + }, + } + ); + + expect(cssText).toMatchSnapshot(); +}); + +it('respects passed babel options', async () => { + expect.assertions(2); + + expect(() => + transform( + dedent` + import { css } from '@linaria/core'; + + export const error = <jsx />; + `, + { + filename: './test.js', + outputFilename, + pluginOptions: { + rules, + babelOptions: { + babelrc: false, + configFile: false, + presets: [['@babel/preset-env', { loose: true }]], + }, + }, + } + ) + ).toThrow( + /Support for the experimental syntax 'jsx' isn't currently enabled/ + ); + + expect(() => + transform( + dedent` + import { css } from '@linaria/core'; + + export const error = <jsx />; + export const title = css\` + background-image: url(/assets/test.jpg); + \`; + `, + { + filename: './test.js', + outputFilename, + pluginOptions: { + rules, + babelOptions: { + babelrc: false, + configFile: false, + presets: [ + ['@babel/preset-env', { loose: true }], + '@babel/preset-react', + ], + }, + }, + } + ) + ).not.toThrow('Unexpected token'); +}); + +it("doesn't throw due to duplicate preset", async () => { + expect.assertions(1); + + expect(() => + transform( + dedent` + import { styled } from '@linaria/react'; + + const Title = styled.h1\` color: blue; \`; + + const Article = styled.article\` + ${'${Title}'} { + font-size: 16px; + } + \`; + `, + { + filename: './test.js', + outputFilename, + pluginOptions: { + rules, + babelOptions: { + babelrc: false, + configFile: false, + presets: [require.resolve('../src')], + plugins: [ + require.resolve('@babel/plugin-transform-modules-commonjs'), + ], + }, + }, + } + ) + ).not.toThrow('Duplicate plugin/preset detected'); +}); + +it('should return transformed code even when file only contains unused linaria code', async () => { + const { code } = await transform( + dedent` + import { css } from '@linaria/core'; + + const title = css\` + color: red; + \`; + `, + { + filename: './test.js', + outputFilename, + pluginOptions: { + rules, + }, + } + ); + + expect(code).not.toContain('css`'); +}); diff --git a/@linaria/packages/babel/__utils__/linaria-snapshot-serializer.ts b/@linaria/packages/babel/__utils__/linaria-snapshot-serializer.ts new file mode 100644 index 0000000..1e65cd3 --- /dev/null +++ b/@linaria/packages/babel/__utils__/linaria-snapshot-serializer.ts @@ -0,0 +1,26 @@ +import type { LinariaMetadata } from '../src'; + +type Serializer<T> = { + test: (value: any) => value is T; + print: (value: T) => string; +}; + +const withLinariaMetadata = ( + value: any +): value is { linaria: LinariaMetadata } => + value && typeof value.linaria === 'object'; + +export default { + test: withLinariaMetadata, + print: ({ linaria }) => ` +CSS: + +${Object.keys(linaria.rules) + .map((selector) => `${selector} {${linaria.rules[selector].cssText}}`) + .join('\n')} + +Dependencies: ${ + linaria.dependencies.length ? linaria.dependencies.join(', ') : 'NA' + } +`, +} as Serializer<{ linaria: LinariaMetadata }>; diff --git a/@linaria/packages/babel/__utils__/strategy-tester.ts b/@linaria/packages/babel/__utils__/strategy-tester.ts new file mode 100644 index 0000000..168b491 --- /dev/null +++ b/@linaria/packages/babel/__utils__/strategy-tester.ts @@ -0,0 +1,976 @@ +/* eslint-env jest */ +/* eslint-disable import/no-extraneous-dependencies */ +import { join, resolve } from 'path'; +import * as babel from '@babel/core'; +import dedent from 'dedent'; +import stripAnsi from 'strip-ansi'; + +import { Module } from '../src'; +import type { Evaluator, StrictOptions } from '../src'; +import serializer from './linaria-snapshot-serializer'; + +expect.addSnapshotSerializer(serializer); +async function transformAsync( + code: string, + opts?: babel.TransformOptions +): Promise<babel.BabelFileResult> { + return (await babel.transformAsync(code, opts))!; +} + +async function transformFileAsync( + filename: string, + opts?: babel.TransformOptions +): Promise<babel.BabelFileResult> { + return (await babel.transformFileAsync(filename, opts))!; +} + +type TranspileFn = ( + input: string, + conf?: (original: babel.TransformOptions) => babel.TransformOptions +) => Promise<babel.BabelFileResult>; + +// eslint-disable-next-line jest/no-export +export function run( + dirname: string, + evaluator: Evaluator, + strategyDependentTests: (transpile: TranspileFn) => void = () => {} +): void { + const babelrc: babel.TransformOptions = { + babelrc: false, + configFile: false, + plugins: [], + presets: [ + [ + require.resolve('../src'), + { + displayName: true, + evaluate: true, + rules: [ + { + action: evaluator, + }, + { + test: /\/node_modules\/(?!@linaria)/, + action: 'ignore', + }, + { + test: /\/@linaria\/(?:core|react)\/\w+\.ts/, + action: ( + filename: string, + options: StrictOptions, + text: string + ) => { + const { code } = babel.transformSync(text, { + filename, + })!; + return [code, new Map<string, string[]>()]; + }, + }, + ], + }, + ], + ], + }; + + const transpile: TranspileFn = async ( + input: string, + conf: (original: typeof babelrc) => typeof babelrc = (i) => i + ) => { + const { code, metadata } = await transformAsync(input, { + filename: join(dirname, 'source.js'), + ...conf(babelrc), + }); + // The slug will be machine specific, so replace it with a consistent one + return { + metadata, + code, + }; + }; + + beforeEach(() => Module.invalidateEvalCache()); + + it('evaluates identifier in scope', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const answer = 42; + const foo = () => answer; + const days = foo() + ' days'; + + export const Title = styled.h1\` + &:before { + content: "${'${days}'}" + } + \`; + ` + ); + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('hoistable identifiers', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + { + var days = 42; + } + + export const Title = styled.h1\` + &:before { + content: "${'${days}'}" + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('non-hoistable identifiers', async () => { + expect.assertions(1); + + try { + await transpile( + dedent` + import { styled } from '@linaria/react'; + + { + const days = 42; + } + + export const Title = styled.h1\` + &:before { + content: "${'${days}'}" + } + \`; + ` + ); + } catch (e) { + expect( + stripAnsi(e.message.replace(dirname, '<<DIRNAME>>')) + ).toMatchSnapshot(); + } + }); + + it('evaluates babel helpers', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + function copyAndExtend(a, b) { + return { ...a, ...b }; + } + + const obj = copyAndExtend({ a: 1 }, { a: 2 }); + + export const Title = styled.h1\` + &:before { + content: "${'${obj.a}'}" + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('evaluates imported typescript enums', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + import { Colors } from '@linaria/babel-preset/__fixtures__/enums'; + + export const Title = styled.h1\` + color: ${'${Colors.BLUE}'}; + \`; + `, + (config) => ({ + ...config, + presets: ['@babel/preset-typescript', ...(config.presets ?? [])], + filename: 'source.ts', + }) + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('evaluates local expressions', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const answer = 42; + const foo = () => answer; + + export const Title = styled.h1\` + &:before { + content: "${"${foo() + ' days'}"}" + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('evaluates functions with nested identifiers', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const objects = { key: { fontSize: 12 } }; + const foo = (k) => { + const obj = objects[k]; + return obj; + }; + + export const Title = styled.h1\` + ${"${foo('key')}"} + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('evaluates expressions with dependencies', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + import slugify from '@linaria/babel-preset/__fixtures__/slugify'; + + export const Title = styled.h1\` + &:before { + content: "${"${slugify('test')}"}" + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('evaluates expressions with expressions depending on shared dependency', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + const slugify = require('@linaria/babel-preset/__fixtures__/slugify').default; + + const boo = t => slugify(t) + 'boo'; + const bar = t => slugify(t) + 'bar'; + + export const Title = styled.h1\` + &:before { + content: "${"${boo('test') + bar('test')}"}" + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('evaluates multiple expressions with shared dependency', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + const slugify = require('@linaria/babel-preset/__fixtures__/slugify').default; + + const boo = t => slugify(t) + 'boo'; + const bar = t => slugify(t) + 'bar'; + + export const Title = styled.h1\` + &:before { + content: "${"${boo('test')}"}" + content: "${"${bar('test')}"}" + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('evaluates interpolations with sequence expression', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Title = styled.h1\` + color: ${'${(external, () => "blue")}'}; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('evaluates dependencies with sequence expression', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const color = (external, () => 'blue'); + + export const Title = styled.h1\` + color: ${'${color}'}; + \`; + ` + ); + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('evaluates component interpolations', async () => { + const { code, metadata } = await transpile( + dedent` + const { styled } = require('@linaria/react'); + + export const Title = styled.h1\` + color: red; + \`; + + export const Paragraph = styled.p\` + ${'${Title}'} { + color: blue; + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('throws when interpolation evaluates to undefined', async () => { + expect.assertions(1); + + try { + await transpile( + dedent` + const { styled } = require('@linaria/react'); + + let fontSize; + + export const Title = styled.h1\` + font-size: ${'${fontSize}'}; + \`; + ` + ); + } catch (e) { + expect( + stripAnsi(e.message.replace(dirname, '<<DIRNAME>>')) + ).toMatchSnapshot(); + } + }); + + it('throws when interpolation evaluates to null', async () => { + expect.assertions(1); + + try { + await transpile( + dedent` + const { styled } = require('@linaria/react'); + + const color = null; + + export const Title = styled.h1\` + color: ${'${color}'}; + \`; + ` + ); + } catch (e) { + expect( + stripAnsi(e.message.replace(dirname, '<<DIRNAME>>')) + ).toMatchSnapshot(); + } + }); + + it('throws when interpolation evaluates to NaN', async () => { + expect.assertions(1); + + try { + await transpile( + dedent` + const { styled } = require('@linaria/react'); + + const height = NaN; + + export const Title = styled.h1\` + height: ${'${height}'}px; + \`; + ` + ); + } catch (e) { + expect( + stripAnsi(e.message.replace(dirname, '<<DIRNAME>>')) + ).toMatchSnapshot(); + } + }); + + it('handles wrapping another styled component', async () => { + const { code, metadata } = await transpile( + dedent` + const { css } = require('..'); + const { styled } = require('@linaria/react'); + + const Title = styled.h1\` + color: red; + \`; + + export const BlueTitle = styled(Title)\` + font-size: 24px; + color: blue; + \`; + + export const GreenTitle = styled(BlueTitle)\` + color: green; + \`; + ` + ); + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('handles indirect wrapping another styled component', async () => { + const { code, metadata } = await transpile( + dedent` + const { styled } = require('@linaria/react'); + + const Title = styled.h1\` + color: red; + \`; + + const hoc = Cmp => Cmp; + + export const CustomTitle = styled(hoc(Title))\` + font-size: 24px; + color: blue; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('inlines object styles as CSS string', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const fill = (top = 0, left = 0, right = 0, bottom = 0) => ({ + position: 'absolute', + top, + right, + bottom, + left, + }); + + export const Title = styled.h1\` + ${'${fill(0, 0)}'} + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('inlines array styles as CSS string', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const fill = (top = 0, left = 0, right = 0, bottom = 0) => [ + { position: 'absolute' }, + { + top, + right, + bottom, + left, + } + ]; + + export const Title = styled.h1\` + ${'${fill(0, 0)}'} + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('ignores inline arrow function expressions', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Title = styled.h1\` + &:before { + content: "${'${props => props.content}'}" + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('simplifies react components', async () => { + const div = '<div>{props.children + constant}</div>'; + const { code, metadata } = await transpile( + dedent` + import React from 'react'; + import { styled } from '@linaria/react'; + import constant from './broken-dependency'; + + const FuncComponent = (props) => ${div}; + + class ClassComponent extends React.PureComponent { + method() { + return constant; + } + + render() { + return ${div}; + } + } + + export const StyledFunc = styled(FuncComponent)\` + color: red; + \`; + export const StyledClass = styled(ClassComponent)\` + color: blue; + \`; + `, + (config) => ({ + ...config, + plugins: ['@babel/plugin-syntax-jsx', ...(config.plugins || [])], + }) + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('ignores inline vanilla function expressions', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + export const Title = styled.h1\` + &:before { + content: "${'${function(props) { return props.content }}'}" + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('ignores external expressions', async () => { + const { code, metadata } = await transpile( + dedent` + import { styled } from '@linaria/react'; + + const generate = props => props.content; + + export const Title = styled.h1\` + &:before { + content: "${'${generate}'}" + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('evaluates complex styles with functions and nested selectors', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + export const bareIconClass = css\`\`; + + const getSizeStyles = (fs) => ({ + [\`${'&.${bareIconClass}'}\`]: { + fontSize: fs * 1.5, + }, + }); + + export const SIZES = { + XS: css\`${'${getSizeStyles(11)}'}\`, + }; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('throws codeframe error when evaluation fails', async () => { + expect.assertions(1); + + try { + await transpile( + dedent` + import { styled } from '@linaria/react'; + + const foo = props => { throw new Error('This will fail') }; + + export const Title = styled.h1\` + font-size: ${'${foo()}'}px; + \`; + ` + ); + } catch (e) { + expect( + stripAnsi(e.message.replace(dirname, '<<DIRNAME>>')) + ).toMatchSnapshot(); + } + }); + + it('handles escapes properly', async () => { + const { code, metadata } = await transformFileAsync( + resolve(__dirname, '../__fixtures__/escape-character.js'), + babelrc + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('handles complex component', async () => { + const { code, metadata } = await transformFileAsync( + resolve(__dirname, '../__fixtures__/complex-component.js'), + babelrc + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('generates stable class names', async () => { + const { code, metadata } = await transformFileAsync( + resolve(__dirname, '../__fixtures__/components-library.js'), + babelrc + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('derives display name from filename', async () => { + const { code, metadata } = await transformAsync( + dedent` + import { styled } from '@linaria/react'; + + export default styled.h1\` + font-size: 14px; + \`; + `, + { + ...babelrc, + filename: join(dirname, 'FancyName.js'), + } + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('derives display name from parent folder name', async () => { + const { code, metadata } = await transformAsync( + dedent` + import { styled } from '@linaria/react'; + + export default styled.h1\` + font-size: 14px; + \`; + `, + { + ...babelrc, + filename: join(dirname, 'FancyName/index.js'), + } + )!; + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it("throws if couldn't determine a display name", async () => { + expect.assertions(1); + + try { + await transformAsync( + dedent` + import { styled } from '@linaria/react'; + + export default styled.h1\` + font-size: 14px; + \`; + `, + { + ...babelrc, + filename: join(dirname, '/.js'), + } + ); + } catch (e) { + expect( + stripAnsi(e.message.replace(dirname, '<<DIRNAME>>')) + ).toMatchSnapshot(); + } + }); + + it('does not strip instanbul coverage sequences', async () => { + const { code, metadata } = await transformAsync( + dedent` + import { styled } from '@linaria/react'; + + const a = 42; + + export const Title = styled.h1\` + height: ${'${a}'}px; + \`; + `, + { + ...babelrc, + cwd: '/home/user/project', + filename: 'file.js', + plugins: [ + [ + // eslint-disable-next-line import/no-extraneous-dependencies + require('babel-plugin-istanbul').default({ + ...babel, + assertVersion: () => {}, + }), + { cwd: '/home/user/project' }, + ], + ], + } + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + // PR #524 + it('should work with String and Number object', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from '@linaria/core'; + + export const style = css\` + width: ${'${new String("100%")}'}; + opacity: ${'${new Number(0.75)}'}; + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('should work with generated classnames as selectors', async () => { + const { code, metadata } = await transpile( + dedent` + import { css } from "@linaria/core"; + + export const text = css\`\`; + + export const square = css\` + .${'${text}'} { + color: red; + } + \`; + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('should process `css` calls inside components', async () => { + const { code, metadata } = await transpile( + dedent` + import React from 'react' + import {css} from '@linaria/core' + + export function Component() { + const opacity = 0.2; + const className = css\` + opacity: ${'${opacity}'}; + \`; + + return React.createElement("div", { className }); + } + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('should process `styled` calls inside components', async () => { + const { code, metadata } = await transpile( + dedent` + import React from 'react' + import {css} from '@linaria/core' + + export function Component() { + const opacity = 0.2; + const MyComponent = styled.h1\` + opacity: ${'${opacity}'}; + \`; + + return React.createElement(MyComponent); + } + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('should process `css` calls with complex interpolation inside components', async () => { + const { code, metadata } = await transpile( + dedent` + import React from 'react' + import {css} from '@linaria/core' + import externalDep from '@linaria/babel-preset/__fixtures__/sample-script'; + const globalObj = { + opacity: 0.5, + }; + + export function Component() { + const classes = { + value: 0.2, + cell: css\` + opacity: 0; + \`, + }; + + const classes2 = classes; + const referencedExternalDep = externalDep + + const className = css\` + opacity: ${'${globalObj.opacity}'}; + font-size: ${'${externalDep}'} + font-size: ${'${referencedExternalDep}'} + + &:hover .${'${classes2.cell}'} { + opacity: ${'${classes.value}'}; + } + \`; + + return React.createElement("div", { className }); + } + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('should process `styled` calls with complex interpolation inside components', async () => { + const { code, metadata } = await transpile( + dedent` + import React from 'react' + import {css} from '@linaria/core' + + const globalObj = { + opacity: 0.5, + }; + + const Styled1 = styled.p\` + opacity: ${'${globalObj.opacity}'} + \` + + export function Component() { + const classes = { + value: 0.2, + cell: css\` + opacity: 0; + \`, + }; + + const classes2 = classes; + + const MyComponent = styled\` + opacity: ${'${globalObj.opacity}'}; + + &:hover .${'${classes2.cell}'} { + opacity: ${'${classes.value}'}; + } + ${'${Styled1}'} { + font-size: 1; + } + \`; + + return React.createElement(MyComponent); + } + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('should handle shadowed identifier inside components', async () => { + const { code, metadata } = await transpile( + dedent` + import React from 'react' + import {css} from '@linaria/core' + + const color = 'red'; + + export default function Component() { + const color = 'blue' + const val = { color }; + return React.createElement('div', {className: css\`background-color:${'${val.color}'};\`}); + } + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + it('it should not throw location error for hoisted identifier', async () => { + const { code, metadata } = await transpile( + dedent` + import React from 'react' + import {css} from '@linaria/core' + + const size = () => 5 + export default function Component() { + const color = size() + return css\`opacity:${'${color}'};\` + } + ` + ); + + expect(code).toMatchSnapshot(); + expect(metadata).toMatchSnapshot(); + }); + + strategyDependentTests(transpile); +} diff --git a/@linaria/packages/babel/babel.config.js b/@linaria/packages/babel/babel.config.js new file mode 100644 index 0000000..c9ad680 --- /dev/null +++ b/@linaria/packages/babel/babel.config.js @@ -0,0 +1,3 @@ +const config = require('../../babel.config'); + +module.exports = config; diff --git a/@linaria/packages/babel/package.json b/@linaria/packages/babel/package.json new file mode 100644 index 0000000..a63612c --- /dev/null +++ b/@linaria/packages/babel/package.json @@ -0,0 +1,60 @@ +{ + "name": "@linaria/babel-preset", + "version": "3.0.0-beta.7", + "publishConfig": { + "access": "public" + }, + "description": "Blazing fast zero-runtime CSS in JS library", + "main": "lib/index.js", + "module": "esm/index.js", + "types": "types", + "files": [ + "types/", + "lib/", + "esm/" + ], + "license": "MIT", + "repository": "git@github.com:callstack/linaria.git", + "bugs": { + "url": "https://github.com/callstack/linaria/issues" + }, + "homepage": "https://github.com/callstack/linaria#readme", + "keywords": [ + "react", + "linaria", + "css", + "css-in-js", + "styled-components", + "babel-plugin", + "babel" + ], + "scripts": { + "build:lib": "cross-env NODE_ENV=legacy babel src --out-dir lib --extensions '.js,.jsx,.ts,.tsx' --source-maps --delete-dir-on-start", + "build:esm": "babel src --out-dir esm --extensions '.js,.jsx,.ts,.tsx' --source-maps --delete-dir-on-start", + "build": "yarn build:lib && yarn build:esm", + "build:declarations": "tsc --emitDeclarationOnly --outDir types", + "prepare": "yarn build && yarn build:declarations", + "test": "jest --config ../../jest.config.js --rootDir .", + "typecheck": "tsc --noEmit --composite false", + "watch": "yarn build --watch" + }, + "devDependencies": { + "@types/cosmiconfig": "^5.0.3", + "@types/dedent": "^0.7.0", + "dedent": "^0.7.0", + "strip-ansi": "^5.2.0" + }, + "dependencies": { + "@babel/generator": ">=7", + "@babel/plugin-syntax-dynamic-import": ">=7", + "@babel/template": ">=7", + "@linaria/core": "^3.0.0-beta.4", + "@linaria/logger": "^3.0.0-beta.3", + "cosmiconfig": "^5.1.0", + "source-map": "^0.7.3", + "stylis": "^3.5.4" + }, + "peerDependencies": { + "@babel/core": ">=7" + } +} diff --git a/@linaria/packages/babel/src/babel.ts b/@linaria/packages/babel/src/babel.ts new file mode 100644 index 0000000..d99a54b --- /dev/null +++ b/@linaria/packages/babel/src/babel.ts @@ -0,0 +1,3 @@ +import type core from '@babel/core'; + +export type Core = typeof core; diff --git a/@linaria/packages/babel/src/dynamic-import-noop.ts b/@linaria/packages/babel/src/dynamic-import-noop.ts new file mode 100644 index 0000000..879fdc4 --- /dev/null +++ b/@linaria/packages/babel/src/dynamic-import-noop.ts @@ -0,0 +1,26 @@ +import type { Import } from '@babel/types'; +import type { NodePath } from '@babel/traverse'; +import syntax from '@babel/plugin-syntax-dynamic-import'; +import type { Visitor } from '@babel/traverse'; +import { Core } from './babel'; + +export default function dynamic({ types: t }: Core): { + inherits: any; + visitor: Visitor; +} { + return { + inherits: syntax, + visitor: { + Import(path: NodePath<Import>) { + const noop = t.arrowFunctionExpression([], t.identifier('undefined')); + + path.parentPath.replaceWith( + t.objectExpression([ + t.objectProperty(t.identifier('then'), noop), + t.objectProperty(t.identifier('catch'), noop), + ]) + ); + }, + }, + }; +} diff --git a/@linaria/packages/babel/src/eval-cache.ts b/@linaria/packages/babel/src/eval-cache.ts new file mode 100644 index 0000000..96149a6 --- /dev/null +++ b/@linaria/packages/babel/src/eval-cache.ts @@ -0,0 +1,87 @@ +import { createHash } from 'crypto'; +import { debug } from '@linaria/logger'; + +const fileHashes = new Map<string, string>(); +const evalCache = new Map<string, any>(); +const fileKeys = new Map<string, string[]>(); + +const hash = (text: string) => createHash('sha1').update(text).digest('base64'); + +let lastText: string = ''; +let lastHash: string = hash(lastText); + +const memoizedHash: typeof hash = (text) => { + if (lastText !== text) { + lastHash = hash(text); + lastText = text; + } + + return lastHash; +}; + +const toKey = (filename: string, exports: string[]) => + exports.length > 0 ? `${filename}:${exports.join(',')}` : filename; + +export const clear = () => { + fileHashes.clear(); + evalCache.clear(); + fileKeys.clear(); +}; + +export const clearForFile = (filename: string) => { + const keys = fileKeys.get(filename) ?? []; + if (keys.length === 0) { + return; + } + + debug('eval-cache:clear-for-file', filename); + + for (const key of keys) { + fileHashes.delete(key); + evalCache.delete(key); + } + + fileKeys.set(filename, []); +}; + +export const has = ( + [filename, ...exports]: string[], + text: string +): boolean => { + const key = toKey(filename, exports); + const textHash = memoizedHash(text); + debug('eval-cache:has', `${key} ${textHash}`); + + return fileHashes.get(key) === textHash; +}; + +export const get = ([filename, ...exports]: string[], text: string): any => { + const key = toKey(filename, exports); + const textHash = memoizedHash(text); + debug('eval-cache:get', `${key} ${textHash}`); + + if (fileHashes.get(key) !== textHash) { + return undefined; + } + + return evalCache.get(key); +}; + +export const set = ( + [filename, ...exports]: string[], + text: string, + value: any +): void => { + const key = toKey(filename, exports); + const textHash = memoizedHash(text); + debug('eval-cache:set', `${key} ${textHash}`); + + fileHashes.set(key, textHash); + evalCache.set(key, value); + + if (!fileKeys.has(filename)) { + fileKeys.set(filename, []); + } + + fileKeys.get(filename)!.push(key); +}; diff --git a/@linaria/packages/babel/src/evaluators/buildOptions.ts b/@linaria/packages/babel/src/evaluators/buildOptions.ts new file mode 100644 index 0000000..a938d4b --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/buildOptions.ts @@ -0,0 +1,104 @@ +/** + * This file handles preparing babel config for Linaria preevaluation. + */ + +import type { PluginItem, TransformOptions } from '@babel/core'; +import type { StrictOptions } from '../types'; + +type DefaultOptions = Partial<TransformOptions> & { + plugins: PluginItem[]; + presets: PluginItem[]; + caller: { evaluate: boolean }; +}; + +export default function buildOptions( + filename: string, + options?: StrictOptions +): TransformOptions { + const plugins: Array<string | object> = [ + // Include these plugins to avoid extra config when using { module: false } for webpack + '@babel/plugin-transform-modules-commonjs', + '@babel/plugin-proposal-export-namespace-from', + ]; + + const defaults: DefaultOptions = { + caller: { name: 'linaria', evaluate: true }, + filename: filename, + presets: [ + [ + require.resolve('../index'), + { + ...(options || {}), + }, + ], + ], + plugins: [ + ...plugins.map((name) => require.resolve(name as string)), + // We don't support dynamic imports when evaluating, but don't wanna syntax error + // This will replace dynamic imports with an object that does nothing + require.resolve('../dynamic-import-noop'), + ], + }; + + const babelOptions = + // Shallow copy the babel options because we mutate it later + options?.babelOptions ? { ...options.babelOptions } : {}; + + // If we programmatically pass babel options while there is a .babelrc, babel might throw + // We need to filter out duplicate presets and plugins so that this doesn't happen + // This workaround isn't full proof, but it's still better than nothing + const keys: Array<keyof TransformOptions & ('presets' | 'plugins')> = [ + 'presets', + 'plugins', + ]; + keys.forEach((field) => { + babelOptions[field] = babelOptions[field] + ? babelOptions[field]!.filter((item: PluginItem) => { + // If item is an array it's a preset/plugin with options ([preset, options]) + // Get the first item to get the preset.plugin name + // Otherwise it's a plugin name (can be a function too) + const name = Array.isArray(item) ? item[0] : item; + + if ( + // In our case, a preset might also be referring to linaria/babel + // We require the file from internal path which is not the same one that we export + // This case won't get caught and the preset won't filtered, even if they are same + // So we add an extra check for top level linaria/babel + name === 'linaria/babel' || + name === '@linaria' || + name === '@linaria/babel-preset' || + name === require.resolve('../index') || + // Also add a check for the plugin names we include for bundler support + plugins.includes(name) + ) { + return false; + } + + // Loop through the default presets/plugins to see if it already exists + return !defaults[field].some((it) => + // The default presets/plugins can also have nested arrays, + Array.isArray(it) ? it[0] === name : it === name + ); + }) + : []; + }); + + return { + // Passed options shouldn't be able to override the options we pass + // Linaria's plugins rely on these (such as filename to generate consistent hash) + ...babelOptions, + ...defaults, + presets: [ + // Preset order is last to first, so add the extra presets to start + // This makes sure that our preset is always run first + ...babelOptions.presets!, + ...defaults.presets, + ], + plugins: [ + ...defaults.plugins, + // Plugin order is first to last, so add the extra presets to end + // This makes sure that the plugins we specify always run first + ...babelOptions.plugins!, + ], + }; +} diff --git a/@linaria/packages/babel/src/evaluators/index.ts b/@linaria/packages/babel/src/evaluators/index.ts new file mode 100644 index 0000000..1d24750 --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/index.ts @@ -0,0 +1,22 @@ +/** + * This file is an entry point for module evaluation for getting lazy dependencies. + */ + +import Module from '../module'; +import type { StrictOptions } from '../types'; + +export default function evaluate( + code: string, + filename: string, + options: StrictOptions +) { + const m = new Module(filename, options); + + m.dependencies = []; + m.evaluate(code, ['__linariaPreval']); + + return { + value: m.exports, + dependencies: m.dependencies, + }; +} diff --git a/@linaria/packages/babel/src/evaluators/templateProcessor.ts b/@linaria/packages/babel/src/evaluators/templateProcessor.ts new file mode 100644 index 0000000..0c910d4 --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/templateProcessor.ts @@ -0,0 +1,313 @@ +/** + * This file handles transforming template literals to class names or styled components and generates CSS content. + * It uses CSS code from template literals and evaluated values of lazy dependencies stored in ValueCache. + */ + +import type { Expression } from '@babel/types'; +import generator from '@babel/generator'; + +import type { StyledMeta } from '@linaria/core'; +import { debug } from '@linaria/logger'; +import { units } from '../units'; +import type { + State, + StrictOptions, + TemplateExpression, + ValueCache, +} from '../types'; + +import isSerializable from '../utils/isSerializable'; +import throwIfInvalid from '../utils/throwIfInvalid'; +import stripLines from '../utils/stripLines'; +import toCSS from '../utils/toCSS'; +import getLinariaComment from '../utils/getLinariaComment'; +import { Core } from '../babel'; + +// Match any valid CSS units followed by a separator such as ;, newline etc. +const unitRegex = new RegExp(`^(${units.join('|')})(;|,|\n| |\\))`); + +type Interpolation = { + id: string; + node: Expression; + source: string; + unit: string; +}; + +function hasMeta(value: any): value is StyledMeta { + return value && typeof value === 'object' && (value as any).__linaria; +} + +const processedPaths = new WeakSet(); + +export default function getTemplateProcessor( + { types: t }: Core, + options: StrictOptions +) { + return function process( + { styled, path }: TemplateExpression, + state: State, + valueCache: ValueCache + ) { + if (processedPaths.has(path)) { + // Do not process an expression + // if it is referenced in one template more than once + return; + } + + processedPaths.add(path); + + const { quasi } = path.node; + + const interpolations: Interpolation[] = []; + + // Check if the variable is referenced anywhere for basic DCE + // Only works when it's assigned to a variable + let isReferenced = true; + + const [, slug, displayName, className] = getLinariaComment(path); + + const parent = path.findParent( + (p) => + t.isObjectProperty(p) || + t.isJSXOpeningElement(p) || + t.isVariableDeclarator(p) + ); + + if (parent) { + const parentNode = parent.node; + if (t.isVariableDeclarator(parentNode) && t.isIdentifier(parentNode.id)) { + const { referencePaths } = path.scope.getBinding( + parentNode.id.name + ) || { referencePaths: [] }; + + isReferenced = referencePaths.length !== 0; + } + } + + // Serialize the tagged template literal to a string + let cssText = ''; + + const expressions = path.get('quasi').get('expressions'); + + quasi.quasis.forEach((el, i, self) => { + let appended = false; + + if (i !== 0 && el.value.cooked) { + // Check if previous expression was a CSS variable that we replaced + // If it has a unit after it, we need to move the unit into the interpolation + // e.g. `var(--size)px` should actually be `var(--size)` + // So we check if the current text starts with a unit, and add the unit to the previous interpolation + // Another approach would be `calc(var(--size) * 1px), but some browsers don't support all units + // https://bugzilla.mozilla.org/show_bug.cgi?id=956573 + const matches = el.value.cooked.match(unitRegex); + + if (matches) { + const last = interpolations[interpolations.length - 1]; + const [, unit] = matches; + + if (last && cssText.endsWith(`var(--${last.id})`)) { + last.unit = unit; + cssText += el.value.cooked.replace(unitRegex, '$2'); + appended = true; + } + } + } + + if (!appended) { + cssText += el.value.cooked; + } + + const ex = expressions[i]; + + if (ex && !ex.isExpression()) { + throw ex.buildCodeFrameError( + `The expression '${generator(ex.node).code}' is not supported.` + ); + } + + if (ex) { + const { end } = ex.node.loc!; + const result = ex.evaluate(); + const beforeLength = cssText.length; + + // The location will be end of the current string to start of next string + const next = self[i + 1]; + const loc = { + // +1 because the expressions location always shows 1 column before + start: { line: el.loc!.end.line, column: el.loc!.end.column + 1 }, + end: next + ? { line: next.loc!.start.line, column: next.loc!.start.column } + : { line: end.line, column: end.column + 1 }, + }; + + if (result.confident) { + throwIfInvalid(result.value, ex); + + if (isSerializable(result.value)) { + // If it's a plain object or an array, convert it to a CSS string + cssText += stripLines(loc, toCSS(result.value)); + } else { + cssText += stripLines(loc, result.value); + } + + state.replacements.push({ + original: loc, + length: cssText.length - beforeLength, + }); + } else { + // Try to preval the value + if ( + options.evaluate && + !(t.isFunctionExpression(ex) || t.isArrowFunctionExpression(ex)) + ) { + const value = valueCache.get(ex.node); + throwIfInvalid(value, ex); + + // Skip the blank string instead of throw ing an error + if (value === '') { + return; + } + + if (value && typeof value !== 'function') { + // Only insert text for non functions + // We don't touch functions because they'll be interpolated at runtime + + if (hasMeta(value)) { + // If it's an React component wrapped in styled, get the class name + // Useful for interpolating components + cssText += `.${value.__linaria.className}`; + } else if (isSerializable(value)) { + cssText += stripLines(loc, toCSS(value)); + } else { + // For anything else, assume it'll be stringified + cssText += stripLines(loc, value); + } + + state.replacements.push({ + original: loc, + length: cssText.length - beforeLength, + }); + + return; + } + } + + if (styled) { + const id = `${slug}-${i}`; + + interpolations.push({ + id, + node: ex.node, + source: ex.getSource() || generator(ex.node).code, + unit: '', + }); + + cssText += `var(--${id})`; + } else { + // CSS custom properties can't be used outside components + throw ex.buildCodeFrameError( + `The CSS cannot contain JavaScript expressions when using the 'css' tag. To evaluate the expressions at build time, pass 'evaluate: true' to the babel plugin.` + ); + } + } + } + }); + + let selector = `.${className}`; + + if (styled) { + // If `styled` wraps another component and not a primitive, + // get its class name to create a more specific selector + // it'll ensure that styles are overridden properly + if (options.evaluate && t.isIdentifier(styled.component.node)) { + let value = valueCache.get(styled.component.node.name); + while (hasMeta(value)) { + selector += `.${value.__linaria.className}`; + value = value.__linaria.extends; + } + } + + const props = []; + + props.push( + t.objectProperty(t.identifier('name'), t.stringLiteral(displayName!)) + ); + + props.push( + t.objectProperty(t.identifier('class'), t.stringLiteral(className!)) + ); + + // If we found any interpolations, also pass them so they can be applied + if (interpolations.length) { + // De-duplicate interpolations based on the source and unit + // If two interpolations have the same source code and same unit, + // we don't need to use 2 custom properties for them, we can use a single one + const result: { [key: string]: Interpolation } = {}; + + interpolations.forEach((it) => { + const key = it.source + it.unit; + + if (key in result) { + cssText = cssText.replace( + `var(--${it.id})`, + `var(--${result[key].id})` + ); + } else { + result[key] = it; + } + }); + + props.push( + t.objectProperty( + t.identifier('vars'), + t.objectExpression( + Object.keys(result).map((key) => { + const { id, node, unit } = result[key]; + const items = [node]; + + if (unit) { + items.push(t.stringLiteral(unit)); + } + + return t.objectProperty( + t.stringLiteral(id), + t.arrayExpression(items) + ); + }) + ) + ) + ); + } + + path.replaceWith( + t.callExpression( + t.callExpression( + t.identifier(state.file.metadata.localName || 'styled'), + [styled.component.node] + ), + [t.objectExpression(props)] + ) + ); + + path.addComment('leading', '#__PURE__'); + } else { + path.replaceWith(t.stringLiteral(className!)); + } + + if (!isReferenced && !cssText.includes(':global')) { + return; + } + + debug( + 'evaluator:template-processor:extracted-rule', + `\n${selector} {${cssText}\n}` + ); + + state.rules[selector] = { + cssText, + className: className!, + displayName: displayName!, + start: path.parent?.loc?.start ?? null, + }; + }; +} diff --git a/@linaria/packages/babel/src/evaluators/visitors/JSXElement.ts b/@linaria/packages/babel/src/evaluators/visitors/JSXElement.ts new file mode 100644 index 0000000..89a0ccd --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/visitors/JSXElement.ts @@ -0,0 +1,56 @@ +import { types as t } from '@babel/core'; +import type { NodePath } from '@babel/traverse'; +import type { Function, JSXElement as JSXElementNode } from '@babel/types'; + +function getFunctionName(path: NodePath<Function>): string | null { + if (path.isClassMethod() && t.isIdentifier(path.node.key)) { + return path.node.key.name; + } + + return null; +} + +export default function JSXElement(path: NodePath<JSXElementNode>) { + // JSX can be safely replaced on an empty fragment because it is unnecessary for styles + const emptyFragment = t.jsxFragment( + t.jsxOpeningFragment(), + t.jsxClosingFragment(), + [] + ); + + // We can do even more + // If that JSX is a result of a function, we can replace the function body. + const scopePath = path.scope.path; + if (scopePath.isFunction()) { + const emptyBody = t.blockStatement([t.returnStatement(emptyFragment)]); + + // Is it not just a function, but a method `render`? + if (getFunctionName(scopePath) === 'render') { + const decl = scopePath.findParent((p) => p.isClassDeclaration()); + + // Replace the whole component + if (decl?.isClassDeclaration()) { + decl.replaceWith(t.functionDeclaration(decl.node.id, [], emptyBody)); + + return; + } + } + + const body = scopePath.get('body'); + if (Array.isArray(body)) { + throw new Error( + `A body of a function is expected to be a single element but an array was returned. It's possible if JS syntax has been changed since that code was written.` + ); + } + + const node: typeof scopePath.node = { + ...scopePath.node, + body: emptyBody, + params: [], + }; + + scopePath.replaceWith(node); + } else { + path.replaceWith(emptyFragment); + } +} diff --git a/@linaria/packages/babel/src/evaluators/visitors/ProcessCSS.ts b/@linaria/packages/babel/src/evaluators/visitors/ProcessCSS.ts new file mode 100644 index 0000000..6f737a1 --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/visitors/ProcessCSS.ts @@ -0,0 +1,20 @@ +/** + * This visitor replaces css tag with the generated className + * + */ + +import { types as t } from '@babel/core'; +import type { NodePath } from '@babel/traverse'; +import type { TaggedTemplateExpression } from '@babel/types'; +import getLinariaComment from '../../utils/getLinariaComment'; + +export default function ProcessCSS(path: NodePath<TaggedTemplateExpression>) { + if (t.isIdentifier(path.node.tag) && path.node.tag.name === 'css') { + const [, , , className] = getLinariaComment(path); + if (!className) { + return; + } + + path.replaceWith(t.stringLiteral(className)); + } +} diff --git a/@linaria/packages/babel/src/evaluators/visitors/ProcessStyled.ts b/@linaria/packages/babel/src/evaluators/visitors/ProcessStyled.ts new file mode 100644 index 0000000..a9e38c5 --- /dev/null +++ b/@linaria/packages/babel/src/evaluators/visitors/ProcessStyled.ts @@ -0,0 +1,46 @@ +/** + * This visitor replaces styled components with metadata about them. + * CallExpression should be used to match styled components. + * Works out of the box for styled that wraps other component, + * styled.tagName are transformed to call expressions using @babel/plugin-transform-template-literals + * @babel/plugin-transform-template-literals is loaded as a prest, to force proper ordering. It has to run just after linaria. + * It is used explicitly in extractor, and loaded as a part of `prest-env` in shaker + */ + +import { types as t } from '@babel/core'; +import type { NodePath } from '@babel/traverse'; +import type { CallExpression } from '@babel/types'; +import { expression } from '@babel/template'; +import getLinariaComment from '../../utils/getLinariaComment'; + +const linariaComponentTpl = expression( + `{ + displayName: %%displayName%%, + __linaria: { + className: %%className%%, + extends: %%extends%% + } + }` +); + +export default function ProcessStyled(path: NodePath<CallExpression>) { + const [type, , displayName, className] = getLinariaComment(path); + if (!className) { + return; + } + + if (type === 'css') { + path.replaceWith(t.stringLiteral(className)); + return; + } + + path.replaceWith( + linariaComponentTpl({ + className: t.stringLiteral(className), + displayName: displayName ? t.stringLiteral(displayName) : null, + extends: t.isCallExpression(path.node.callee) + ? path.node.callee.arguments[0] + : t.nullLiteral(), + }) + ); +} diff --git a/@linaria/packages/babel/src/extract.ts b/@linaria/packages/babel/src/extract.ts new file mode 100644 index 0000000..fb2df83 --- /dev/null +++ b/@linaria/packages/babel/src/extract.ts @@ -0,0 +1,222 @@ +/* eslint-disable no-param-reassign */ + +/** + * This is an entry point for styles extraction. + * On enter, It: + * - traverse the code using visitors (TaggedTemplateExpression, ImportDeclaration) + * - schedule evaluation of lazy dependencies (those who are not simple expressions //TODO does they have it's name?) + * - let templateProcessor to save evaluated values in babel state as `replacements`. + * On exit, It: + * - store result of extraction in babel's file metadata + */ + +import type { Node, Program, Expression } from '@babel/types'; +import type { NodePath, Scope, Visitor } from '@babel/traverse'; +import { expression, statement } from '@babel/template'; +import generator from '@babel/generator'; +import { debug, error } from '@linaria/logger'; +import evaluate from './evaluators'; +import getTemplateProcessor from './evaluators/templateProcessor'; +import Module from './module'; +import type { + State, + StrictOptions, + LazyValue, + ExpressionValue, + ValueCache, +} from './types'; +import { ValueType } from './types'; +import CollectDependencies from './visitors/CollectDependencies'; +import DetectStyledImportName from './visitors/DetectStyledImportName'; +import GenerateClassNames from './visitors/GenerateClassNames'; +import type { Core } from './babel'; + +function isLazyValue(v: ExpressionValue): v is LazyValue { + return v.kind === ValueType.LAZY; +} + +function isNodePath<T extends Node>(obj: NodePath<T> | T): obj is NodePath<T> { + return 'node' in obj && obj?.node !== undefined; +} + +function findFreeName(scope: Scope, name: string): string { + // By default `name` is used as a name of the function … + let nextName = name; + let idx = 0; + while (scope.hasBinding(nextName, false)) { + // … but if there is an already defined variable with this name … + // … we are trying to use a name like wrap_N + idx += 1; + nextName = `wrap_${idx}`; + } + + return nextName; +} + +function unwrapNode<T extends Node>( + item: NodePath<T> | T | string +): T | string { + if (typeof item === 'string') { + return item; + } + + return isNodePath(item) ? item.node : item; +} + +// All exported values will be wrapped with this function +const expressionWrapperTpl = statement(` + const %%wrapName%% = (fn) => { + try { + return fn(); + } catch (e) { + return e; + } + }; +`); + +const expressionTpl = expression(`%%wrapName%%(() => %%expression%%)`); +const exportsLinariaPrevalTpl = statement( + `exports.__linariaPreval = %%expressions%%` +); + +function addLinariaPreval( + { types: t }: Core, + path: NodePath<Program>, + lazyDeps: Array<Expression | string> +): Program { + // Constant __linariaPreval with all dependencies + const wrapName = findFreeName(path.scope, '_wrap'); + const statements = [ + expressionWrapperTpl({ wrapName }), + exportsLinariaPrevalTpl({ + expressions: t.arrayExpression( + lazyDeps.map((expression) => expressionTpl({ expression, wrapName })) + ), + }), + ]; + + const programNode = path.node; + return t.program( + [...programNode.body, ...statements], + programNode.directives, + programNode.sourceType, + programNode.interpreter + ); +} + +export default function extract( + babel: Core, + options: StrictOptions +): { visitor: Visitor<State> } { + const process = getTemplateProcessor(babel, options); + + return { + visitor: { + Program: { + enter(path: NodePath<Program>, state: State) { + // Collect all the style rules from the styles we encounter + state.queue = []; + state.rules = {}; + state.index = -1; + state.dependencies = []; + state.replacements = []; + debug('extraction:start', state.file.opts.filename); + + // Invalidate cache for module evaluation to get fresh modules + Module.invalidate(); + + // We need our transforms to run before anything else + // So we traverse here instead of a in a visitor + path.traverse({ + ImportDeclaration: (p) => DetectStyledImportName(babel, p, state), + TaggedTemplateExpression: (p) => { + GenerateClassNames(babel, p, state, options); + CollectDependencies(babel, p, state, options); + }, + }); + + const lazyDeps = state.queue.reduce( + (acc, { expressionValues: values }) => { + acc.push(...values.filter(isLazyValue)); + return acc; + }, + [] as LazyValue[] + ); + + const expressionsToEvaluate = lazyDeps.map((v) => unwrapNode(v.ex)); + const originalLazyExpressions = lazyDeps.map((v) => + unwrapNode(v.originalEx) + ); + + debug('lazy-deps:count', lazyDeps.length); + + let lazyValues: any[] = []; + + if (expressionsToEvaluate.length > 0) { + debug( + 'lazy-deps:original-expressions-list', + originalLazyExpressions.map((node) => + typeof node !== 'string' ? generator(node).code : node + ) + ); + debug( + 'lazy-deps:expressions-to-eval-list', + expressionsToEvaluate.map((node) => + typeof node !== 'string' ? generator(node).code : node + ) + ); + + const program = addLinariaPreval( + babel, + path, + expressionsToEvaluate + ); + const { code } = generator(program); + debug('lazy-deps:evaluate', ''); + try { + const evaluation = evaluate( + code, + state.file.opts.filename, + options + ); + debug('lazy-deps:sub-files', evaluation.dependencies); + + state.dependencies.push(...evaluation.dependencies); + lazyValues = evaluation.value.__linariaPreval || []; + debug('lazy-deps:values', evaluation.value.__linariaPreval); + } catch (e) { + error('lazy-deps:evaluate', code); + throw new Error( + 'An unexpected runtime error occurred during dependencies evaluation: \n' + + e.stack + + '\n\nIt may happen when your code or third party module is invalid or uses identifiers not available in Node environment, eg. window. \n' + + 'Note that line numbers in above stack trace will most likely not match, because Linaria needed to transform your code a bit.\n' + ); + } + } + + const valueCache: ValueCache = new Map(); + originalLazyExpressions.forEach((key, idx) => + valueCache.set(key, lazyValues[idx]) + ); + state.queue.forEach((item) => process(item, state, valueCache)); + }, + exit(_: any, state: State) { + if (Object.keys(state.rules).length) { + // Store the result as the file metadata under linaria key + state.file.metadata.linaria = { + rules: state.rules, + replacements: state.replacements, + dependencies: state.dependencies, + }; + } + + // Invalidate cache for module evaluation when we're done + Module.invalidate(); + + debug('extraction:end', state.file.opts.filename); + }, + }, + }, + }; +} diff --git a/@linaria/packages/babel/src/index.ts b/@linaria/packages/babel/src/index.ts new file mode 100644 index 0000000..048fe58 --- /dev/null +++ b/@linaria/packages/babel/src/index.ts @@ -0,0 +1,45 @@ +/** + * File defines babel prest for Linaria. + * It uses ./extract function that is an entry point for styles extraction. + * It also bypass babel options defined in Linaria config file with it's defaults (see ./utils/loadOptions). + */ +import type { ConfigAPI, TransformCaller } from '@babel/core'; + +import { debug } from '@linaria/logger'; +import type { PluginOptions } from './utils/loadOptions'; +import loadOptions from './utils/loadOptions'; + +export * as EvalCache from './eval-cache'; +export { default as buildOptions } from './evaluators/buildOptions'; +export { default as JSXElement } from './evaluators/visitors/JSXElement'; +export { default as ProcessCSS } from './evaluators/visitors/ProcessCSS'; +export { default as ProcessStyled } from './evaluators/visitors/ProcessStyled'; +export { default as Module } from './module'; +export { + default as transform, + extractCssFromAst, + shouldTransformCode, +} from './transform'; +export * from './types'; +export type { PluginOptions } from './utils/loadOptions'; +export { default as isNode } from './utils/isNode'; +export { default as getVisitorKeys } from './utils/getVisitorKeys'; +export { default as peek } from './utils/peek'; +export { default as slugify } from './utils/slugify'; +export { default as CollectDependencies } from './visitors/CollectDependencies'; +export { default as DetectStyledImportName } from './visitors/DetectStyledImportName'; +export { default as GenerateClassNames } from './visitors/GenerateClassNames'; + +function isEnabled(caller?: TransformCaller & { evaluate?: true }) { + return caller?.name !== 'linaria' || !caller.evaluate; +} + +export default function linaria(babel: ConfigAPI, options: PluginOptions) { + if (!babel.caller(isEnabled)) { + return {}; + } + debug('options', JSON.stringify(options)); + return { + plugins: [[require('./extract'), loadOptions(options)]], + }; +} diff --git a/@linaria/packages/babel/src/module.ts b/@linaria/packages/babel/src/module.ts new file mode 100644 index 0000000..7893812 --- /dev/null +++ b/@linaria/packages/babel/src/module.ts @@ -0,0 +1,373 @@ +/** + * This is a custom implementation for the module system for evaluating code, + * used for resolving values for dependencies interpolated in `css` or `styled`. + * + * This serves 2 purposes: + * - Avoid leakage from evaluated code to module cache in current context, e.g. `babel-register` + * - Allow us to invalidate the module cache without affecting other stuff, necessary for rebuilds + * + * We also use it to transpile the code with Babel by default. + * We also store source maps for it to provide correct error stacktraces. + * + */ + +import NativeModule from 'module'; +import vm from 'vm'; +import fs from 'fs'; +import path from 'path'; +import type { BabelFileResult } from '@babel/core'; +import { debug } from '@linaria/logger'; +import * as EvalCache from './eval-cache'; +import * as process from './process'; +import type { Evaluator, StrictOptions } from './types'; + +// Supported node builtins based on the modules polyfilled by webpack +// `true` means module is polyfilled, `false` means module is empty +const builtins = { + assert: true, + buffer: true, + child_process: false, + cluster: false, + console: true, + constants: true, + crypto: true, + dgram: false, + dns: false, + domain: true, + events: true, + fs: false, + http: true, + https: true, + module: false, + net: false, + os: true, + path: true, + punycode: true, + process: true, + querystring: true, + readline: false, + repl: false, + stream: true, + string_decoder: true, + sys: true, + timers: true, + tls: false, + tty: true, + url: true, + util: true, + vm: true, + zlib: true, +}; + +// Separate cache for evaluated modules +let cache: { [id: string]: Module } = {}; + +const NOOP = () => {}; + +const createCustomDebug = + (depth: number) => + (..._args: Parameters<typeof debug>) => { + const [namespaces, arg1, ...args] = _args; + const modulePrefix = depth === 0 ? 'module' : `sub-module-${depth}`; + debug(`${modulePrefix}:${namespaces}`, arg1, ...args); + }; + +const cookModuleId = (rawId: string) => { + // It's a dirty hack for avoiding conflicts with babel-preset-react-app + // https://github.com/callstack/linaria/issues/745 + // FIXME @Anber: I'll try to figure out a better solution. Probably, using Terser as a shaker's core can solve problems with interfered plugins. + return rawId.replace( + '/@babel/runtime/helpers/esm/', + '/@babel/runtime/helpers/' + ); +}; + +class Module { + static invalidate: () => void; + static invalidateEvalCache: () => void; + static _resolveFilename: ( + id: string, + options: { id: string; filename: string; paths: string[] } + ) => string; + static _nodeModulePaths: (filename: string) => string[]; + + id: string; + filename: string; + options: StrictOptions; + imports: Map<string, string[]> | null; + paths: string[]; + exports: any; + extensions: string[]; + dependencies: string[] | null; + transform: ((text: string) => BabelFileResult | null) | null; + debug: typeof debug; + debuggerDepth: number; + + constructor( + filename: string, + options: StrictOptions, + debuggerDepth: number = 0 + ) { + this.id = filename; + this.filename = filename; + this.options = options; + this.imports = null; + this.paths = []; + this.dependencies = null; + this.transform = null; + this.debug = createCustomDebug(debuggerDepth); + this.debuggerDepth = debuggerDepth; + + Object.defineProperties(this, { + id: { + value: filename, + writable: false, + }, + filename: { + value: filename, + writable: false, + }, + paths: { + value: Object.freeze( + ( + NativeModule as unknown as { + _nodeModulePaths(filename: string): string[]; + } + )._nodeModulePaths(path.dirname(filename)) + ), + writable: false, + }, + }); + + this.exports = {}; + + // We support following extensions by default + this.extensions = ['.json', '.js', '.jsx', '.ts', '.tsx']; + this.debug('prepare', filename); + } + + resolve = (rawId: string) => { + const id = cookModuleId(rawId); + const extensions = ( + NativeModule as unknown as { + _extensions: { [key: string]: Function }; + } + )._extensions; + const added: string[] = []; + + try { + // Check for supported extensions + this.extensions.forEach((ext) => { + if (ext in extensions) { + return; + } + + // When an extension is not supported, add it + // And keep track of it to clean it up after resolving + // Use noop for the transform function since we handle it + extensions[ext] = NOOP; + added.push(ext); + }); + + return Module._resolveFilename(id, this); + } finally { + // Cleanup the extensions we added to restore previous behaviour + added.forEach((ext) => delete extensions[ext]); + } + }; + + require: { + (id: string): any; + resolve: (id: string) => string; + ensure: () => void; + cache: typeof cache; + } = Object.assign( + (rawId: string) => { + const id = cookModuleId(rawId); + this.debug('require', id); + if (id in builtins) { + // The module is in the allowed list of builtin node modules + // Ideally we should prevent importing them, but webpack polyfills some + // So we check for the list of polyfills to determine which ones to support + if (builtins[id as keyof typeof builtins]) { + return require(id); + } + + return null; + } + + // Resolve module id (and filename) relatively to parent module + const filename = this.resolve(id); + if (filename === id && !path.isAbsolute(id)) { + // The module is a builtin node modules, but not in the allowed list + throw new Error( + `Unable to import "${id}". Importing Node builtins is not supported in the sandbox.` + ); + } + + this.dependencies?.push(id); + + let cacheKey = filename; + let only: string[] = []; + if (this.imports?.has(id)) { + // We know what exactly we need from this module. Let's shake it! + only = this.imports.get(id)!.sort(); + if (only.length === 0) { + // Probably the module is used as a value itself + // like `'The answer is ' + require('./module')` + only = ['default']; + } + + cacheKey += `:${only.join(',')}`; + } + + let m = cache[cacheKey]; + + if (!m) { + this.debug('cached:not-exist', id); + // Create the module if cached module is not available + m = new Module(filename, this.options, this.debuggerDepth + 1); + m.transform = this.transform; + + // Store it in cache at this point with, otherwise + // we would end up in infinite loop with cyclic dependencies + cache[cacheKey] = m; + + if (this.extensions.includes(path.extname(filename))) { + // To evaluate the file, we need to read it first + const code = fs.readFileSync(filename, 'utf-8'); + if (/\.json$/.test(filename)) { + // For JSON files, parse it to a JS object similar to Node + m.exports = JSON.parse(code); + } else { + // For JS/TS files, evaluate the module + // The module will be transpiled using provided transform + m.evaluate(code, only.includes('*') ? null : only); + } + } else { + // For non JS/JSON requires, just export the id + // This is to support importing assets in webpack + // The module will be resolved by css-loader + m.exports = id; + } + } else { + this.debug('cached:exist', id); + } + + return m.exports; + }, + { + ensure: NOOP, + cache, + resolve: this.resolve, + } + ); + + evaluate(text: string, only: string[] | null = null) { + const filename = this.filename; + const matchedRules = this.options.rules + .filter(({ test }) => { + if (!test) { + return true; + } + + if (typeof test === 'function') { + // this is not a test + // eslint-disable-next-line jest/no-disabled-tests + return test(filename); + } + + if (test instanceof RegExp) { + return test.test(filename); + } + + return false; + }) + .reverse(); + + const cacheKey = [this.filename, ...(only ?? [])]; + + if (EvalCache.has(cacheKey, text)) { + this.exports = EvalCache.get(cacheKey, text); + return; + } + + let code: string | null | undefined; + const action = matchedRules.length > 0 ? matchedRules[0].action : 'ignore'; + if (action === 'ignore') { + this.debug('ignore', `${filename}`); + code = text; + } else { + // Action can be a function or a module name + const evaluator: Evaluator = + typeof action === 'function' ? action : require(action).default; + + // For JavaScript files, we need to transpile it and to get the exports of the module + let imports: Module['imports']; + + this.debug('prepare-evaluation', this.filename, 'using', evaluator.name); + + [code, imports] = evaluator(this.filename, this.options, text, only); + this.imports = imports; + + this.debug( + 'evaluate', + `${this.filename} (only ${(only || []).join(', ')}):\n${code}` + ); + } + + const script = new vm.Script( + `(function (exports) { ${code}\n})(exports);`, + { + filename: this.filename, + } + ); + + script.runInContext( + vm.createContext({ + clearImmediate: NOOP, + clearInterval: NOOP, + clearTimeout: NOOP, + setImmediate: NOOP, + setInterval: NOOP, + setTimeout: NOOP, + global, + process, + module: this, + exports: this.exports, + require: this.require, + __filename: this.filename, + __dirname: path.dirname(this.filename), + }) + ); + + EvalCache.set(cacheKey, text, this.exports); + } +} + +Module.invalidate = () => { + cache = {}; +}; + +Module.invalidateEvalCache = () => { + EvalCache.clear(); +}; + +// Alias to resolve the module using node's resolve algorithm +// This static property can be overriden by the webpack loader +// This allows us to use webpack's module resolution algorithm +Module._resolveFilename = (id, options) => + ( + NativeModule as unknown as { + _resolveFilename: (id: string, options: any) => string; + } + )._resolveFilename(id, options); + +Module._nodeModulePaths = (filename: string) => + ( + NativeModule as unknown as { + _nodeModulePaths: (filename: string) => string[]; + } + )._nodeModulePaths(filename); + +export default Module; diff --git a/@linaria/packages/babel/src/plugin-syntax-dynamic-import.d.ts b/@linaria/packages/babel/src/plugin-syntax-dynamic-import.d.ts new file mode 100644 index 0000000..b7ddeed --- /dev/null +++ b/@linaria/packages/babel/src/plugin-syntax-dynamic-import.d.ts @@ -0,0 +1,4 @@ +declare module '@babel/plugin-syntax-dynamic-import' { + const syntax: {}; + export default syntax; +} diff --git a/@linaria/packages/babel/src/process.ts b/@linaria/packages/babel/src/process.ts new file mode 100644 index 0000000..2553577 --- /dev/null +++ b/@linaria/packages/babel/src/process.ts @@ -0,0 +1,31 @@ +/** + * It contains API for mocked process variable available in node environment used to evaluate scripts with node's `vm` in ./module.ts + */ +export const nextTick = (fn: Function) => setTimeout(fn, 0); + +export const platform = 'browser'; +export const arch = 'browser'; +export const execPath = 'browser'; +export const title = 'browser'; +export const pid = 1; +export const browser = true; +export const argv = []; + +export const binding = function binding() { + throw new Error('No such module. (Possibly not yet loaded)'); +}; + +export const cwd = () => '/'; + +const noop = () => {}; +export const exit = noop; +export const kill = noop; +export const chdir = noop; +export const umask = noop; +export const dlopen = noop; +export const uptime = noop; +export const memoryUsage = noop; +export const uvCounters = noop; +export const features = {}; + +export const env = process.env; diff --git a/@linaria/packages/babel/src/transform.ts b/@linaria/packages/babel/src/transform.ts new file mode 100644 index 0000000..cfc0ea6 --- /dev/null +++ b/@linaria/packages/babel/src/transform.ts @@ -0,0 +1,187 @@ +/** + * This file exposes transform function that: + * - parse the passed code to AST + * - transforms the AST using Linaria babel preset ('./babel/index.js) and additional config defined in Linaria config file or passed to bundler configuration. + * - runs generated CSS files through default of user-defined preprocessor + * - generates source maps for CSS files + * - return transformed code (without Linaria template literals), generated CSS, source maps and babel metadata from transform step. + */ + +import path from 'path'; +import type { BabelFileMetadata, BabelFileResult } from '@babel/core'; +import { parseSync, transformFromAstSync } from '@babel/core'; +import stylis from 'stylis'; +import type { Mapping } from 'source-map'; +import { SourceMapGenerator } from 'source-map'; +import { debug } from '@linaria/logger'; +import loadOptions from './utils/loadOptions'; +import type { LinariaMetadata, Options, PreprocessorFn, Result } from './types'; + +const STYLIS_DECLARATION = 1; +const posixSep = path.posix.sep; +const babelPreset = require.resolve('./index'); + +export function transformUrl( + url: string, + outputFilename: string, + sourceFilename: string, + platformPath: typeof path = path +) { + // Replace asset path with new path relative to the output CSS + const relative = platformPath.relative( + platformPath.dirname(outputFilename), + // Get the absolute path to the asset from the path relative to the JS file + platformPath.resolve(platformPath.dirname(sourceFilename), url) + ); + + if (platformPath.sep === posixSep) { + return relative; + } + + return relative.split(platformPath.sep).join(posixSep); +} + +export function shouldTransformCode(code: string): boolean { + return /\b(styled|css)/.test(code); +} + +export function extractCssFromAst( + babelFileResult: BabelFileResult, + code: string, + options: Options +): Result { + const { metadata, code: transformedCode, map } = babelFileResult; + + if ( + !metadata || + !(metadata as BabelFileMetadata & { linaria: LinariaMetadata }).linaria + ) { + return { + code: transformedCode || '', // if there was only unused code we want to return transformed code which will be later removed by the bundler + sourceMap: map, + }; + } + + const { rules, replacements, dependencies } = ( + metadata as BabelFileMetadata & { + linaria: LinariaMetadata; + } + ).linaria; + const mappings: Mapping[] = []; + + let cssText = ''; + + let preprocessor: PreprocessorFn; + if (typeof options.preprocessor === 'function') { + // eslint-disable-next-line prefer-destructuring + preprocessor = options.preprocessor; + } else { + switch (options.preprocessor) { + case 'none': + preprocessor = (selector, text) => `${selector} {${text}}\n`; + break; + case 'stylis': + default: + stylis.use(null)((context, decl) => { + const { outputFilename } = options; + if (context === STYLIS_DECLARATION && outputFilename) { + // When writing to a file, we need to adjust the relative paths inside url(..) expressions + // It'll allow css-loader to resolve an imported asset properly + return decl.replace( + /\b(url\((["']?))(\.[^)]+?)(\2\))/g, + (match, p1, p2, p3, p4) => + p1 + transformUrl(p3, outputFilename, options.filename) + p4 + ); + } + + return decl; + }); + + preprocessor = stylis; + } + } + + Object.keys(rules).forEach((selector, index) => { + mappings.push({ + generated: { + line: index + 1, + column: 0, + }, + original: rules[selector].start!, + name: selector, + source: '', + }); + + // Run each rule through stylis to support nesting + cssText += `${preprocessor(selector, rules[selector].cssText)}\n`; + }); + + return { + code: transformedCode || '', + cssText, + rules, + replacements, + dependencies, + sourceMap: map, + + get cssSourceMapText() { + if (mappings?.length) { + const generator = new SourceMapGenerator({ + file: options.filename.replace(/\.js$/, '.css'), + }); + + mappings.forEach((mapping) => + generator.addMapping( + Object.assign({}, mapping, { source: options.filename }) + ) + ); + + generator.setSourceContent(options.filename, code); + + return generator.toString(); + } + + return ''; + }, + }; +} + +export default function transform(code: string, options: Options): Result { + // Check if the file contains `css` or `styled` words first + // Otherwise we should skip transforming + if (!shouldTransformCode(code)) { + return { + code, + sourceMap: options.inputSourceMap, + }; + } + + debug( + 'transform', + `${options.filename} to ${options.outputFilename}\n${code}` + ); + + const pluginOptions = loadOptions(options.pluginOptions); + const babelOptions = pluginOptions?.babelOptions ?? null; + + // Parse the code first so babel uses user's babel config for parsing + // We don't want to use user's config when transforming the code + const ast = parseSync(code, { + ...babelOptions, + filename: options.filename, + caller: { name: 'linaria' }, + }); + + const babelFileResult = transformFromAstSync(ast!, code, { + ...(babelOptions?.rootMode ? { rootMode: babelOptions.rootMode } : null), + filename: options.filename, + presets: [[babelPreset, pluginOptions]], + babelrc: false, + configFile: false, + sourceMaps: true, + sourceFileName: options.filename, + inputSourceMap: options.inputSourceMap, + })!; + + return extractCssFromAst(babelFileResult, code, options); +} diff --git a/@linaria/packages/babel/src/types.ts b/@linaria/packages/babel/src/types.ts new file mode 100644 index 0000000..bee9a47 --- /dev/null +++ b/@linaria/packages/babel/src/types.ts @@ -0,0 +1,180 @@ +import type { Node, Expression, TaggedTemplateExpression } from '@babel/types'; +import type { TransformOptions } from '@babel/core'; +import type { NodePath } from '@babel/traverse'; +import type { StyledMeta } from '@linaria/core'; +import type { RawSourceMap } from 'source-map'; +import type { PluginOptions } from './utils/loadOptions'; + +export type JSONValue = string | number | boolean | JSONObject | JSONArray; + +export interface JSONObject { + [x: string]: JSONValue; +} + +export interface JSONArray extends Array<JSONValue> {} + +export type Serializable = JSONArray | JSONObject; + +export enum ValueType { + COMPONENT, + LAZY, + FUNCTION, + VALUE, +} + +export type Value = Function | StyledMeta | string | number; + +export type ValueCache = Map<Expression | string, Value>; + +export type ComponentValue = { + kind: ValueType.COMPONENT; + ex: NodePath<Expression> | Expression | string; +}; + +export type LazyValue = { + kind: ValueType.LAZY; + ex: NodePath<Expression> | Expression | string; + originalEx: NodePath<Expression> | Expression | string; +}; + +export type FunctionValue = { + kind: ValueType.FUNCTION; + ex: any; +}; + +export type EvaluatedValue = { + kind: ValueType.VALUE; + value: Value; +}; + +export type ExpressionValue = + | ComponentValue + | LazyValue + | FunctionValue + | EvaluatedValue; + +export type TemplateExpression = { + styled?: { component: any }; + path: NodePath<TaggedTemplateExpression>; + expressionValues: ExpressionValue[]; +}; + +type Rules = { + [selector: string]: { + className: string; + displayName: string; + cssText: string; + start: Location | null | undefined; + }; +}; + +type Replacements = Array<{ + original: { + start: Location; + end: Location; + }; + length: number; +}>; + +type Dependencies = string[]; + +export type State = { + queue: TemplateExpression[]; + rules: Rules; + replacements: Replacements; + index: number; + dependencies: Dependencies; + file: { + opts: { + cwd: string; + root: string; + filename: string; + }; + metadata: { + localName?: string; + linaria?: { + rules: Rules; + replacements: Replacements; + dependencies: Dependencies; + }; + }; + }; +}; + +export type Evaluator = ( + filename: string, + options: StrictOptions, + text: string, + only: string[] | null +) => [string, Map<string, string[]> | null]; + +export type EvalRule = { + test?: RegExp | ((path: string) => boolean); + action: Evaluator | 'ignore' | string; +}; + +type ClassNameFn = (hash: string, title: string) => string; + +export type StrictOptions = { + classNameSlug?: string | ClassNameFn; + displayName: boolean; + evaluate: boolean; + ignore?: RegExp; + babelOptions: TransformOptions; + rules: EvalRule[]; +}; + +export type Location = { + line: number; + column: number; +}; + +export type Replacement = { + original: { start: Location; end: Location }; + length: number; +}; + +export type Result = { + code: string; + sourceMap?: RawSourceMap | null; + cssText?: string; + cssSourceMapText?: string; + dependencies?: string[]; + rules?: Rules; + replacements?: Replacement[]; +}; + +export type LinariaMetadata = { + rules: Rules; + replacements: Replacement[]; + dependencies: string[]; +}; + +export type Options = { + filename: string; + preprocessor?: Preprocessor; + outputFilename?: string; + inputSourceMap?: RawSourceMap; + pluginOptions?: Partial<PluginOptions>; +}; + +export type PreprocessorFn = (selector: string, cssText: string) => string; +export type Preprocessor = 'none' | 'stylis' | PreprocessorFn | void; + +type AllNodes = { [T in Node['type']]: Extract<Node, { type: T }> }; + +declare module '@babel/types' { + type VisitorKeys = { + [T in keyof AllNodes]: Extract< + keyof AllNodes[T], + { + [Key in keyof AllNodes[T]]: AllNodes[T][Key] extends + | Node + | Node[] + | null + ? Key + : never; + }[keyof AllNodes[T]] + >; + }; +} diff --git a/@linaria/packages/babel/src/units.ts b/@linaria/packages/babel/src/units.ts new file mode 100644 index 0000000..7ac148a --- /dev/null +++ b/@linaria/packages/babel/src/units.ts @@ -0,0 +1,101 @@ +// https://www.w3.org/TR/css-values-4/ +export const units = [ + // font relative lengths + 'em', + 'ex', + 'cap', + 'ch', + 'ic', + 'rem', + 'lh', + 'rlh', + + // viewport percentage lengths + 'vw', + 'vh', + 'vi', + 'vb', + 'vmin', + 'vmax', + + // absolute lengths + 'cm', + 'mm', + 'Q', + 'in', + 'pc', + 'pt', + 'px', + + // angle units + 'deg', + 'grad', + 'rad', + 'turn', + + // duration units + 's', + 'ms', + + // frequency units + 'Hz', + 'kHz', + + // resolution units + 'dpi', + 'dpcm', + 'dppx', + 'x', + + // https://www.w3.org/TR/css-grid-1/#fr-unit + 'fr', + + // percentages + '%', +]; + +export const unitless = { + animationIterationCount: true, + borderImageOutset: true, + borderImageSlice: true, + borderImageWidth: true, + boxFlex: true, + boxFlexGroup: true, + boxOrdinalGroup: true, + columnCount: true, + columns: true, + flex: true, + flexGrow: true, + flexPositive: true, + flexShrink: true, + flexNegative: true, + flexOrder: true, + gridRow: true, + gridRowEnd: true, + gridRowSpan: true, + gridRowStart: true, + gridColumn: true, + gridColumnEnd: true, + gridColumnSpan: true, + gridColumnStart: true, + fontWeight: true, + lineClamp: true, + lineHeight: true, + opacity: true, + order: true, + orphans: true, + tabSize: true, + widows: true, + zIndex: true, + zoom: true, + + // SVG-related properties + fillOpacity: true, + floodOpacity: true, + stopOpacity: true, + strokeDasharray: true, + strokeDashoffset: true, + strokeMiterlimit: true, + strokeOpacity: true, + strokeWidth: true, +}; diff --git a/@linaria/packages/babel/src/utils/getLinariaComment.ts b/@linaria/packages/babel/src/utils/getLinariaComment.ts new file mode 100644 index 0000000..06b3edb --- /dev/null +++ b/@linaria/packages/babel/src/utils/getLinariaComment.ts @@ -0,0 +1,31 @@ +import type { Node } from '@babel/types'; + +const pattern = /^linaria (css|styled) (.+)$/; + +export default function getLinariaComment( + path: { node: Node }, + remove: boolean = true +): ['css' | 'styled' | null, ...(string | null)[]] { + const comments = path.node.leadingComments; + if (!comments) { + return [null, null, null, null]; + } + + const idx = comments.findIndex((comment) => pattern.test(comment.value)); + if (idx === -1) { + return [null, null, null, null]; + } + + const matched = comments[idx].value.match(pattern); + if (!matched) { + return [null, null, null, null]; + } + + if (remove) { + path.node.leadingComments = comments.filter((_, i) => i !== idx); + } + + const type = matched[1] === 'css' ? 'css' : 'styled'; + + return [type, ...matched[2].split(' ').map((i) => (i ? i : null))]; +} diff --git a/@linaria/packages/babel/src/utils/getVisitorKeys.ts b/@linaria/packages/babel/src/utils/getVisitorKeys.ts new file mode 100644 index 0000000..72f11f8 --- /dev/null +++ b/@linaria/packages/babel/src/utils/getVisitorKeys.ts @@ -0,0 +1,10 @@ +import { types as t } from '@babel/core'; +import type { Node, VisitorKeys } from '@babel/types'; + +type Keys<T extends Node> = (VisitorKeys[T['type']] & keyof T)[]; + +export default function getVisitorKeys<TNode extends Node>( + node: TNode +): Keys<TNode> { + return t.VISITOR_KEYS[node.type] as Keys<TNode>; +} diff --git a/@linaria/packages/babel/src/utils/hasImport.ts b/@linaria/packages/babel/src/utils/hasImport.ts new file mode 100644 index 0000000..39c1f07 --- /dev/null +++ b/@linaria/packages/babel/src/utils/hasImport.ts @@ -0,0 +1,83 @@ +import { dirname } from 'path'; +import Module from '../module'; + +const linariaLibs = new Set([ + '@linaria/core', + '@linaria/react', + 'linaria', + 'linaria/react', +]); + +const safeResolve = (name: string) => { + try { + return require.resolve(name); + } catch (err) { + return null; + } +}; + +// Verify if the binding is imported from the specified source +export default function hasImport( + t: any, + scope: any, + filename: string, + identifier: string, + sources: string[] +): boolean { + const binding = scope.getAllBindings()[identifier]; + + if (!binding) { + return false; + } + + const p = binding.path; + + const resolveFromFile = (id: string) => { + try { + return Module._resolveFilename(id, { + id: filename, + filename, + paths: Module._nodeModulePaths(dirname(filename)), + }); + } catch (e) { + return null; + } + }; + + const isImportingModule = (value: string) => + sources.some( + (source) => + // If the value is an exact match, assume it imports the module + value === source || + // Otherwise try to resolve both and check if they are the same file + resolveFromFile(value) === + (linariaLibs.has(source) + ? safeResolve(source) + : resolveFromFile(source)) + ); + + if (t.isImportSpecifier(p) && t.isImportDeclaration(p.parentPath)) { + return isImportingModule(p.parentPath.node.source.value); + } + + if (t.isVariableDeclarator(p)) { + if ( + t.isCallExpression(p.node.init) && + t.isIdentifier(p.node.init.callee) && + p.node.init.callee.name === 'require' && + p.node.init.arguments.length === 1 + ) { + const node = p.node.init.arguments[0]; + + if (t.isStringLiteral(node)) { + return isImportingModule(node.value); + } + + if (t.isTemplateLiteral(node) && node.quasis.length === 1) { + return isImportingModule(node.quasis[0].value.cooked); + } + } + } + + return false; +} diff --git a/@linaria/packages/babel/src/utils/isBoxedPrimitive.ts b/@linaria/packages/babel/src/utils/isBoxedPrimitive.ts new file mode 100644 index 0000000..2d20eec --- /dev/null +++ b/@linaria/packages/babel/src/utils/isBoxedPrimitive.ts @@ -0,0 +1,10 @@ +// There is a problem with using boxed numbers and strings in TS, +// so we cannot just use `instanceof` here + +const constructors = ['Number', 'String']; +export default function isBoxedPrimitive(o: any): o is Number | String { + return ( + constructors.includes(o.constructor.name) && + typeof o?.valueOf() !== 'object' + ); +} diff --git a/@linaria/packages/babel/src/utils/isNode.ts b/@linaria/packages/babel/src/utils/isNode.ts new file mode 100644 index 0000000..2340ef9 --- /dev/null +++ b/@linaria/packages/babel/src/utils/isNode.ts @@ -0,0 +1,5 @@ +import type { Node } from '@babel/types'; + +const isNode = (obj: any): obj is Node => obj?.type !== undefined; + +export default isNode; diff --git a/@linaria/packages/babel/src/utils/isSerializable.ts b/@linaria/packages/babel/src/utils/isSerializable.ts new file mode 100644 index 0000000..1f65d58 --- /dev/null +++ b/@linaria/packages/babel/src/utils/isSerializable.ts @@ -0,0 +1,11 @@ +import type { Serializable } from '../types'; +import isBoxedPrimitive from './isBoxedPrimitive'; + +export default function isSerializable(o: any): o is Serializable { + return ( + (Array.isArray(o) && o.every(isSerializable)) || + (typeof o === 'object' && + o !== null && + (o.constructor.name === 'Object' || isBoxedPrimitive(o))) + ); +} diff --git a/@linaria/packages/babel/src/utils/isStyledOrCss.ts b/@linaria/packages/babel/src/utils/isStyledOrCss.ts new file mode 100644 index 0000000..04e4789 --- /dev/null +++ b/@linaria/packages/babel/src/utils/isStyledOrCss.ts @@ -0,0 +1,67 @@ +import type { + CallExpression, + Expression, + TaggedTemplateExpression, +} from '@babel/types'; +import type { NodePath } from '@babel/traverse'; +import type { State, TemplateExpression } from '../types'; +import { Core } from '../babel'; +import hasImport from './hasImport'; + +type Result = NonNullable<TemplateExpression['styled']> | 'css' | null; + +const cache = new WeakMap<NodePath<TaggedTemplateExpression>, Result>(); + +export default function isStyledOrCss( + { types: t }: Core, + path: NodePath<TaggedTemplateExpression>, + state: State +): Result { + if (!cache.has(path)) { + const { tag } = path.node; + + const localName = state.file.metadata.localName || 'styled'; + + if ( + t.isCallExpression(tag) && + t.isIdentifier(tag.callee) && + tag.arguments.length === 1 && + tag.callee.name === localName && + hasImport(t, path.scope, state.file.opts.filename, localName, [ + '@linaria/react', + 'linaria/react', + ]) + ) { + const tagPath = path.get('tag') as NodePath<CallExpression>; + cache.set(path, { + component: tagPath.get('arguments')[0] as NodePath<Expression>, + }); + } else if ( + t.isMemberExpression(tag) && + t.isIdentifier(tag.object) && + t.isIdentifier(tag.property) && + tag.object.name === localName && + hasImport(t, path.scope, state.file.opts.filename, localName, [ + '@linaria/react', + 'linaria/react', + ]) + ) { + cache.set(path, { + component: { node: t.stringLiteral(tag.property.name) }, + }); + } else if ( + hasImport(t, path.scope, state.file.opts.filename, 'css', [ + '@linaria/core', + 'linaria', + ]) && + t.isIdentifier(tag) && + tag.name === 'css' + ) { + cache.set(path, 'css'); + } else { + cache.set(path, null); + } + } + + return cache.get(path) ?? null; +} diff --git a/@linaria/packages/babel/src/utils/loadOptions.ts b/@linaria/packages/babel/src/utils/loadOptions.ts new file mode 100644 index 0000000..8c0f0b8 --- /dev/null +++ b/@linaria/packages/babel/src/utils/loadOptions.ts @@ -0,0 +1,38 @@ +import cosmiconfig from 'cosmiconfig'; +import type { StrictOptions } from '../types'; + +export type PluginOptions = StrictOptions & { + configFile?: string; +}; + +const explorer = cosmiconfig('linaria'); + +export default function loadOptions( + overrides: Partial<PluginOptions> = {} +): Partial<StrictOptions> { + const { configFile, ignore, ...rest } = overrides; + + const result = + configFile !== undefined + ? explorer.loadSync(configFile) + : explorer.searchSync(); + + return { + displayName: false, + evaluate: true, + rules: [ + { + // FIXME: if `rule` is not specified in a config, `@linaria/shaker` should be added as a dependency + // eslint-disable-next-line import/no-extraneous-dependencies + action: require('@linaria/shaker').default, + }, + { + // The old `ignore` option is used as a default value for `ignore` rule. + test: ignore ?? /[\\/]node_modules[\\/]/, + action: 'ignore', + }, + ], + ...(result ? result.config : null), + ...rest, + }; +} diff --git a/@linaria/packages/babel/src/utils/peek.ts b/@linaria/packages/babel/src/utils/peek.ts new file mode 100644 index 0000000..cabadc4 --- /dev/null +++ b/@linaria/packages/babel/src/utils/peek.ts @@ -0,0 +1,3 @@ +const peek = <T>(stack: T[], offset = 1): T => stack[stack.length - offset]; + +export default peek; diff --git a/@linaria/packages/babel/src/utils/slugify.ts b/@linaria/packages/babel/src/utils/slugify.ts new file mode 100644 index 0000000..39772ad --- /dev/null +++ b/@linaria/packages/babel/src/utils/slugify.ts @@ -0,0 +1,83 @@ +/** + * This file contains a utility to generate hashes to be used as generated class names + */ + +/* eslint-disable no-bitwise, default-case, no-param-reassign, prefer-destructuring */ + +/** + * murmurhash2 via https://gist.github.com/raycmorgan/588423 + */ + +function doHash(str: string, seed: number = 0) { + const m = 0x5bd1e995; + const r = 24; + let h = seed ^ str.length; + let length = str.length; + let currentIndex = 0; + + while (length >= 4) { + let k = UInt32(str, currentIndex); + + k = Umul32(k, m); + k ^= k >>> r; + k = Umul32(k, m); + + h = Umul32(h, m); + h ^= k; + + currentIndex += 4; + length -= 4; + } + + switch (length) { + case 3: + h ^= UInt16(str, currentIndex); + h ^= str.charCodeAt(currentIndex + 2) << 16; + h = Umul32(h, m); + break; + + case 2: + h ^= UInt16(str, currentIndex); + h = Umul32(h, m); + break; + + case 1: + h ^= str.charCodeAt(currentIndex); + h = Umul32(h, m); + break; + } + + h ^= h >>> 13; + h = Umul32(h, m); + h ^= h >>> 15; + + return h >>> 0; +} + +function UInt32(str: string, pos: number) { + return ( + str.charCodeAt(pos++) + + (str.charCodeAt(pos++) << 8) + + (str.charCodeAt(pos++) << 16) + + (str.charCodeAt(pos) << 24) + ); +} + +function UInt16(str: string, pos: number) { + return str.charCodeAt(pos++) + (str.charCodeAt(pos++) << 8); +} + +function Umul32(n: number, m: number) { + n |= 0; + m |= 0; + const nlo = n & 0xffff; + const nhi = n >>> 16; + const res = (nlo * m + (((nhi * m) & 0xffff) << 16)) | 0; + return res; +} + +function slugify(code: string) { + return doHash(code).toString(36); +} + +export default slugify; diff --git a/@linaria/packages/babel/src/utils/stripLines.ts b/@linaria/packages/babel/src/utils/stripLines.ts new file mode 100644 index 0000000..d0495ae --- /dev/null +++ b/@linaria/packages/babel/src/utils/stripLines.ts @@ -0,0 +1,23 @@ +import type { Location } from '../types'; + +// Stripping away the new lines ensures that we preserve line numbers +// This is useful in case of tools such as the stylelint pre-processor +// This should be safe because strings cannot contain newline: https://www.w3.org/TR/CSS2/syndata.html#strings +export default function stripLines( + loc: { start: Location; end: Location }, + text: string | number +) { + let result = String(text) + .replace(/[\r\n]+/g, ' ') + .trim(); + + // If the start and end line numbers aren't same, add new lines to span the text across multiple lines + if (loc.start.line !== loc.end.line) { + result += '\n'.repeat(loc.end.line - loc.start.line); + + // Add extra spaces to offset the column + result += ' '.repeat(loc.end.column); + } + + return result; +} diff --git a/@linaria/packages/babel/src/utils/throwIfInvalid.ts b/@linaria/packages/babel/src/utils/throwIfInvalid.ts new file mode 100644 index 0000000..a883f9f --- /dev/null +++ b/@linaria/packages/babel/src/utils/throwIfInvalid.ts @@ -0,0 +1,49 @@ +import generator from '@babel/generator'; +import type { Serializable } from '../types'; +import isSerializable from './isSerializable'; + +// Throw if we can't handle the interpolated value +function throwIfInvalid( + value: Error | Function | string | number | Serializable | undefined, + ex: any +): void { + if ( + typeof value === 'function' || + typeof value === 'string' || + (typeof value === 'number' && Number.isFinite(value)) || + isSerializable(value) + ) { + return; + } + + // We can't use instanceof here so let's use duck typing + if (value && typeof value !== 'number' && value.stack && value.message) { + throw ex.buildCodeFrameError( + `An error occurred when evaluating the expression: + + > ${value.message}. + + Make sure you are not using a browser or Node specific API and all the variables are available in static context. + Linaria have to extract pieces of your code to resolve the interpolated values. + Defining styled component or class will not work inside: + - function, + - class, + - method, + - loop, + because it cannot be statically determined in which context you use them. + That's why some variables may be not defined during evaluation. + ` + ); + } + + const stringified = + typeof value === 'object' ? JSON.stringify(value) : String(value); + + throw ex.buildCodeFrameError( + `The expression evaluated to '${stringified}', which is probably a mistake. If you want it to be inserted into CSS, explicitly cast or transform the value to a string, e.g. - 'String(${ + generator(ex.node).code + })'.` + ); +} + +export default throwIfInvalid; diff --git a/@linaria/packages/babel/src/utils/toCSS.ts b/@linaria/packages/babel/src/utils/toCSS.ts new file mode 100644 index 0000000..b396087 --- /dev/null +++ b/@linaria/packages/babel/src/utils/toCSS.ts @@ -0,0 +1,57 @@ +import { unitless } from '../units'; +import type { JSONValue } from '../types'; +import isSerializable from './isSerializable'; +import isBoxedPrimitive from './isBoxedPrimitive'; + +const hyphenate = (s: string) => { + if (s.startsWith('--')) { + // It's a custom property which is already well formatted. + return s; + } + return ( + s + // Hyphenate CSS property names from camelCase version from JS string + .replace(/([A-Z])/g, (match, p1) => `-${p1.toLowerCase()}`) + // Special case for `-ms` because in JS it starts with `ms` unlike `Webkit` + .replace(/^ms-/, '-ms-') + ); +}; + +// Some tools such as polished.js output JS objects +// To support them transparently, we convert JS objects to CSS strings +export default function toCSS(o: JSONValue): string { + if (Array.isArray(o)) { + return o.map(toCSS).join('\n'); + } + + if (isBoxedPrimitive(o)) { + return o.valueOf().toString(); + } + + return Object.entries(o) + .filter( + ([, value]) => + // Ignore all falsy values except numbers + typeof value === 'number' || value + ) + .map(([key, value]) => { + if (isSerializable(value)) { + return `${key} { ${toCSS(value)} }`; + } + + return `${hyphenate(key)}: ${ + typeof value === 'number' && + value !== 0 && + // Strip vendor prefixes when checking if the value is unitless + !( + key.replace( + /^(Webkit|Moz|O|ms)([A-Z])(.+)$/, + (match, p1, p2, p3) => `${p2.toLowerCase()}${p3}` + ) in unitless + ) + ? `${value}px` + : value + };`; + }) + .join(' '); +} diff --git a/@linaria/packages/babel/src/utils/toValidCSSIdentifier.ts b/@linaria/packages/babel/src/utils/toValidCSSIdentifier.ts new file mode 100644 index 0000000..9a2be8f --- /dev/null +++ b/@linaria/packages/babel/src/utils/toValidCSSIdentifier.ts @@ -0,0 +1,3 @@ +export default function toValidCSSIdentifier(s: string) { + return s.replace(/[^-_a-z0-9\u00A0-\uFFFF]/gi, '_').replace(/^\d/, '_'); +} diff --git a/@linaria/packages/babel/src/visitors/CollectDependencies.ts b/@linaria/packages/babel/src/visitors/CollectDependencies.ts new file mode 100644 index 0000000..fc6d2fd --- /dev/null +++ b/@linaria/packages/babel/src/visitors/CollectDependencies.ts @@ -0,0 +1,132 @@ +/** + * This file is a visitor that checks TaggedTemplateExpressions and look for Linaria css or styled templates. + * For each template it makes a list of dependencies, try to evaluate expressions, and if it is not possible, mark them as lazy dependencies. + */ + +import type { + Expression, + Identifier as IdentifierNode, + TaggedTemplateExpression, + TSType, +} from '@babel/types'; +import type { NodePath } from '@babel/traverse'; +import { debug } from '@linaria/logger'; +import generator from '@babel/generator'; +import throwIfInvalid from '../utils/throwIfInvalid'; +import type { State, StrictOptions, ExpressionValue } from '../types'; +import { ValueType } from '../types'; +import isStyledOrCss from '../utils/isStyledOrCss'; +import { Core } from '../babel'; + +/** + * Hoist the node and its dependencies to the highest scope possible + */ +function hoist(babel: Core, ex: NodePath<Expression | null>) { + const Identifier = (idPath: NodePath<IdentifierNode>) => { + if (!idPath.isReferencedIdentifier()) { + return; + } + const binding = idPath.scope.getBinding(idPath.node.name); + if (!binding) return; + const { scope, path: bindingPath, referencePaths } = binding; + // parent here can be null or undefined in different versions of babel + if (!scope.parent) { + // It's a variable from global scope + return; + } + + if (bindingPath.isVariableDeclarator()) { + const initPath = bindingPath.get('init') as NodePath<Expression | null>; + hoist(babel, initPath); + initPath.hoist(scope); + if (initPath.isIdentifier()) { + referencePaths.forEach((referencePath) => { + referencePath.replaceWith(babel.types.identifier(initPath.node.name)); + }); + } + } + }; + + if (ex.isIdentifier()) { + return Identifier(ex); + } + + ex.traverse({ + Identifier, + }); +} + +export default function CollectDependencies( + babel: Core, + path: NodePath<TaggedTemplateExpression>, + state: State, + options: StrictOptions +) { + const { types: t } = babel; + const styledOrCss = isStyledOrCss(babel, path, state); + if (!styledOrCss) { + return; + } + const expressions = path.get('quasi').get('expressions'); + + debug('template-parse:identify-expressions', expressions.length); + + const expressionValues: ExpressionValue[] = expressions.map( + (ex: NodePath<Expression | TSType>) => { + if (!ex.isExpression()) { + throw ex.buildCodeFrameError( + `The expression '${generator(ex.node).code}' is not supported.` + ); + } + + const result = ex.evaluate(); + if (result.confident) { + throwIfInvalid(result.value, ex); + return { kind: ValueType.VALUE, value: result.value }; + } + if ( + options.evaluate && + !(t.isFunctionExpression(ex) || t.isArrowFunctionExpression(ex)) + ) { + // save original expression that may be changed during hoisting + const originalExNode = t.cloneNode(ex.node); + + hoist(babel, ex as NodePath<Expression | null>); + + // save hoisted expression to be used to evaluation + const hoistedExNode = t.cloneNode(ex.node); + + // get back original expression to the tree + ex.replaceWith(originalExNode); + + return { kind: ValueType.LAZY, ex: hoistedExNode, originalEx: ex }; + } + + return { kind: ValueType.FUNCTION, ex }; + } + ); + + debug( + 'template-parse:evaluate-expressions', + expressionValues.map((expressionValue) => + expressionValue.kind === ValueType.VALUE ? expressionValue.value : 'lazy' + ) + ); + + if (styledOrCss !== 'css' && 'name' in styledOrCss.component.node) { + // It's not a real dependency. + // It can be simplified because we need just a className. + expressionValues.push({ + // kind: ValueType.COMPONENT, + kind: ValueType.LAZY, + ex: styledOrCss.component.node.name, + originalEx: styledOrCss.component.node.name, + }); + } + + state.queue.push({ + styled: styledOrCss !== 'css' ? styledOrCss : undefined, + path, + expressionValues, + }); +} diff --git a/@linaria/packages/babel/src/visitors/DetectStyledImportName.ts b/@linaria/packages/babel/src/visitors/DetectStyledImportName.ts new file mode 100644 index 0000000..07bf724 --- /dev/null +++ b/@linaria/packages/babel/src/visitors/DetectStyledImportName.ts @@ -0,0 +1,33 @@ +/* eslint-disable no-param-reassign */ +/** + * This Visitor checks if import of `@linaria/react` was renamed and stores that information in state + */ + +import type { ImportDeclaration } from '@babel/types'; +import type { NodePath } from '@babel/traverse'; +import type { State } from '../types'; +import { Core } from '../babel'; + +export default function DetectStyledImportName( + { types: t }: Core, + path: NodePath<ImportDeclaration>, + state: State +) { + if (!t.isLiteral(path.node.source, { value: '@linaria/react' })) { + return; + } + + path.node.specifiers.forEach((specifier) => { + if (!t.isImportSpecifier(specifier)) { + return; + } + + const importedName = t.isStringLiteral(specifier.imported) + ? specifier.imported.value + : specifier.imported.name; + + if (specifier.local.name !== importedName) { + state.file.metadata.localName = specifier.local.name; + } + }); +} diff --git a/@linaria/packages/babel/src/visitors/GenerateClassNames.ts b/@linaria/packages/babel/src/visitors/GenerateClassNames.ts new file mode 100644 index 0000000..9bebf2e --- /dev/null +++ b/@linaria/packages/babel/src/visitors/GenerateClassNames.ts @@ -0,0 +1,161 @@ +/** + * This file is a visitor that checks TaggedTemplateExpressions and look for Linaria css or styled templates. + * For each template it generates a slug that will be used as a CSS class for particular Template Expression, + * and generates a display name for class or styled components. + * It saves that meta data as comment above the template, to be later used in templateProcessor. + */ + +import { basename, dirname, relative } from 'path'; +import type { ObjectProperty, TaggedTemplateExpression } from '@babel/types'; +import type { NodePath } from '@babel/traverse'; +import { debug } from '@linaria/logger'; +import type { State, StrictOptions } from '../types'; +import toValidCSSIdentifier from '../utils/toValidCSSIdentifier'; +import slugify from '../utils/slugify'; +import getLinariaComment from '../utils/getLinariaComment'; +import isStyledOrCss from '../utils/isStyledOrCss'; +import { Core } from '../babel'; + +export default function GenerateClassNames( + babel: Core, + path: NodePath<TaggedTemplateExpression>, + state: State, + options: StrictOptions +) { + const { types: t } = babel; + const styledOrCss = isStyledOrCss(babel, path, state); + if (!styledOrCss) { + return; + } + + const expressions = path.get('quasi').get('expressions'); + + debug('template-parse:identify-expressions', expressions.length); + + // Increment the index of the style we're processing + // This is used for slug generation to prevent collision + // Also used for display name if it couldn't be determined + state.index++; + + let [, slug, displayName, predefinedClassName] = getLinariaComment(path); + + const parent = path.findParent( + (p) => + t.isObjectProperty(p) || + t.isJSXOpeningElement(p) || + t.isVariableDeclarator(p) + ); + + if (!displayName && parent) { + const parentNode = parent.node; + if (t.isObjectProperty(parentNode)) { + if ('name' in parentNode.key) { + displayName = parentNode.key.name; + } else if ('value' in parentNode.key) { + displayName = parentNode.key.value.toString(); + } else { + const keyPath = (parent as NodePath<ObjectProperty>).get('key'); + displayName = keyPath.getSource(); + } + } else if ( + t.isJSXOpeningElement(parentNode) && + t.isJSXIdentifier(parentNode.name) + ) { + displayName = parentNode.name.name; + } else if ( + t.isVariableDeclarator(parentNode) && + t.isIdentifier(parentNode.id) + ) { + displayName = parentNode.id.name; + } + } + + if (!displayName) { + // Try to derive the path from the filename + displayName = basename(state.file.opts.filename); + + if (/^index\.[a-z0-9]+$/.test(displayName)) { + // If the file name is 'index', better to get name from parent folder + displayName = basename(dirname(state.file.opts.filename)); + } + + // Remove the file extension + displayName = displayName.replace(/\.[a-z0-9]+$/, ''); + + if (displayName) { + displayName += state.index; + } else { + throw path.buildCodeFrameError( + "Couldn't determine a name for the component. Ensure that it's either:\n" + + '- Assigned to a variable\n' + + '- Is an object property\n' + + '- Is a prop in a JSX element\n' + ); + } + } + + // Custom properties need to start with a letter, so we prefix the slug + // Also use append the index of the class to the filename for uniqueness in the file + slug = + slug || + toValidCSSIdentifier( + `${displayName.charAt(0).toLowerCase()}${slugify( + `${relative(state.file.opts.root, state.file.opts.filename)}:${ + state.index + }` + )}` + ); + + let className = predefinedClassName + ? predefinedClassName + : options.displayName + ? `${toValidCSSIdentifier(displayName!)}_${slug!}` + : slug!; + + // The className can be defined by the user either as fn or a string + if (typeof options.classNameSlug === 'function') { + try { + className = toValidCSSIdentifier( + options.classNameSlug(slug, displayName) + ); + } catch { + throw new Error(`classNameSlug option must return a string`); + } + } + + if (typeof options.classNameSlug === 'string') { + const { classNameSlug } = options; + + // Available variables for the square brackets used in `classNameSlug` options + const classNameSlugVars: Record<string, string | null> = { + hash: slug, + title: displayName, + }; + + // Variables that were used in the config for `classNameSlug` + const optionVariables = classNameSlug.match(/\[.*?]/g) || []; + let cnSlug = classNameSlug; + + for (let i = 0, l = optionVariables.length; i < l; i++) { + const v = optionVariables[i].slice(1, -1); // Remove the brackets around the variable name + + // Replace the var if it key and value exist otherwise place an empty string + cnSlug = cnSlug.replace(`[${v}]`, classNameSlugVars[v] || ''); + } + + className = toValidCSSIdentifier(cnSlug); + } + + const type = styledOrCss === 'css' ? 'css' : 'styled'; + + debug( + `template-parse:generated-meta:${type}`, + `slug: ${slug}, displayName: ${displayName}, className: ${className}` + ); + + // Save evaluated slug and displayName for future usage in templateProcessor + path.addComment( + 'leading', + `linaria ${type} ${slug} ${displayName} ${className}` + ); +} diff --git a/@linaria/packages/babel/tsconfig.json b/@linaria/packages/babel/tsconfig.json new file mode 100644 index 0000000..73092b0 --- /dev/null +++ b/@linaria/packages/babel/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": {}, + "rootDir": "src/" + }, + "references": [{ "path": "../core" }, { "path": "../logger" }] +} |