├── .editorconfig ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.js ├── index.test.js ├── package-lock.json ├── package.json └── utils.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{json,yml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: push 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - name: Use Node.js 14.x 10 | uses: actions/setup-node@v1 11 | with: 12 | node-version: 14.x 13 | - name: npm install, build, and test 14 | run: | 15 | npm install 16 | npm run build 17 | npm test 18 | env: 19 | CI: true 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | yarn-error.log 4 | coverage/ 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .npmignore 2 | .gitignore 3 | .editorconfig 4 | 5 | node_modules/ 6 | npm-debug.log 7 | yarn.lock 8 | 9 | *.test.js 10 | .travis.yml 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | This project adheres to [Semantic Versioning](http://semver.org/). 3 | 4 | ## 0.2.1 (August 29, 2017) 5 | * Fixed node 4.0.0 backward compatibility issue in code 6 | 7 | ## 0.2 (August 29, 2017) 8 | * Added option to create `mainfest.json`. 9 | * Added more tests 10 | 11 | ## 0.1 (August 26, 2017) 12 | * Initial release. 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2017 dacodekid 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PostCSS Hash [![Build Status][ci-img]][ci] 2 | 3 | [PostCSS] plugin to replace output file names with **HASH** algorithms (`md5`, `sha256`, `sha512`, etc) and string length of your choice - for cache busting. 4 | 5 | ```sh 6 | # input 7 | postcss input.css -o output.css 8 | 9 | # output 10 | output.a1b2c3d4e5.css 11 | 12 | # ./manifest.json 13 | { 14 | "output.css": "output.a1b2c3d4e5.css", 15 | } 16 | 17 | ``` 18 | 19 | ```sh 20 | # input 21 | postcss css/in/*.css --dir css/out/ 22 | 23 | # output 24 | file1.a516675ef8.css 25 | file2.aa36634cc4.css 26 | file3.653f682ad9.css 27 | file4.248a1e8f9e.css 28 | file5.07534806bd.css 29 | 30 | # ./manifest.json 31 | { 32 | "file1.css": "file1.a516675ef8.css", 33 | "file2.css": "file2.aa36634cc4.css", 34 | "file3.css": "file3.653f682ad9.css", 35 | "file4.css": "file4.248a1e8f9e.css", 36 | "file5.css": "file5.07534806bd.css" 37 | } 38 | ``` 39 | 40 | ## Usage 41 | 42 | ```js 43 | // postcss.config.js 44 | module.exports = (ctx) => ({ 45 | plugins: { 46 | 'postcss-hash': { 47 | algorithm: 'sha256', 48 | trim: 20, 49 | manifest: './manifest.json' 50 | }, 51 | } 52 | }); 53 | ``` 54 | 55 | ## Options 56 | ### algorithm `(string, default: 'md5')` 57 | Uses node's inbuilt [crypto] module. Pass any `digest algorithm` that is supported in your environment. Possible values are: `md5`, `md4`, `md2`, `sha`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`. 58 | 59 | ### includeMap `(boolean, default: false)` 60 | Setting `includeMap` to `true` will allow postcss-hash to hash the name of the sourcemap, as well hash the CSS _including_ the `sourceMappingURL` comment. You can set this option to true if you care about the hashed fingerprints matching the contents of the CSS file, and don't mind a performance hit of regenerating the CSS twice. 61 | 62 | 63 | ### trim `(number, default: 10)` 64 | Hash's length. 65 | 66 | ### manifest `(string, default: './manifest.json')` 67 | Will output a `manifest` file with `key: value` pairs. 68 | 69 | ### name `(function, default: ({dir, name, hash, ext}) => path.join(dir, name + '.' + hash + ext)` 70 | Pass a function to customise the name of the output file. The function is given an object of string values: 71 | 72 | - dir: the directory name as a string 73 | - name: the name of the file, excluding any extensions 74 | - hash: the resulting hash digest of the file 75 | - ext: the extension of the file 76 | 77 | **NOTE:** 78 | 1. The values will be either appended or replaced. If this file needs be recreated on each run, you'll have to manually delete it. 79 | 2. `key`s are generated with files' `basename`. If you have `./input/A/one.css` & `./input/B/one.css`, only the last entry will exist. 80 | 81 | ### updateEntry `(function, default: (originalName, hashedName) => { "fileName.css": "hashedName.css"}` 82 | 83 | Pass a function to customize manifest entries. The function is given 84 | 85 | - `originalName`: the original file name. 86 | - `hashedName`: the compiled file name. 87 | 88 | The function must return an object with the fileName as a key and whatever value you want. E.g.: 89 | 90 | ```js 91 | function e(originalName, hashedName) { 92 | var newData = {}; 93 | var key = path.parse(originalName).base; 94 | var value = path.parse(hashedName).base; 95 | 96 | newData[key] = { src: value, css: true }; 97 | 98 | return newData; 99 | } 100 | ``` 101 | 102 | See [PostCSS] docs for examples for your environment. 103 | 104 | ``` 105 | Version: 0.2.0 106 | Updated on: August 29, 2017 107 | ``` 108 | 109 | [PostCSS]: https://github.com/postcss/postcss 110 | [ci-img]: https://travis-ci.org/dacodekid/postcss-hash.svg 111 | [ci]: https://travis-ci.org/dacodekid/postcss-hash 112 | [crypto]: https://nodejs.org/api/crypto.html 113 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { readFileSync, writeFileSync } = require("fs"); 4 | const { dirname, basename } = require("path"); 5 | const MapGenerator = require("postcss/lib/map-generator"); 6 | const utils = require("./utils"); 7 | const mkdirp = require("mkdirp"); 8 | 9 | module.exports = opts => { 10 | opts = Object.assign( 11 | { 12 | algorithm: "md5", 13 | trim: 10, 14 | manifest: "./manifest.json", 15 | includeMap: false, 16 | name: utils.defaultName, 17 | updateEntry: utils.updateEntry 18 | }, 19 | opts 20 | ); 21 | 22 | return { 23 | postcssPlugin: "postcss-hash", 24 | OnceExit(root, { result, stringify }) { 25 | // replace filename 26 | const originalName = result.opts.to; 27 | result.opts.to = utils.rename(originalName, root.toString(), opts); 28 | 29 | // In order to get content addressable hash names, we need to generate 30 | // and hash the map file first, which gives us the ability to hash that, 31 | // then we can do a full map.generate() which will apply the sourceMappingURL 32 | // to the CSS file, allowing us to do a full hash of the CSS including 33 | // thes sourceMappingURL comment. 34 | if (opts.includeMap) { 35 | // Extract the stringifier 36 | let str = stringify; 37 | if (result.opts.syntax) str = result.opts.syntax.stringify; 38 | if (result.opts.stringifier) str = result.opts.stringifier; 39 | if (str.stringify) str = str.stringify; 40 | 41 | // Generate the sourceMap contents 42 | const map = new MapGenerator(str, root, result.opts); 43 | map.generateString(); 44 | 45 | const hash = utils.rename( 46 | originalName, 47 | map.map.toString(), 48 | opts 49 | ); 50 | 51 | // If the sourcemap annotation option is set, then we can name the sourcemap 52 | // based on the contents of its map, so change the option to be a string. 53 | if (result.opts.map) { 54 | result.opts.map.annotation = basename(`${hash}.map`); 55 | } 56 | 57 | // need to call map.generate() which applies the sourceMappingURL comment 58 | // to the CSS and returns it as res[0] 59 | const res = map.generate(); 60 | 61 | result.opts.to = utils.rename(originalName, res[0], opts); 62 | } else { 63 | result.opts.to = utils.rename( 64 | originalName, 65 | root.toString(), 66 | opts 67 | ); 68 | } 69 | 70 | // create/update manifest.json 71 | const newData = utils.data(originalName, result.opts.to, opts); 72 | 73 | // You're probably thinking "Why not make all of the following async?!" 74 | // Well, using the async versions causes race conditions when this plugin 75 | // is called multiple times. Try switching to async versions and running the tests 76 | // and you'll see they fail 77 | mkdirp.sync(dirname(opts.manifest)); 78 | let oldData = {}; 79 | try { 80 | oldData = JSON.parse(readFileSync(opts.manifest, "utf-8")); 81 | } catch (e) { 82 | oldData = {}; 83 | } 84 | const data = JSON.stringify( 85 | Object.assign(oldData, newData), 86 | null, 87 | 2 88 | ); 89 | writeFileSync(opts.manifest, data, "utf-8"); 90 | } 91 | }; 92 | }; 93 | 94 | module.exports.postcss = true; 95 | -------------------------------------------------------------------------------- /index.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const postcss = require("postcss"); 4 | const { join } = require("path"); 5 | const { tmpdir } = require("os"); 6 | const { readFileSync, writeFileSync, readdirSync, unlinkSync } = require("fs"); 7 | const utils = require("./utils"); 8 | const plugin = require("./"); 9 | 10 | const tryUnlink = file => { 11 | try { 12 | unlinkSync(file); 13 | } catch (e) { 14 | // Ignore 15 | } 16 | }; 17 | 18 | const CSS_FILES = { 19 | "file01.css": ".a {}", 20 | "file02.css": ".b {background-color: #fff}", 21 | "file03.scss": ".c {}" 22 | }; 23 | const MANIFEST_FILES = ["manifest.json"]; 24 | 25 | beforeEach(() => { 26 | MANIFEST_FILES.forEach(file => tryUnlink(join(tmpdir(), file))); 27 | Object.keys(CSS_FILES).forEach(file => 28 | writeFileSync(join(tmpdir(), file), CSS_FILES[file]) 29 | ); 30 | }); 31 | 32 | afterEach(() => { 33 | Object.keys(CSS_FILES) 34 | .concat(MANIFEST_FILES) 35 | .forEach(file => tryUnlink(join(tmpdir(), file))); 36 | }); 37 | 38 | test("hash module", () => { 39 | const file = join(tmpdir(), "file01.css"); 40 | 41 | expect.assertions(1); 42 | expect(utils.hash(readFileSync(file, "utf-8"), "sha256", 5)).toHaveLength( 43 | 5 44 | ); 45 | }); 46 | 47 | test("defaultName module", () => { 48 | const parts = { 49 | dir: "in", 50 | name: "file01", 51 | hash: "deadbeef", 52 | ext: ".css" 53 | }; 54 | expect.assertions(1); 55 | expect(utils.defaultName(parts)).toBe("in/file01.deadbeef.css"); 56 | }); 57 | 58 | test("rename module", () => { 59 | const file = join(tmpdir(), "file01.css"); 60 | const opts = { algorithm: "sha256", trim: 5, name: utils.defaultName }; 61 | const hash = utils.hash( 62 | readFileSync(file, "utf-8"), 63 | opts.algorithm, 64 | opts.trim 65 | ); 66 | 67 | expect.assertions(1); 68 | expect(utils.rename(file, readFileSync(file, "utf-8"), opts)).toMatch( 69 | new RegExp(hash) 70 | ); 71 | }); 72 | 73 | test("data module", () => { 74 | const file = join(tmpdir(), "file01.css"); 75 | const opts = { 76 | algorithm: "md5", 77 | trim: 5, 78 | name: utils.defaultName, 79 | updateEntry: utils.updateEntry 80 | }; 81 | const renamedFile = utils.rename(file, readFileSync(file, "utf-8"), opts); 82 | 83 | expect.assertions(1); 84 | expect(utils.data(file, renamedFile, opts)).toBeInstanceOf(Object); 85 | }); 86 | 87 | test("plugin", () => { 88 | const opts = { 89 | algorithm: "md5", 90 | trim: 5, 91 | manifest: join(tmpdir(), "manifest.json") 92 | }; 93 | 94 | const files = readdirSync(tmpdir()).filter(name => /css$/.test(name)); 95 | 96 | return Promise.all( 97 | files.map(file => { 98 | const filePath = join(tmpdir(), file); 99 | return postcss([plugin(opts)]) 100 | .process(readFileSync(filePath, "utf-8"), { 101 | from: file, 102 | to: file 103 | }) 104 | .then(result => { 105 | const hash = utils.hash( 106 | readFileSync(filePath, "utf-8"), 107 | opts.algorithm, 108 | opts.trim 109 | ); 110 | 111 | expect(result.opts.to).toMatch(new RegExp(hash)); 112 | expect(result.warnings().length).toBe(0); 113 | const manifest = JSON.parse( 114 | readFileSync(join(tmpdir(), "manifest.json")) 115 | ); 116 | expect(typeof manifest).toBe("object"); 117 | expect(manifest).toHaveProperty([file], result.opts.to); 118 | }); 119 | }) 120 | ); 121 | }); 122 | 123 | test("plugin ensures manifest directory is created", () => { 124 | const opts = { 125 | algorithm: "md5", 126 | trim: 5, 127 | manifest: join(tmpdir(), "foo/bar/baz/manifest.json") 128 | }; 129 | const filePath = join(tmpdir(), "file01.css"); 130 | return postcss([plugin(opts)]) 131 | .process(readFileSync(filePath, "utf-8"), { 132 | from: filePath, 133 | to: filePath 134 | }) 135 | .then(result => { 136 | const hash = utils.hash( 137 | readFileSync(filePath, "utf-8"), 138 | opts.algorithm, 139 | opts.trim 140 | ); 141 | 142 | expect(result.opts.to).toMatch(new RegExp(hash)); 143 | expect(result.warnings().length).toBe(0); 144 | }); 145 | }); 146 | 147 | test("plugin will hash sourcemap with includeMap", () => { 148 | const opts = { 149 | algorithm: "sha512", 150 | trim: 5, 151 | includeMap: true, 152 | manifest: join(tmpdir(), "foo/bar/baz/manifest.json") 153 | }; 154 | const filePath = join(tmpdir(), "file01.css"); 155 | return postcss([plugin(opts)]) 156 | .process(readFileSync(filePath, "utf-8"), { 157 | from: filePath, 158 | to: filePath, 159 | map: { inline: false }, 160 | }) 161 | .then(result => { 162 | const expectedOutput = '.a {}\n/*# sourceMappingURL=file01.b5686.css.map */'; 163 | const hash = utils.hash(expectedOutput, opts.algorithm, opts.trim); 164 | 165 | expect(result.css).toEqual(expectedOutput); 166 | expect(result.opts.to).toMatch(new RegExp(hash)); 167 | expect(result.warnings().length).toBe(0); 168 | }); 169 | }); 170 | 171 | test("plugin with custom manifest generation", () => { 172 | const opts = { 173 | algorithm: "md5", 174 | trim: 5, 175 | manifest: join(tmpdir(), "manifest.json"), 176 | updateEntry: (original, hashed) => ({ 177 | [original]: { 178 | src: hashed 179 | } 180 | }) 181 | }; 182 | 183 | const files = readdirSync(tmpdir()).filter(name => /css$/.test(name)); 184 | 185 | return Promise.all( 186 | files.map(file => { 187 | const filePath = join(tmpdir(), file); 188 | return postcss([plugin(opts)]) 189 | .process(readFileSync(filePath, "utf-8"), { 190 | from: file, 191 | to: file 192 | }) 193 | .then(result => { 194 | const hash = utils.hash( 195 | readFileSync(filePath, "utf-8"), 196 | opts.algorithm, 197 | opts.trim 198 | ); 199 | 200 | expect(result.opts.to).toMatch(new RegExp(hash)); 201 | expect(result.warnings().length).toBe(0); 202 | const manifest = JSON.parse( 203 | readFileSync(join(tmpdir(), "manifest.json")) 204 | ); 205 | expect(typeof manifest).toBe("object"); 206 | expect(manifest).toHaveProperty([file], { 207 | src: result.opts.to 208 | }); 209 | }); 210 | }) 211 | ); 212 | }); 213 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postcss-hash", 3 | "version": "2.0.0", 4 | "description": "PostCSS plugin to replace output file names with HASH algorithms (md5, sha256, sha512, etc) and string length of your choice - for cache busting", 5 | "keywords": [ 6 | "postcss", 7 | "css", 8 | "postcss-plugin", 9 | "postcss-hash", 10 | "cache", 11 | "cache-busting", 12 | "cache-buster", 13 | "cachebuster", 14 | "hash", 15 | "md5", 16 | "md4", 17 | "md2", 18 | "sha", 19 | "sha1", 20 | "sha224", 21 | "sha256", 22 | "sha384", 23 | "sha512", 24 | "mdc2", 25 | "ripemd160", 26 | "name-changer", 27 | "manifest" 28 | ], 29 | "author": "dacodekid", 30 | "license": "MIT", 31 | "repository": "dacodekid/postcss-hash", 32 | "bugs": { 33 | "url": "https://github.com/dacodekid/postcss-hash/issues" 34 | }, 35 | "homepage": "https://github.com/dacodekid/postcss-hash", 36 | "dependencies": { 37 | "mkdirp": "^0.5.1" 38 | }, 39 | "devDependencies": { 40 | "eslint": "^5.5.0", 41 | "jest": "^23.6.0", 42 | "postcss": "^8.3.0", 43 | "prettier": "^1.14.2" 44 | }, 45 | "scripts": { 46 | "lint": "eslint --init", 47 | "test": "eslint *.js && jest", 48 | "test:watch": "jest --watch" 49 | }, 50 | "eslintConfig": { 51 | "extends": "eslint:recommended", 52 | "parserOptions": { 53 | "ecmaVersion": 6 54 | }, 55 | "globals": { 56 | "Promise": true 57 | }, 58 | "rules": { 59 | "max-len": [ 60 | 2, 61 | 100 62 | ] 63 | }, 64 | "env": { 65 | "node": true, 66 | "jest": true 67 | } 68 | }, 69 | "jest": { 70 | "testPathIgnorePatterns": [ 71 | "/node_modules/", 72 | "/data/" 73 | ], 74 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|js)$", 75 | "moduleFileExtensions": [ 76 | "js" 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | const path = require("path"); 3 | 4 | /* 5 | a function to get hash value for an given content with desired string length. 6 | input: ('a{}', 'sha', 10) output: a1b2c3d4e5 7 | */ 8 | function hash(css, algorithm, trim) { 9 | return crypto 10 | .createHash(algorithm) 11 | .update(css) 12 | .digest("hex") 13 | .substr(0, trim); 14 | } 15 | 16 | function defaultName(parts) { 17 | return path.join(parts.dir, parts.name + "." + parts.hash + parts.ext); 18 | } 19 | 20 | /* 21 | a function to rename a filename by appending hash value. 22 | input: ('./file.css', 'a {}', {algorithm: 'sha256', trim: 10}) output: ./file.a1b2c3d4e5.css 23 | */ 24 | function rename(file, css, opts) { 25 | return opts.name({ 26 | dir: path.dirname(file), 27 | name: path.basename(file, path.extname(file)), 28 | ext: path.extname(file), 29 | hash: hash(css, opts.algorithm, opts.trim) 30 | }); 31 | } 32 | 33 | /* 34 | will return an object of {oldname: newname} to append/update into manifest file. 35 | input: ('./css/file.css', './file.a1b2c3d4e5.css') output: {"file.css": "file.a1b2c3d4e5.css"} 36 | */ 37 | function data(originalName, hashedName, opts) { 38 | var key = path.parse(originalName).base; 39 | var value = path.parse(hashedName).base; 40 | 41 | return opts.updateEntry(key, value); 42 | } 43 | 44 | function updateEntry(originalName, hashedName) { 45 | return { [originalName]: hashedName }; 46 | } 47 | 48 | module.exports.hash = hash; 49 | module.exports.rename = rename; 50 | module.exports.defaultName = defaultName; 51 | module.exports.data = data; 52 | module.exports.updateEntry = updateEntry; 53 | --------------------------------------------------------------------------------