├── .babelrc
├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .npmignore
├── .nvmrc
├── .travis.yml
├── README.md
├── __tests__
├── end-to-end
│ ├── README.md
│ ├── __helpers__.js
│ ├── chained-selectors.html
│ ├── chained-selectors.js
│ ├── complex.html
│ ├── complex.js
│ ├── font-face.html
│ ├── font-face.js
│ ├── keyframes.html
│ ├── keyframes.js
│ ├── longhand.html
│ ├── longhand.js
│ ├── mq.html
│ ├── mq.js
│ ├── overrides.html
│ ├── overrides.js
│ ├── pseudo.html
│ ├── pseudo.js
│ ├── repetition.html
│ ├── repetition.js
│ ├── supports.html
│ ├── supports.js
│ ├── unatomisable.html
│ └── unatomisable.js
└── unit
│ ├── expand-shorthand.js
│ ├── handle-map.js
│ ├── merge-rules-by-declarations.js
│ ├── merge-rules-by-selector.js
│ ├── number-to-letter.js
│ ├── passthru-unatomisable.js
│ ├── resolve-declarations.js
│ └── unchain-selectors.js
├── package.json
├── rollup.config.js
└── src
├── index.js
└── lib
├── expand-shorthand.js
├── get-context.js
├── merge-rules-by-declarations.js
├── merge-rules-by-selector.js
├── number-to-letter.js
├── passthru-unatomisable.js
├── report-stats.js
├── resolve-declarations.js
└── unchain-selectors.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "transform-es2015-modules-commonjs",
4 | "transform-async-to-generator"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | ; This file is for unifying the coding style for different editors and IDEs
2 | ; See editorconfig.org
3 |
4 | ; top-most EditorConfig file
5 | root = true
6 |
7 | [*]
8 | indent_style = space
9 | indent_size = 4
10 | end_of_line = lf
11 | charset = utf-8
12 | trim_trailing_whitespace = true
13 | insert_final_newline = true
14 |
15 | [*.json]
16 | indent_size = 2
17 |
18 | [*.yml]
19 | indent_size = 2
20 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | coverage
2 | build
3 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "allowImportExportEverywhere": false,
3 | "env": {
4 | "browser": true,
5 | "es6": true,
6 | "jest": true,
7 | "node": true
8 | },
9 | "extends": "airbnb",
10 | "parser": "babel-eslint",
11 | "parserOptions": {
12 | "sourceType": "module"
13 | },
14 | "rules": {
15 | "indent": [
16 | "error",
17 | 4
18 | ],
19 | "linebreak-style": [
20 | "error",
21 | "unix"
22 | ],
23 | "quotes": [
24 | "error",
25 | "single"
26 | ],
27 | "semi": [
28 | "error",
29 | "always"
30 | ],
31 | "import/no-extraneous-dependencies": [
32 | "error", {
33 | "devDependencies": true,
34 | "optionalDependencies": false,
35 | "peerDependencies": false
36 | }],
37 | "max-len": 0,
38 | "arrow-parens": 0
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | coverage
4 | .DS_Store
5 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | *
2 | !build/**/*
3 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 6
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - '6'
4 | before_install:
5 | - 'npm install -g npm@latest'
6 | notifications:
7 | email:
8 | on_success: change
9 | on_failure: always
10 | after_success:
11 | - 'npm run coverage'
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # postcss-atomised
2 | [](https://badge.fury.io/js/postcss-atomised) [](https://travis-ci.org/sndrs/postcss-atomised) [](https://coveralls.io/github/sndrs/postcss-atomised?branch=master) [](https://dependencyci.com/github/sndrs/postcss-atomised) [](https://david-dm.org/sndrs/postcss-atomised) [](https://david-dm.org/sndrs/postcss-atomised#info=devDependencies)
3 |
4 | _This is probably not stable. It was initially developed for use on [the Guardian website](https://github.com/guardian/frontend), but it feels like it's the wrong solution to the problem of `bloat + complexity`. Leaving it here in case anyone finds it useful or we pick it up again._
5 |
6 | ---
7 |
8 | [PostCSS](http://postcss.org) plugin that [atomises](http://www.creativebloq.com/css3/atomic-css-11619006) a standard set of CSS, and provides a json map from the original classes to the atomised ones.
9 |
10 | Enables you to write CSS in an insolated, super-modular fashion without worrying about the bloat of duplication (the only way you could serve a smaller stylesheet would be to use fewer styles).
11 |
12 | ## Example
13 | Take your stylesheet…
14 |
15 | ```css
16 | /* original.css */
17 | .one {
18 | background-color: red;
19 | margin: 1rem;
20 | }
21 | .two {
22 | background-color: red;
23 | margin-top: 1rem;
24 | }
25 | @media (min-width: 100px) {
26 | .two:hover {
27 | background-color: hotpink;
28 | }
29 | }
30 | ```
31 | Pass it through the plugin…
32 |
33 | ```javascript
34 | // load the original CSS file and atomise it
35 |
36 | import {readFileSync} from 'fs';
37 |
38 | import postcss from 'postcss';
39 | import atomised from 'postcss-atomised';
40 |
41 | const css = readFileSync('./original.css', 'utf8');
42 | const options = {};
43 |
44 | postcss([atomised(options)]).process(css).then(result => {
45 | // do something with `result`
46 | });
47 | ```
48 |
49 | `result.css` is a String containing the atomised CSS:
50 |
51 | ```css
52 | .a { background-color: red; }
53 | .b { margin-top: 1rem; }
54 | .c { margin-right: 1rem; }
55 | .d { margin-bottom: 1rem; }
56 | .e { margin-left: 1rem; }
57 | @media (min-width: 100px) {
58 | .f:hover { background-color: hotpink; }
59 | }
60 | ```
61 |
62 | You now also have a file called `atomised-map.json` in `cwd`.
63 |
64 | ```javascript
65 | // atomised-map.json
66 | {
67 | "one": ["a", "b", "c"," d", "e"],
68 | "two": ["a", "b", "f"]
69 | }
70 | ```
71 |
72 | This can be used to transform your templates.
73 |
74 | ```html
75 |
76 |
77 | ```
78 |
79 | into…
80 |
81 | ```html
82 |
83 |
84 | ```
85 |
86 | ## Options
87 | Type: `Object` | `Null`
88 |
89 | No options are required. By default, a file called `atomised-map.json` will be written to `cwd` containing the atomised JSON map.
90 |
91 | ### `options.mapPath`
92 | Type: (`String` | `Null`) _optional_
93 |
94 |
95 | Alternative location for the atomised JSON map to be saved. `null` will prevent the output being written to disk.
96 |
97 | ### `options.mapHandler`
98 | Type: (`Function`) _optional_
99 |
100 | Callback function that receives one arguement – the JSON map object.
101 |
102 | ## Restrictions
103 | - only single class selectors can be atomised (other selectors will pass straight through)
104 | - multiple/duplicate and pseudo selectors/elements are fine
105 |
106 | | Selector | Ok |
107 | |---|---|
108 | | `.a .b { }` | :x: |
109 | | `.a.b { }` | :x: |
110 | | `a { }` | :x: |
111 | | `a b { }` | :x: |
112 | | `#a { }` | :x: |
113 | | `a[b] { }` | :x: |
114 | | `a > b { }` | :x: |
115 | | `a + b { }` | :x: |
116 | | `a ~ b { }` | :x: |
117 | | `*` | :x: |
118 | | `.a:b { }` | :white_check_mark: |
119 | | `.a, .b { }` | :white_check_mark: |
120 | | `.a { } .a { }` | :white_check_mark: |
121 | | `.a:hover { }` | :white_check_mark: |
122 |
123 | ## Development
124 | Run `npm start` to run the test runner in watch mode, or `npm test` for a one-off.
125 | Node 6 or greater is needed for development.
126 |
127 | ## Node.js 0.*
128 | Node 4 or greater is needed to use the plugin.
129 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/README.md:
--------------------------------------------------------------------------------
1 | # End-to-end tests
2 |
3 | These compare the input and output of atomisation for a set of test cases (the `*.html` files).
4 |
5 | Original and atomised versions of each test html file are rendered in [phantom](http://phantomjs.org).
6 |
7 | The result of calling `getComputedStyle()` for each DOM element in the body of both versions is then compared.
8 |
9 | If the final atomised DOM renders with precisely the same `getComputedStyle()` output, the test passes.
10 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/__helpers__.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { readFileSync } from 'fs';
3 | import { create } from 'phantom';
4 | import postcss from 'postcss';
5 | import replaceClasses from 'replace-classes';
6 |
7 | import atomised from '../../src';
8 |
9 | const getComputedStyles = (page) => page.evaluate(
10 | // ES5 because it runs in phantom
11 | /* eslint-disable prefer-arrow-callback */
12 | function getPhantomComputedStyles() {
13 | return [].slice.call(document.body.getElementsByTagName('*')).map(function getTagComputedStyles(element) {
14 | return window.getComputedStyle(element);
15 | });
16 | },
17 | /* eslint-enable prefer-arrow-callback */
18 | );
19 |
20 |
21 | export default filePath => async (done) => {
22 | const src = readFileSync(path.resolve(__dirname, filePath), 'utf8');
23 | const instance = await create();
24 | const page = await instance.createPage();
25 |
26 | await page.property('viewportSize', { width: 600, height: 1 });
27 |
28 | // console.log(await page.property('content'));
29 |
30 | await page.property('content', src);
31 | const orginalComputedStyles = await getComputedStyles(page);
32 |
33 | // console.log(await page.property('content'));
34 |
35 | let atomicMap;
36 | const atomisedCSS = await postcss([atomised({
37 | mapHandler: json => { atomicMap = Object.assign({}, json); },
38 | mapPath: null,
39 | })]).process(src.match(/
9 |
10 |
11 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/chained-selectors.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders chained selectors properly', test('./chained-selectors.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/complex.html:
--------------------------------------------------------------------------------
1 |
17 |
18 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/complex.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders complex css properly', test('./complex.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/font-face.html:
--------------------------------------------------------------------------------
1 |
11 |
12 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/font-face.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders @font-face declarations properly', test('./font-face.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/keyframes.html:
--------------------------------------------------------------------------------
1 |
10 |
11 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/keyframes.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders @keyframes properly', test('./keyframes.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/longhand.html:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/longhand.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders expanded shorthand declarations properly', test('./longhand.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/mq.html:
--------------------------------------------------------------------------------
1 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/mq.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders media queries properly', test('./mq.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/overrides.html:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/overrides.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders overridden declarations properly', test('./overrides.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/pseudo.html:
--------------------------------------------------------------------------------
1 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/pseudo.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders pseudos properly', test('./pseudo.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/repetition.html:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/repetition.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders common declarations properly', test('./repetition.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/supports.html:
--------------------------------------------------------------------------------
1 |
11 |
12 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/supports.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders @supports properly', test('./supports.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/unatomisable.html:
--------------------------------------------------------------------------------
1 |
24 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/__tests__/end-to-end/unatomisable.js:
--------------------------------------------------------------------------------
1 | import test from './__helpers__';
2 |
3 | it('renders unatomisable rules properly', test('./unatomisable.html'));
4 |
--------------------------------------------------------------------------------
/__tests__/unit/expand-shorthand.js:
--------------------------------------------------------------------------------
1 | import postcss from 'postcss';
2 | import perfectionist from 'perfectionist';
3 |
4 | import expandShorthand from '../../src/lib/expand-shorthand';
5 |
6 | function parse(css) {
7 | const src = postcss().process(css).root;
8 | expandShorthand(src);
9 | return perfectionist.process(src).css;
10 | }
11 |
12 | it('expands `margin`', () => {
13 | expect(parse(`
14 | margin: 10px
15 | `)).toBe(parse(`
16 | margin-top: 10px;
17 | margin-right: 10px;
18 | margin-bottom: 10px;
19 | margin-left: 10px
20 | `));
21 | });
22 |
23 | it('expands `padding`', () => {
24 | expect(parse(`
25 | padding: 10px
26 | `)).toBe(parse(`
27 | padding-top: 10px;
28 | padding-right: 10px;
29 | padding-bottom: 10px;
30 | padding-left: 10px
31 | `));
32 | });
33 |
34 | it('does nothing with `padding-top`', () => {
35 | expect(parse('padding-top: 10px')).toBe(parse('padding-top: 10px'));
36 | });
37 |
38 | it('expands `font`', () => {
39 | expect(parse(`
40 | font: 1rem "Roboto Condensed", sans-serif;
41 | `)).toBe(parse(`
42 | font-style: normal;
43 | font-variant: normal;
44 | font-weight: normal;
45 | font-stretch: normal;
46 | font-size: 1rem;
47 | line-height: normal;
48 | font-family: Roboto Condensed, sans-serif;
49 | `));
50 | });
51 |
--------------------------------------------------------------------------------
/__tests__/unit/handle-map.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 |
3 | import path from 'path';
4 | import postcss from 'postcss';
5 | import del from 'del';
6 | import atomised from '../../src';
7 |
8 | const defaultLocation = path.resolve(process.cwd(), 'atomised-map.json');
9 | const testLocation = path.resolve(__dirname, '..', 'test.json');
10 | var handler = jest.fn();
11 |
12 | afterEach(async done => {
13 | await del(defaultLocation);
14 | await del(testLocation);
15 | done();
16 | })
17 |
18 | it('does not save a map if mapPath is null', async done => {
19 | await postcss([atomised({mapPath: null})]).process('.red { color: red }');
20 | expect(() => {
21 | require(defaultLocation);
22 | }).toThrow();
23 | done();
24 | });
25 |
26 | it('saves the map to the specified location if it is specified', async done => {
27 | await postcss([atomised({ mapPath: testLocation })]).process('.red { color: red }');
28 | expect(() => {
29 | require(testLocation);
30 | }).not.toThrow();
31 | expect(() => {
32 | require(defaultLocation);
33 | }).toThrow();
34 | done();
35 | });
36 |
37 | it('call the handler function if it is specified', async done => {
38 | await postcss([atomised({ mapHandler: handler })]).process('.red { color: red }');
39 | expect(handler).toBeCalledWith({red: ['a']});
40 | done();
41 | });
42 |
43 | it('saves the map to the default location if none is specified', async done => {
44 | await postcss([atomised()]).process('.red { color: red }');
45 | expect(() => {
46 | require(defaultLocation);
47 | }).not.toThrow();
48 | done();
49 | });
50 |
--------------------------------------------------------------------------------
/__tests__/unit/merge-rules-by-declarations.js:
--------------------------------------------------------------------------------
1 | import postcss from 'postcss';
2 | import perfectionist from 'perfectionist';
3 |
4 | import mergeRulesByDeclarations from '../../src/lib/merge-rules-by-declarations';
5 |
6 | function parse(css) {
7 | const src = postcss().process(css).root;
8 | mergeRulesByDeclarations(src);
9 | return perfectionist.process(src).css;
10 | }
11 |
12 | it('combines rules with identical declarations', () => {
13 | expect(parse(`
14 | body {
15 | color: red;
16 | font-size: 1rem
17 | }
18 | .a {
19 | color: red;
20 | font-size: 1rem
21 | }
22 | `)).toBe(parse(`
23 | body, .a {
24 | color: red;
25 | font-size: 1rem
26 | }
27 | `));
28 | });
29 |
--------------------------------------------------------------------------------
/__tests__/unit/merge-rules-by-selector.js:
--------------------------------------------------------------------------------
1 | import postcss from 'postcss';
2 | import perfectionist from 'perfectionist';
3 |
4 | import mergeRulesBySelector from '../../src/lib/merge-rules-by-selector';
5 |
6 | function parse(css) {
7 | const src = postcss().process(css).root;
8 | mergeRulesBySelector(src);
9 | return perfectionist.process(src).css;
10 | }
11 |
12 | it('merges rules with identical selectors', () => {
13 | expect(parse(`
14 | .a {
15 | color: red;
16 | }
17 | .a {
18 | font-size: 1rem;
19 | }
20 | `)).toBe(parse(`
21 | .a {
22 | color: red;
23 | font-size: 1rem
24 | }
25 | `));
26 | });
27 |
28 | it('does not merge rules with similar but non-identical selectors', () => {
29 | expect(parse(`
30 | body, .a {
31 | color: red;
32 | }
33 | .a {
34 | font-size: 1rem;
35 | }
36 | `)).toBe(parse(`
37 | body, .a {
38 | color: red;
39 | }
40 | .a {
41 | font-size: 1rem;
42 | }
43 | `));
44 | });
45 |
--------------------------------------------------------------------------------
/__tests__/unit/number-to-letter.js:
--------------------------------------------------------------------------------
1 | import numberToLetter from '../../src/lib/number-to-letter';
2 |
3 | it('returns values for any number', () => {
4 | expect(numberToLetter(0)).toBe('a');
5 | expect(numberToLetter(52)).toBe('aa');
6 | expect(numberToLetter(2756)).toBe('aaa');
7 | expect(numberToLetter(1000000000)).toBe('bFMXym');
8 | });
9 |
--------------------------------------------------------------------------------
/__tests__/unit/passthru-unatomisable.js:
--------------------------------------------------------------------------------
1 | import postcss from 'postcss';
2 | import perfectionist from 'perfectionist';
3 |
4 | import passthruUnatomisable from '../../src/lib/passthru-unatomisable';
5 |
6 | function parse(css) {
7 | const src = postcss().process(css);
8 | passthruUnatomisable(src.root, [], src.result);
9 | return perfectionist.process(src).css.trim();
10 | }
11 |
12 | it('removes ID selectors', () => {
13 | expect(parse(`
14 | #a {
15 | font-size: 1rem;
16 | }
17 | `)).toBe('');
18 | });
19 |
20 | it('removes Type selectors', () => {
21 | expect(parse(`
22 | body {
23 | font-size: 1rem;
24 | }
25 | `)).toBe('');
26 | });
27 |
28 | it('removes Universal selectors', () => {
29 | expect(parse(`
30 | * {
31 | font-size: 1rem;
32 | }
33 | `)).toBe('');
34 | });
35 |
36 | it('removes Attribute selectors', () => {
37 | expect(parse(`
38 | [class] {
39 | font-size: 1rem;
40 | }
41 | `)).toBe('');
42 | });
43 |
44 | it('removes combinator selectors', () => {
45 | expect(parse(`
46 | .a + .b {
47 | font-size: 1rem;
48 | }
49 | `)).toBe('');
50 |
51 | expect(parse(`
52 | .a ~ .b {
53 | font-size: 1rem;
54 | }
55 | `)).toBe('');
56 |
57 | expect(parse(`
58 | .a > .b {
59 | font-size: 1rem;
60 | }
61 | `)).toBe('');
62 |
63 | expect(parse(`
64 | .a .b {
65 | font-size: 1rem;
66 | }
67 | `)).toBe('');
68 | });
69 |
70 | it('keeps single Classes', () => {
71 | expect(parse(`
72 | .a {
73 | font-size: 1rem;
74 | }
75 | `)).toBe(parse(`
76 | .a {
77 | font-size: 1rem;
78 | }
79 | `));
80 | });
81 |
82 | it('keeps pseudo-classes', () => {
83 | expect(parse(`
84 | .a {
85 | font-size: 2rem;
86 | }
87 | .a:hover {
88 | font-size: 1rem;
89 | }
90 | `)).toBe(parse(`
91 | .a {
92 | font-size: 2rem;
93 | }
94 | .a:hover {
95 | font-size: 1rem;
96 | }
97 | `));
98 | });
99 |
100 | it('keeps pseudo-elements', () => {
101 | expect(parse(`
102 | .a {
103 | font-size: 2rem;
104 | }
105 | .a::after {
106 | font-size: 1rem;
107 | }
108 | `)).toBe(parse(`
109 | .a {
110 | font-size: 2rem;
111 | }
112 | .a::after {
113 | font-size: 1rem;
114 | }
115 | `));
116 | });
117 |
--------------------------------------------------------------------------------
/__tests__/unit/resolve-declarations.js:
--------------------------------------------------------------------------------
1 | import postcss from 'postcss';
2 | import perfectionist from 'perfectionist';
3 |
4 | import resolveDeclarations from '../../src/lib/resolve-declarations';
5 |
6 | function parse(css) {
7 | const src = postcss().process(css).root;
8 | resolveDeclarations(src);
9 | return perfectionist.process(src).css;
10 | }
11 |
12 | it('resolves duplicate declarations in single individual rules', () => {
13 | expect(parse(`
14 | .a {
15 | color: red;
16 | color: blue
17 | }
18 | .a {
19 | color: red;
20 | }
21 | `)).toBe(parse(`
22 | .a {
23 | color: blue;
24 | }
25 | .a {
26 | color: red;
27 | }
28 | `));
29 | });
30 |
--------------------------------------------------------------------------------
/__tests__/unit/unchain-selectors.js:
--------------------------------------------------------------------------------
1 | import postcss from 'postcss';
2 | import perfectionist from 'perfectionist';
3 |
4 | import unchainSelectors from '../../src/lib/unchain-selectors';
5 |
6 | function parse(css) {
7 | const src = postcss().process(css).root;
8 | unchainSelectors(src);
9 | return perfectionist.process(src).css;
10 | }
11 |
12 | it('expands lists of selectors in a single rule to individual rules', () => {
13 | expect(parse(`
14 | .a {
15 | color: green;
16 | }
17 | .a, .b {
18 | color: red;
19 | }
20 | .b {
21 | color: blue
22 | }
23 | `)).toBe(parse(`
24 | .a {
25 | color: green;
26 | }
27 | .a {
28 | color: red;
29 | }
30 | .b {
31 | color: red;
32 | }
33 | .b {
34 | color: blue;
35 | }
36 | `));
37 | });
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "postcss-atomised",
3 | "version": "0.3.2",
4 | "description": "PostCSS plugin that creates an atomised stylesheet from the input CSS, and provides a json map from the original classes to the atomic ones.",
5 | "main": "build/index.js",
6 | "repository": {
7 | "type": "git",
8 | "url": "git+https://github.com/sndrs/postcss-atomised.git"
9 | },
10 | "author": "Alex Sanders ",
11 | "license": "ISC",
12 | "bugs": {
13 | "url": "https://github.com/sndrs/postcss-atomised/issues"
14 | },
15 | "homepage": "https://github.com/sndrs/postcss-atomised#readme",
16 | "keywords": [
17 | "postcss",
18 | "css",
19 | "postcss-plugin",
20 | "atomic",
21 | "atomic-css"
22 | ],
23 | "engines": {
24 | "node": ">=4"
25 | },
26 | "dependencies": {
27 | "chalk": "^1.1.3",
28 | "css-mqpacker": "^5.0.1",
29 | "cssstats": "^3.0.0-beta.1",
30 | "lodash.isequal": "^4.3.1",
31 | "lodash.isstring": "^4.0.1",
32 | "lodash.uniqby": "^4.5.0",
33 | "mkdirp": "^0.5.1",
34 | "parse-css-font": "^2.0.2",
35 | "parse-css-sides": "^2.0.0",
36 | "pify": "^2.3.0",
37 | "postcss": "^5.2.0",
38 | "postcss-resolve-prop": "^3.1.0",
39 | "postcss-selector-parser": "^2.1.1",
40 | "pretty-bytes": "^4.0.2",
41 | "shorthash": "0.0.2"
42 | },
43 | "devDependencies": {
44 | "babel-cli": "^6.9.0",
45 | "babel-eslint": "^7.1.1",
46 | "babel-jest": "^17.0.2",
47 | "babel-plugin-transform-async-to-generator": "^6.8.0",
48 | "babel-plugin-transform-es2015-modules-commonjs": "^6.11.5",
49 | "babel-preset-es2015-rollup": "^1.1.1",
50 | "babel-register": "^6.9.0",
51 | "coveralls": "^2.11.9",
52 | "del": "^2.2.2",
53 | "eslint": "^3.10.2",
54 | "eslint-config-airbnb": "^13.0.0",
55 | "eslint-plugin-import": "^2.2.0",
56 | "eslint-plugin-jsx-a11y": "^2.2.3",
57 | "eslint-plugin-react": "^6.7.1",
58 | "globby": "^6.0.0",
59 | "jest": "^17.0.3",
60 | "perfectionist": "^2.1.3",
61 | "phantom": "^3.1.0",
62 | "postcss-reporter": "^2.0.0",
63 | "replace-classes": "^1.0.0",
64 | "rollup": "^0.36.4",
65 | "rollup-plugin-babel": "^2.6.1"
66 | },
67 | "scripts": {
68 | "start": "npm test -- --watch",
69 | "build": "rm -rf build && rollup -c",
70 | "prepublish": "npm run build",
71 | "postpublish": "rm -rf build && git push --follow-tags",
72 | "coverage": "cat ./coverage/lcov.info | coveralls",
73 | "test": "npm run lint && jest --verbose --coverage",
74 | "lint": "eslint ."
75 | },
76 | "jest": {
77 | "testPathIgnorePatterns": [
78 | "/node_modules",
79 | "/build",
80 | "/coverage",
81 | "__helpers__"
82 | ]
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import babel from 'rollup-plugin-babel';
2 |
3 | export default {
4 | entry: 'src/index.js',
5 | format: 'cjs',
6 | plugins: [
7 | babel({
8 | babelrc: false,
9 | presets: ['es2015-rollup'],
10 | }),
11 | ],
12 | dest: 'build/index.js',
13 | };
14 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { writeFile } from 'fs';
3 |
4 | import mkdirp from 'mkdirp';
5 | import hash from 'shorthash';
6 | import pify from 'pify';
7 | import isString from 'lodash.isstring';
8 |
9 | import postcss from 'postcss';
10 | import mqpacker from 'css-mqpacker';
11 | import stats from 'cssstats';
12 |
13 | import mergeRulesBySelector from './lib/merge-rules-by-selector';
14 | import mergeRulesByDeclarations from './lib/merge-rules-by-declarations';
15 | import unchainSelectors from './lib/unchain-selectors';
16 | import passthruUnatomisable from './lib/passthru-unatomisable';
17 | import resolveDeclarations from './lib/resolve-declarations';
18 | import expandShorthand from './lib/expand-shorthand';
19 | import numberToLetter from './lib/number-to-letter';
20 | import reportStats from './lib/report-stats';
21 | import getContext from './lib/get-context';
22 |
23 | const writeFileP = pify(writeFile);
24 |
25 | const atomise = (css, result, mapPath, mapHandler) => {
26 | reportStats(result, stats(css.toString()), 'magenta', 'Found: ');
27 |
28 | // We'll create a new root of atomic classes to eventually return in the result
29 | const newRoot = [];
30 |
31 | // We also need a place to store the map of original classnames to the atomic ones
32 | const atomicMap = {};
33 |
34 | // Prepare the CSS for parsing:
35 |
36 | // 1. get single instances of each selector if its a list
37 | unchainSelectors(css); // .a, .b {} => .a {}; .b {}
38 |
39 | // 2. merge rules with the same selector if they have the same container (root, at-rule etc)
40 | mergeRulesBySelector(css); // .a {}; .a {} => .a {}
41 |
42 | // 3. expand shorthand rules
43 | expandShorthand(css); // margin to margin-top/right/bottom/left etc
44 |
45 | // 4. get rid over over-ridden props in a rule (like the cascade would).
46 | resolveDeclarations(css); // .a {color: red; color: blue} => .a {color: blue}
47 |
48 | // Now we're ready to start...
49 |
50 | // Pass any keyframes or font-face at-rules straight through
51 | // to the atomic stylesheet
52 | css.walkAtRules(atRule => {
53 | if (['keyframes', 'font-face'].some(name => name === atRule.name)) {
54 | newRoot.push(atRule.clone());
55 | atRule.remove();
56 | }
57 | });
58 |
59 | // Pass any rules which don't use single classnames as selectors
60 | // straight through to the atomic stylesheet (they're not really atomic,
61 | // but maybe the design requires complex selectors – we shouldn't break it)
62 | passthruUnatomisable(css, newRoot, result);
63 |
64 | // Now we have something we can atomise...
65 |
66 | // We'll go through each declaration, and if we've not seen
67 | // it in this context before (in this at-rule, with this pseudo etc),
68 | // we creat a new atomic class that captures it and store that
69 | // against a hash of the declaration + the context, for
70 | // future reference
71 |
72 | // Create a new postcss object to describe an atomic representation
73 | // of a declaration
74 | function createAtomicRule(decl, selector, atrules) {
75 | return atrules.reduce((rule, atrule) => {
76 | const { name, params } = atrule;
77 | return postcss.atRule({ name, params }).append(rule);
78 | }, postcss.rule({ selector }).append(decl));
79 | }
80 |
81 | // create the store for the hash/atomic rule pairs
82 | const atomicRules = {};
83 |
84 | // check each declaration...
85 | css.walkDecls(decl => {
86 | // get the context of a declaration
87 | const context = getContext(decl);
88 | const contextAtrules = context.filter(node => node.type === 'atrule');
89 | const [className, ...contextPseudos] = context.filter(node => node.type === 'rule')[0].selector.split(/::|:/);
90 |
91 | // create a hash from the declaration + context
92 | const key = hash.unique(createAtomicRule(decl, contextPseudos.join(''), contextAtrules).toString());
93 |
94 | // if we've not seen this declaration in this context before...
95 | if (!{}.hasOwnProperty.call(atomicRules, key)) {
96 | // create an atomic rule for it
97 | const shortClassName = numberToLetter(Object.keys(atomicRules).length);
98 | const atomicClassName = `.${shortClassName}${contextPseudos.reduce((pseudos, pseudo) => `${pseudos}:${pseudo}`, '')}`;
99 | newRoot.push(createAtomicRule(decl, atomicClassName, contextAtrules));
100 |
101 | // then store the atomic selector against its hash
102 | atomicRules[key] = shortClassName;
103 | }
104 |
105 | // create a mapping from the selector to which this declaration
106 | // belongs to the atomic rule which captures it
107 | const mapClassName = className.replace(/^\./g, '');
108 | atomicMap[mapClassName] = (atomicMap[mapClassName] || []).concat(atomicRules[key]);
109 | });
110 |
111 | // clear out the old css and return the atomic rules
112 | result.root.removeAll();
113 | result.root.append(newRoot);
114 |
115 | // merge media queries and sort by min-width
116 | mqpacker.pack(result, { sort: true }).css; // eslint-disable-line no-unused-expressions
117 |
118 | // combine any rules that have the same contents
119 | // e.g. unatomiseable/atomisable ones
120 | mergeRulesByDeclarations(css); // body {color: red}; .a {color: red} => body, .a {color: red}
121 |
122 | reportStats(result, stats(css.toString()), 'blue', 'Returned: ');
123 |
124 | // handle the map
125 | mapHandler(atomicMap);
126 | if (isString(mapPath)) {
127 | mkdirp.sync(path.dirname(mapPath));
128 | return writeFileP(mapPath, JSON.stringify(atomicMap, null, 2));
129 | }
130 | return Promise.resolve;
131 | };
132 |
133 | export default postcss.plugin('postcss-atomised', ({
134 | mapPath = path.resolve(process.cwd(), 'atomised-map.json'),
135 | mapHandler = () => {},
136 | } = {}) => (css, result) => atomise(css, result, mapPath, mapHandler));
137 |
--------------------------------------------------------------------------------
/src/lib/expand-shorthand.js:
--------------------------------------------------------------------------------
1 | import postcss from 'postcss';
2 | import parseSides from 'parse-css-sides';
3 | import parseFont from 'parse-css-font';
4 |
5 | // expand shorthand rules
6 | export default css => {
7 | css.walkDecls(decl => {
8 | ['margin', 'padding'].forEach(prop => {
9 | if (decl.prop === prop) {
10 | const sides = parseSides(decl.value);
11 | decl.replaceWith(Object.keys(sides).map(key =>
12 | postcss.decl({ prop: `${prop}-${key}`, value: sides[key] }),
13 | ));
14 | }
15 | });
16 | if (decl.prop === 'font') {
17 | const fontProps = parseFont(decl.value);
18 | decl.replaceWith(Object.keys(fontProps).map(key => {
19 | if (key === 'lineHeight') {
20 | return postcss.decl({ prop: 'line-height', value: fontProps[key] });
21 | }
22 | return postcss.decl({ prop: `font-${key}`, value: fontProps[key].toString() });
23 | }));
24 | }
25 | });
26 | };
27 |
--------------------------------------------------------------------------------
/src/lib/get-context.js:
--------------------------------------------------------------------------------
1 | // Helper to get the context of a node
2 | export default node => {
3 | const parents = [];
4 | let testNode = node;
5 |
6 | while (testNode.parent) {
7 | parents.push(testNode.parent);
8 | testNode = testNode.parent;
9 | }
10 | return parents;
11 | };
12 |
--------------------------------------------------------------------------------
/src/lib/merge-rules-by-declarations.js:
--------------------------------------------------------------------------------
1 | import isEqual from 'lodash.isequal';
2 |
3 | const getDecls = node => node.filter(testNode => testNode.type === 'decl').map(decl => {
4 | const { prop, value } = decl;
5 | return { prop, value };
6 | });
7 |
8 | // merge rules with the same declarations in the same container (root, at-rule etc)
9 | export default css => {
10 | const blacklist = ['keyframes', 'font-face'];
11 | css.walkRules(rule => {
12 | if (blacklist.some(name => name === rule.parent.name)) {
13 | return;
14 | }
15 | const ruleDecls = getDecls(rule.nodes);
16 | rule.parent.each(testRule => {
17 | if (rule !== testRule && isEqual(ruleDecls, getDecls(testRule.nodes))) {
18 | rule.selector += `, ${testRule.selector}`; // eslint-disable-line no-param-reassign
19 | testRule.remove();
20 | }
21 | });
22 | });
23 | };
24 |
--------------------------------------------------------------------------------
/src/lib/merge-rules-by-selector.js:
--------------------------------------------------------------------------------
1 | // merge rules with the same selector in the same container (root, at-rule etc)
2 | export default css => {
3 | css.walkRules(rule => {
4 | rule.parent.each(testRule => {
5 | if (rule.selector === testRule.selector && rule !== testRule) {
6 | testRule.walkDecls(decl => decl.moveTo(rule));
7 | testRule.remove();
8 | }
9 | });
10 | });
11 | };
12 |
--------------------------------------------------------------------------------
/src/lib/number-to-letter.js:
--------------------------------------------------------------------------------
1 | // I have no idea how this actually works, but it does
2 | // I got it off SO #toptier
3 | // http://stackoverflow.com/a/32007970
4 | export default function numberToLetter(i) {
5 | return (i >= 52 ? numberToLetter((i / 52 >> 0) - 1) : '') + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'[i % 52 >> 0]; // eslint-disable-line no-bitwise
6 | }
7 |
--------------------------------------------------------------------------------
/src/lib/passthru-unatomisable.js:
--------------------------------------------------------------------------------
1 | import parseSelector from 'postcss-selector-parser';
2 | import chalk from 'chalk';
3 |
4 | import getContext from './get-context';
5 |
6 | export default (css, newRoot, result) => {
7 | css.walkRules(rule => {
8 | parseSelector(selectors => {
9 | selectors.each(selector => {
10 | const [first, ...rest] = selector.nodes;
11 | if (first.type !== 'class' || rest.some(textSelector => textSelector.type !== 'pseudo')) {
12 | const newRuleInContext = getContext(rule).reduce((newRule, context) => {
13 | if (context !== rule.root()) {
14 | const newParent = context.clone();
15 | newParent.removeAll();
16 | newParent.append(newRule);
17 | return newParent;
18 | }
19 | return newRule;
20 | }, rule.clone());
21 | newRoot.push(newRuleInContext);
22 | rule.remove();
23 | result.warn(`${chalk.magenta(rule.selector)} cannot be atomised`, { node: rule });
24 | }
25 | return false;
26 | });
27 | }).process(rule.selector);
28 | });
29 | };
30 |
--------------------------------------------------------------------------------
/src/lib/report-stats.js:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk';
2 | import prettyBytes from 'pretty-bytes';
3 |
4 | const pluralise = (term, count) => (count > 1 ? `${term}s` : term);
5 |
6 | export default (result, stats, colour, description) => {
7 | result.messages.push({
8 | type: 'atomised-src',
9 | plugin: 'postcss-atomised',
10 | text: chalk[colour](`${description}${stats.declarations.total} ${pluralise('declaration', stats.declarations.total)} in ${prettyBytes(stats.gzipSize)}.`),
11 | });
12 | };
13 |
--------------------------------------------------------------------------------
/src/lib/resolve-declarations.js:
--------------------------------------------------------------------------------
1 | import postcss from 'postcss';
2 | import resolveProp from 'postcss-resolve-prop';
3 | import uniqBy from 'lodash.uniqby';
4 |
5 | // get rid over over-ridden props in a rule
6 | export default css => {
7 | css.walkRules(rule => {
8 | const resolvedDecls = [];
9 | rule.walkDecls(decl => {
10 | const { prop } = decl;
11 | resolvedDecls.push(postcss.decl({
12 | prop,
13 | value: resolveProp(rule, prop),
14 | }));
15 | });
16 | rule.removeAll();
17 | rule.append(uniqBy(resolvedDecls, 'prop'));
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/src/lib/unchain-selectors.js:
--------------------------------------------------------------------------------
1 | // get single instances of each selector if its a list (.a, .b etc)
2 | export default css => {
3 | css.walkRules(rule => {
4 | rule.replaceWith(rule.selectors.map(selector => rule.clone({ selector })));
5 | });
6 | };
7 |
--------------------------------------------------------------------------------