Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(atomic): create an atomic package for the css API #867

Merged
merged 2 commits into from
Nov 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,16 @@ module.exports = {
];
```

- `atomize: (cssText) => { className: string, cssText: string, property: string, }[]`

A function that will be used to split css text into an array of atoms (atoms have className, cssText and the property key defined for them)

To configure for use with `@linaria/atomic`, set this option to `atomize: require('@linaria/atomic').atomize`

- `babelOptions: Object`

If you need to specify custom babel configuration, you can pass them here. These babel options will be used by Linaria when parsing and evaluating modules.

- `resolveOptions: Object`

By default, the loader will resolve modules using the `alias` and `modules`
Expand Down Expand Up @@ -151,7 +157,7 @@ After that, your `package.json` should look like the following:
Now in your `preact.config.js`, we will modify the babel rule to use the necessary loaders and presets. Add the following:

```js
export default config => {
export default (config) => {
const { options, ...babelLoaderRule } = config.module.rules[0]; // Get the babel rule and options
options.presets.push('@babel/preset-react', '@linaria'); // Push the necessary presets
config.module.rules[0] = {
Expand All @@ -160,15 +166,15 @@ export default config => {
use: [
{
loader: 'babel-loader',
options
options,
},
{
loader: '@linaria/webpack-loader',
options: {
babelOptions: options // Pass the current babel options to linaria's babel instance
}
}
]
babelOptions: options, // Pass the current babel options to linaria's babel instance
},
},
],
};
};
```
Expand Down Expand Up @@ -265,7 +271,7 @@ exports.onCreateWebpackConfig = ({ actions, loaders, getConfig, stage }) => {

config.module.rules = [
...config.module.rules.filter(
rule => String(rule.test) !== String(/\.js?$/)
(rule) => String(rule.test) !== String(/\.js?$/)
),

{
Expand Down
35 changes: 35 additions & 0 deletions packages/atomic/README.md
Original file line number Diff line number Diff line change
@@ -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
```
3 changes: 3 additions & 0 deletions packages/atomic/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const config = require('../../babel.config');

module.exports = config;
43 changes: 43 additions & 0 deletions packages/atomic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@linaria/atomic",
"version": "3.0.0-beta.13",
"publishConfig": {
"access": "public"
},
"description": "Blazing fast zero-runtime CSS in JS library",
"sideEffects": false,
"main": "lib/index.js",
"module": "esm/index.js",
"types": "types/index.d.ts",
"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"
],
"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",
"typecheck": "tsc --noEmit --composite false",
"watch": "yarn build --watch"
},
"dependencies": {
"@linaria/utils": "^3.0.0-beta.13",
"postcss": "^8.3.11"
}
}
3 changes: 3 additions & 0 deletions packages/atomic/src/CSSProperties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type CSSProperties = {
[key: string]: string | number | CSSProperties;
};
45 changes: 45 additions & 0 deletions packages/atomic/src/atomize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import postcss from 'postcss';
import { slugify } from '@linaria/utils';

export default function atomize(cssText: string) {
const atomicRules: {
className: string;
cssText: string;
property: string;
}[] = [];

const stylesheet = postcss.parse(cssText);

stylesheet.walkDecls((decl) => {
const parent = decl.parent;
if (parent === stylesheet) {
const line = `${decl.prop}: ${decl.value};`;
const className = `atm_${slugify(line)}`;
atomicRules.push({
property: decl.prop,
className,
cssText: line,
});
}
});
// Things like @media rules
stylesheet.walkAtRules((atRule) => {
atRule.walkDecls((decl) => {
const slug = slugify(
[atRule.name, atRule.params, decl.prop, decl.value].join(';')
);
const className = `atm_${slug}`;
atomicRules.push({
// For @ rules we want the unique property we do merging on to contain
// the atrule params, eg. `media only screen and (max-width: 600px)`
// But not the value. That way, our hashes will match when the media rule +
// the declaration property match, and we can merge atomic media rules
property: [atRule.name, atRule.params, decl.prop].join(' '),
className,
cssText: `@${atRule.name} ${atRule.params} { .${className} { ${decl.prop}: ${decl.value}; } }`,
});
});
});

return atomicRules;
}
18 changes: 18 additions & 0 deletions packages/atomic/src/css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { CSSProperties } from './CSSProperties';

export interface StyleCollectionObject {
[key: string]: string;
}

type CSS = (
strings: TemplateStringsArray,
...exprs: Array<string | number | CSSProperties>
) => StyleCollectionObject;

export const css: CSS = () => {
throw new Error(
'Using the "css" tag in runtime is not supported. Make sure you have set up the Babel plugin correctly.'
);
};

export default css;
6 changes: 6 additions & 0 deletions packages/atomic/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { default as css } from './css';
export { default as atomize } from './atomize';
export { cx } from '@linaria/utils';

export type { CSSProperties } from './CSSProperties';
export type { StyleCollectionObject } from './css';
5 changes: 5 additions & 0 deletions packages/atomic/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": { "paths": {}, "rootDir": "src/" },
"references": [{ "path": "../utils" }]
}
2 changes: 1 addition & 1 deletion packages/babel/__fixtures__/slugify.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import slugify from '../src/utils/slugify';
import { slugify } from '@linaria/utils';

export default slugify;
22 changes: 22 additions & 0 deletions packages/babel/__tests__/__snapshots__/babel.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`compiles atomic css 1`] = `
"/* @flow */
import { css } from '@linaria/atomic';
import { styled } from '@linaria/react';
const x = {
\\"background\\": \\"atm_k1dxsr\\",
\\"height\\": \\"atm_128ffr2\\"
};
console.log(x);"
`;

