├── .gitignore ├── Makefile ├── bin └── sqwish ├── package.json ├── README.md ├── example ├── styles.min.css └── styles.css ├── tests └── tests.js └── src └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | test: 4 | node tests/tests.js -------------------------------------------------------------------------------- /bin/sqwish: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../').exec(process.argv.slice(2)) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sqwish", 3 | "description": "a tool for compressing CSS", 4 | "homepage": "https://github.com/ded/sqwish", 5 | "version": "0.2.2", 6 | "author": "Dustin Diaz <@ded>", 7 | "keywords": [ 8 | "minify", 9 | "css", 10 | "compress" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/ded/sqwish.git" 15 | }, 16 | "main": "src/index.js", 17 | "engines": { 18 | "node": ">= 0.4.1" 19 | }, 20 | "directories": { 21 | "lib": "src", 22 | "bin": "bin" 23 | }, 24 | "devDependencies": { 25 | "sink-test": ">= 0.0.6" 26 | }, 27 | "scripts": { 28 | "test": "node tests/tests.js" 29 | }, 30 | "preferGlobal": true, 31 | "bin": { 32 | "sqwish": "bin/sqwish" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Welcome to Sqwish 2 | ================= 3 | 4 | A [Node](http://nodejs.org) based CSS Compressor. It works like this. 5 | 6 | ``` javascript 7 | require('sqwish').minify('body { color: #ff33cc; }'); 8 | // => "body{color:#f3c}" 9 | ``` 10 | 11 | CLI 12 | --- 13 | 14 | Install it. 15 | 16 | $ npm install -g sqwish 17 | 18 | Use it like this: 19 | 20 | $ sqwish app.css # default output is .min.css therefore app.css => app.min.css 21 | $ # or... 22 | $ sqwish css/styles.css -o prod/something-else.min.css 23 | 24 | Notes 25 | ----- 26 | 27 | Sqwish does not attempt to fix invalid CSS, therefore, at minimum, your CSS should at least follow the basic rules: 28 | 29 | ``` css 30 | selectors[,more selectors] { 31 | property: value; 32 | another-property: another value; 33 | } 34 | ``` 35 | 36 | Strict Optimizations 37 | -------------------- 38 | 39 | Aside from regular minification, in --strict mode Sqwish will combine duplicate selectors and merge duplicate properties. 40 | 41 | ``` css 42 | /* before */ 43 | div { 44 | color: orange; 45 | background: red; 46 | } 47 | div { 48 | color: #ff33cc; 49 | margin: 1px 0px 1px 0px; 50 | } 51 | 52 | /* after */ 53 | div{color:#f3c;background:red;margin:1px 0} 54 | ``` 55 | 56 | This mode can be enabled as so: 57 | 58 | sqwish.minify(css, true); 59 | 60 | on the command line 61 | 62 | $ sqwish styles.css --strict 63 | 64 | Developers 65 | ---------- 66 | 67 | Be sure you have the proper testing harness set up ahead of time by installing the sink-test submodule 68 | 69 | $ npm install --dev 70 | 71 | Tests can be added in tests/tests.js, and then run as such: 72 | 73 | $ npm test 74 | 75 | License 76 | ------- 77 | 78 | Sqwish is copyright Dustin Diaz 2011 under MIT License 79 | 80 | **Happy Sqwishing!** -------------------------------------------------------------------------------- /example/styles.min.css: -------------------------------------------------------------------------------- 1 | /*! this file has a copyright thing */ 2 | /*! this is the main copyright of a thing 3 | * it has a url like example.com 4 | * and contact me at @ded 5 | */ 6 | *{color:red;margin:5px;padding:0}fieldset,img{border-color:transparent;border-width:0}a{color:#2276BB;text-decoration:none}a:hover{text-decoration:underline}ul{list-style:none}ul.dot li:before{content:"\00B7 \0020"}hr{display:none}div.hr{font-size:16px;line-height:1;margin:.5em 0;overflow:hidden;width:100%;background:#eee;height:1px}#delete #content .reallyimportant{padding:1em;font-size:120%;background:#ffffe3;-webkit-border-radius:5px;-moz-border-radius:5px}#remember_delete_message{width:80%;padding:10px;margin:5px 0 15px 15px;-moz-box-shadow:0px 1px 2px rgba(0,0,0,1);-webkit-box-shadow:0px 1px 2px rgba(0,0,0,1)}input[type=text],input[type=password],select,textarea{-webkit-transition:border linear .2s,-webkit-box-shadow linear .2s;-moz-transition:border linear .2s,-moz-box-shadow linear .2s;transition:border linear .2s,box-shadow linear .2s;border:1px solid #aaa}input[type=text]:focus,input[type=password]:focus,textarea:focus{-webkit-box-shadow:0 0 8px rgba(82,168,236,.5);-moz-box-shadow:0 0 8px rgba(82,168,236,.5);box-shadow:0 0 8px rgba(82,168,236,.5);border-color:rgba(82,168,236,.75)!important;outline:none}input.with-box:focus,input[class*=search]:focus,input[id*=search]:focus{-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;border-color:inherit!important}body.email-address-nag .email-address-nag-banner{display:block}.no-display{display:none}.email-address-nag .content-bubble-arrow{display:none}.email-address-nag-banner,.employee-nag-banner{border-bottom:1px solid #c0deed;-webkit-border-top-right-radius:5px;-webkit-border-top-left-radius:5px;-moz-border-radius-topright:5px;-moz-border-radius-topleft:5px;padding:13px;color:#333;background-color:#ffd;display:none;margin-top:23px}.employee-nag-banner{display:block}body,p[rel="stuff and things"]{background:orange;font:100 2/300px helvetica neue,helvetica;color:red} -------------------------------------------------------------------------------- /example/styles.css: -------------------------------------------------------------------------------- 1 | /* even more crap */ 2 | /*! this file has a copyright thing */ 3 | * { 4 | margin: 0; padding: 0 5 | } 6 | * { 7 | margin: 5px; 8 | color: red; 9 | } 10 | /* media queries */ 11 | @media screen and (max-device-width: 480px) { 12 | .column { 13 | float: none; 14 | } 15 | } 16 | fieldset, img { 17 | border-width: 0; 18 | border-color: transparent; 19 | } 20 | a { 21 | text-decoration: none; 22 | color: #2276BB; 23 | } 24 | a:hover { 25 | text-decoration: underline; 26 | } 27 | ul { 28 | list-style: none 29 | } 30 | ul.dot li:before { 31 | content: "\00B7 \0020"; 32 | } 33 | hr { 34 | display: none; 35 | } 36 | div.hr { 37 | height: 1px; 38 | background: #eee; 39 | width: 100%; 40 | overflow: hidden; 41 | margin: .5em 0; 42 | line-height: 1; 43 | font-size: 16px; 44 | } 45 | #delete #content .reallyimportant { 46 | -moz-border-radius: 5px; 47 | -webkit-border-radius: 5px; 48 | background: #ffffe3; 49 | font-size: 120%; 50 | padding: 1em 51 | } 52 | #remember_delete_message { 53 | -webkit-box-shadow: 0px 1px 2px rgba(0,0,0,1); 54 | -moz-box-shadow: 0px 1px 2px rgba(0,0,0,1); 55 | margin: 5px 0 15px 15px; 56 | padding: 10px; 57 | width: 80% 58 | } 59 | input[type=text],input[type=password],select,textarea { 60 | border: 1px solid #aaa; 61 | transition: border linear .2s,box-shadow linear .2s; 62 | -moz-transition: border linear .2s,-moz-box-shadow linear .2s; 63 | -webkit-transition: border linear .2s,-webkit-box-shadow linear .2s 64 | } 65 | input[type=text]:focus,input[type=password]:focus,textarea:focus { 66 | outline: none; 67 | border-color: rgba(82,168,236,.75) !important; 68 | box-shadow: 0 0 8px rgba(82,168,236,.5); 69 | -moz-box-shadow: 0 0 8px rgba(82,168,236,.5); 70 | -webkit-box-shadow: 0 0 8px rgba(82,168,236,.5) 71 | } 72 | input.with-box:focus,input[class*=search]:focus,input[id*=search]:focus { 73 | border-color: inherit !important; 74 | box-shadow: none; 75 | -moz-box-shadow: none; 76 | -webkit-box-shadow: none 77 | } 78 | body.email-address-nag .email-address-nag-banner { 79 | display: block 80 | } 81 | .no-display { 82 | display: none 83 | } 84 | .email-address-nag .content-bubble-arrow { 85 | display: none 86 | } 87 | .email-address-nag-banner,.employee-nag-banner { 88 | margin-top: 23px; 89 | display: none; 90 | background-color: #ffd; 91 | color: #333; 92 | padding: 13px; 93 | -moz-border-radius-topleft: 5px; 94 | -moz-border-radius-topright: 5px; 95 | -webkit-border-top-left-radius: 5px; 96 | -webkit-border-top-right-radius: 5px; 97 | border-bottom: 1px solid #c0deed 98 | } 99 | .employee-nag-banner { 100 | display: block 101 | } 102 | 103 | /*! this is the main copyright of a thing 104 | * it has a url like example.com 105 | * and contact me at @ded 106 | */ 107 | body, p[rel="stuff and things"] { 108 | color: red; 109 | font: 100 2/300px helvetica neue, helvetica; 110 | background: orange; 111 | } -------------------------------------------------------------------------------- /tests/tests.js: -------------------------------------------------------------------------------- 1 | var sink = require('../node_modules/sink-test/') 2 | , start = sink.start 3 | , sink = sink.sink 4 | , sqwish = require('../src') 5 | 6 | sink('basic mode', function (test, ok) { 7 | 8 | test('whitespace', 1, function () { 9 | var input = ' \n body { color : red ; background : blue ; \r\n } ' 10 | , expected = 'body{color:red;background:blue}' 11 | , actual = sqwish.minify(input) 12 | ok(actual == expected, 'all appropriate whitespace was removed') 13 | }) 14 | 15 | test('long hex to short hex', 1, function () { 16 | var input = 'p { color: #ffcc33; }' 17 | , expected = 'p{color:#fc3}' 18 | , actual = sqwish.minify(input) 19 | ok(actual == expected, 'collapsed #ffcc33 to #fc3') 20 | }) 21 | 22 | test('IE long hex is kept as long hex', 1, function () { 23 | var input = "body { filter: progid:DXImageTransform.Microsoft.gradient(gradientType=0, startColorstr='#FFF2F2F2', endColorstr='#FFFFFFFF'); }" 24 | , expected = "body{filter:progid:DXImageTransform.Microsoft.gradient(gradientType=0,startColorstr='#FFF2F2F2',endColorstr='#FFFFFFFF')}" 25 | , actual = sqwish.minify(input) 26 | ok(actual == expected, 'IE long hexes are kept that way') 27 | }) 28 | 29 | test('IE 6-digit hex is kept as 6-digit hex', 1, function () { 30 | var input = "body { filter: progid:DXImageTransform.Microsoft.gradient(gradientType=0, startColorstr='#F2F2F2', endColorstr='#FFFFFF'); }" 31 | , expected = "body{filter:progid:DXImageTransform.Microsoft.gradient(gradientType=0,startColorstr='#F2F2F2',endColorstr='#FFFFFF')}" 32 | , actual = sqwish.minify(input) 33 | ok(actual == expected, 'IE 6-digit hexes are kept that way') 34 | }) 35 | 36 | test('longhand values to shorthand values', 1, function () { 37 | var input = 'p { margin: 0px 1px 0px 1px }' 38 | , expected = 'p{margin:0 1px}' 39 | , actual = sqwish.minify(input) 40 | ok(actual == expected, 'collapsed 0px 1px 0px 1px to 0 1px') 41 | }) 42 | 43 | test('certain longhand values are maintained', 1, function () { 44 | var input = 'p { margin: 11px 1px 1px 1px }' 45 | , expected = 'p{margin:11px 1px 1px 1px}' 46 | , actual = sqwish.minify(input) 47 | ok(actual == expected, 'maintained 11px 1px 1px 1px') 48 | }) 49 | 50 | test('certain double-specified longhand values are maintained', 1, function () { 51 | var input = 'p { margin: 12px 12px 2px 12px }' 52 | , expected = 'p{margin:12px 12px 2px 12px}' 53 | , actual = sqwish.minify(input) 54 | ok(actual == expected, 'maintained 12px 12px 2px 12px') 55 | }) 56 | 57 | test('does not break with @media queries', 2, function () { 58 | var input = '@media screen and (max-device-width: 480px) {' + 59 | ' .column {' + 60 | ' float: none;' + 61 | ' }' + 62 | '}' 63 | , expected = '@media screen and (max-device-width:480px){.column{float:none}}' 64 | , strictOutput = sqwish.minify(input, true) 65 | , regularOutput = sqwish.minify(input) 66 | console.log(strictOutput) 67 | console.log(regularOutput) 68 | ok(regularOutput == expected, 'media queries do not blow up') 69 | ok(strictOutput == expected, 'media queries do not blow up in strict mode') 70 | 71 | }) 72 | 73 | }) 74 | 75 | sink('strict mode', function (test, ok) { 76 | test('combined rules', 1, function () { 77 | var input = 'div { color: red; } div { background: orange; }' 78 | , expected = 'div{background:orange;color:red}' 79 | , actual = sqwish.minify(input, true) 80 | ok(actual == expected, 'collapsed div into a single rule') 81 | }) 82 | 83 | test('combine duplicate properties', 1, function () { 84 | var input = 'div { color: red; } div { color: #ffcc88; }' 85 | , expected = 'div{color:#fc8}' 86 | , actual = sqwish.minify(input, true) 87 | ok(actual == expected, 'collapsed duplicate into a single declaration') 88 | }) 89 | 90 | }) 91 | 92 | start() 93 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Sqwish - a CSS Compressor 3 | * Copyright Dustin Diaz 2011 4 | * https://github.com/ded/sqwish 5 | * License MIT 6 | */ 7 | 8 | var fs = require('fs'); 9 | 10 | function uniq(ar) { 11 | var a = [], i, j; 12 | label: 13 | for (i = ar.length - 1; i >= 0; i--) { 14 | for (j = a.length - 1; j >= 0; j--) { 15 | if (a[j] == ar[i]) { 16 | continue label 17 | } 18 | } 19 | a[a.length] = ar[i] 20 | } 21 | return a 22 | } 23 | 24 | function sqwish(css, strict) { 25 | // allow /*! bla */ style comments to retain copyrights etc. 26 | var comments = css.match(/\/\*![\s\S]+?\*\//g); 27 | 28 | css = css.trim() // give it a solid trimming to start 29 | 30 | // comments 31 | .replace(/\/\*[\s\S]+?\*\//g, '') 32 | 33 | // line breaks and carriage returns 34 | .replace(/[\n\r]/g, '') 35 | 36 | // space between selectors, declarations, properties and values 37 | .replace(/\s*([:;,{}])\s*/g, '$1') 38 | 39 | // replace multiple spaces with single spaces 40 | .replace(/\s+/g, ' ') 41 | 42 | // space between last declaration and end of rule 43 | // also remove trailing semi-colons on last declaration 44 | .replace(/;}/g, '}') 45 | 46 | // this is important 47 | .replace(/\s+(!important)/g, '$1') 48 | 49 | // convert longhand hex to shorthand hex 50 | .replace(/#([a-fA-F0-9])\1([a-fA-F0-9])\2([a-fA-F0-9])\3(?![a-fA-F0-9])/g, '#$1$2$3') 51 | // Restore Microsoft longhand hex 52 | .replace(/(Microsoft[^;}]*)#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])(?![a-fA-F0-9])/g, '$1#$2$2$3$3$4$4') 53 | 54 | // replace longhand values with shorthand '5px 5px 5px 5px' => '5px' 55 | .replace(/\b(\d+[a-z]{2}) \1 \1 \1/gi, '$1') 56 | 57 | // replace double-specified longhand values with shorthand '5px 2px 5px 2px' => '5px 2px' 58 | .replace(/\b(\d+[a-z]{2}) (\d+[a-z]{2}) \1 \2/gi, '$1 $2') 59 | 60 | // replace 0px with 0 61 | .replace(/([\s|:])[0]+px/g, '$10') 62 | 63 | if (strict) { 64 | css = strict_css(css) 65 | } 66 | 67 | // put back in copyrights 68 | if (comments) { 69 | comments = comments ? comments.join('\n') : '' 70 | css = comments + '\n' + css 71 | } 72 | return css 73 | } 74 | 75 | function strict_css(css) { 76 | // now some super fun funky shit where we remove duplicate declarations 77 | // into combined rules 78 | 79 | // store global dict of all rules 80 | var ruleList = {} 81 | , rules = css.match(/([^{]+\{[^}]+\})+?/g) 82 | 83 | // lets find the dups 84 | rules.forEach(function (rule) { 85 | // break rule into selector|declaration parts 86 | var parts = rule.match(/([^{]+)\{([^}]+)/) 87 | , selector = parts[1] 88 | , declarations = parts[2] 89 | 90 | // start new list if it wasn't created already 91 | if (!ruleList[selector]) { 92 | ruleList[selector] = [] 93 | } 94 | 95 | declarations = declarations.split(';') 96 | // filter out duplicate properties 97 | ruleList[selector] = ruleList[selector].filter(function (decl) { 98 | var prop = decl.match(/[^:]+/)[0] 99 | // pre-existing properties are not wanted anymore 100 | return !declarations.some(function (dec) { 101 | // must include '^' as to not confuse "color" with "border-color" etc. 102 | return dec.match(new RegExp('^' + prop.replace(/[-\/\^$*+?.()|[]{}]/g, '\$&') + ':')) 103 | }) 104 | }) 105 | 106 | // latter takes presedence :) 107 | ruleList[selector] = ruleList[selector].concat(declarations); 108 | // still dups? just in case 109 | ruleList[selector] = uniq(ruleList[selector]) 110 | }) 111 | 112 | // reset css because we're gonna recreate the whole shabang. 113 | css = '' 114 | for (var selector in ruleList) { 115 | var joinedRuleList = ruleList[selector].join(';') 116 | css += selector + '{' + (joinedRuleList).replace(/;$/, '') + '}' 117 | } 118 | return css 119 | } 120 | 121 | module.exports.exec = function (args) { 122 | var out, data 123 | , read = args[0] 124 | if (out = args.indexOf('-o') != -1) { 125 | out = args[out + 1] 126 | } else { 127 | out = read.replace(/\.css$/, '.min.css') 128 | } 129 | if (args.indexOf('-v') != -1) { 130 | console.log('compressing ' + read + ' to ' + out + '...') 131 | } 132 | data = fs.readFileSync(read, 'utf8') 133 | fs.writeFileSync(out, sqwish(data, (~args.indexOf('--strict'))), 'utf8') 134 | }; 135 | module.exports.minify = function (css, strict) { 136 | return sqwish(css, strict) 137 | }; 138 | --------------------------------------------------------------------------------