├── .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 | [![npm version](https://badge.fury.io/js/postcss-atomised.svg)](https://badge.fury.io/js/postcss-atomised) [![Build Status](https://travis-ci.org/sndrs/postcss-atomised.svg?branch=master)](https://travis-ci.org/sndrs/postcss-atomised) [![Coverage Status](https://coveralls.io/repos/github/sndrs/postcss-atomised/badge.svg?branch=master)](https://coveralls.io/github/sndrs/postcss-atomised?branch=master) [![Dependency Status](https://dependencyci.com/github/sndrs/postcss-atomised/badge)](https://dependencyci.com/github/sndrs/postcss-atomised) [![Dependency Status](https://david-dm.org/sndrs/postcss-atomised.svg)](https://david-dm.org/sndrs/postcss-atomised) [![devDependency Status](https://david-dm.org/sndrs/postcss-atomised/dev-status.svg)](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 |
25 |
26 |
27 |
28 |
29 |
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 | --------------------------------------------------------------------------------