exports[`compiles atomic css 2`] = `

CSS:

.atm_k1dxsr {background: red;}
.atm_128ffr2 {height: 100px;}

Dependencies: NA

`;

exports[`does not include styles if not referenced anywhere 1`] = `
"import { css } from '@linaria/core';
import { styled } from '@linaria/react';
Expand Down
27 changes: 26 additions & 1 deletion packages/babel/__tests__/babel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ expect.addSnapshotSerializer(serializer);

const transpile = async (
input: string,
opts: Partial<StrictOptions> = { evaluate: false }
opts: Partial<StrictOptions> = {
evaluate: false,
atomize: require('@linaria/atomic').atomize,
}
) =>
(await transformAsync(input, {
babelrc: false,
Expand Down Expand Up @@ -553,3 +556,25 @@ it('handles objects with enums as keys', async () => {
expect(code).toMatchSnapshot();
expect(metadata).toMatchSnapshot();
});

it('compiles atomic css', async () => {
const { code, metadata } = await transpile(
dedent`
/* @flow */

import { css } from '@linaria/atomic';
import { styled } from '@linaria/react';

const x = css\`
background: red;
height: 100px;
\`;

console.log(x);

`
);

expect(code).toMatchSnapshot();
expect(metadata).toMatchSnapshot();
});
2 changes: 2 additions & 0 deletions packages/babel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"watch": "yarn build --watch"
},
"devDependencies": {
"@linaria/atomic": "^3.0.0-beta.13",
"@types/cosmiconfig": "^5.0.3",
"@types/dedent": "^0.7.0",
"dedent": "^0.7.0",
Expand All @@ -50,6 +51,7 @@
"@babel/template": ">=7",
"@linaria/core": "^3.0.0-beta.13",
"@linaria/logger": "^3.0.0-beta.3",
"@linaria/utils": "^3.0.0-beta.13",
"cosmiconfig": "^5.1.0",
"source-map": "^0.7.3",
"stylis": "^3.5.4"
Expand Down
59 changes: 47 additions & 12 deletions packages/babel/src/evaluators/templateProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export default function getTemplateProcessor(
// Only works when it's assigned to a variable
let isReferenced = true;

const [, slug, displayName, className] = getLinariaComment(path);
const [type, slug, displayName, className] = getLinariaComment(path);

const parent = path.findParent(
(p) =>
Expand Down Expand Up @@ -290,24 +290,59 @@ export default function getTemplateProcessor(
);

path.addComment('leading', '#__PURE__');
} else {
} else if (type === 'css') {
path.replaceWith(t.stringLiteral(className!));
}

if (!isReferenced && !cssText.includes(':global')) {
return;
}

debug(
'evaluator:template-processor:extracted-rule',
`\n${selector} {${cssText}\n}`
);
if (type === 'atomic-css') {
const { atomize } = options;
if (!atomize) {
throw new Error(
'The atomic css API was detected, but an atomize function was not passed in the linaria configuration.'
);
}
const atomicRules = atomize(cssText);
atomicRules.forEach((rule) => {
state.rules[`.${rule.className}`] = {
cssText: rule.cssText,
start: path.parent?.loc?.start ?? null,
Copy link
Collaborator

Choose a reason for hiding this comment

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

How do you think, is it possible to specify the real positions of rules for source-maps?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a good question – I think it's a little tricky because a single atom might have come from multiple different modules.

The way it's done here, it should be that an atom's source map points to the last place it was generated from (it does work correctly on the website example). I think that's probably better than nothing – in the case where you have a unique atom, and you want to know where it's coming from, the source map will work for that. In the case where the atom is shared, the source map will point to at least one place where it is defined.

className: className!,
displayName: displayName!,
atom: true,
};

debug(
'evaluator:template-processor:extracted-atomic-rule',
`\n${rule.cssText}`
);
});

const atomicClassObject = t.objectExpression(
atomicRules.map((rule) =>
t.objectProperty(
t.stringLiteral(rule.property),
t.stringLiteral(rule.className)
)
)
);

state.rules[selector] = {
cssText,
className: className!,
displayName: displayName!,
start: path.parent?.loc?.start ?? null,
};
path.replaceWith(atomicClassObject);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Another thought I had was that this is different from the css API in that the atomic css template literal returns an object and requires you to use the atomic cx function to convert that into styles (by resolving the merging of styles).

Some alternatives:

We could instead produce a string that the cx function would need to resolve, like this:

path.replaceWith('atm_prophash1_valuehash1 atm_prophash2_valuehash2')

and then the atomic cx function would know to do the merging based on the structure of the class names.

In performance testing on https://jsbench.me/, there's a slight disadvantage to doing the string manipulation, but only slight.

I think this is probably not preferable, as using the class names as strings without cx from @linaria/atomic will be a gotcha (you'd end up with conflicts resolved by specificity, which will be confusing). The object approach should also be type safe when using css from @linaria/atomic

Interested in your thoughts though

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think, there should be just one cx because classes for it can come from different libs and modules, some of them can use core whereas another can use atomic.

} else {
debug(
'evaluator:template-processor:extracted-rule',
`\n${selector} {${cssText}\n}`
);

state.rules[selector] = {
cssText,
className: className!,
displayName: displayName!,
start: path.parent?.loc?.start ?? null,
};
}
};
}
Loading