├── .npmrc ├── .gitattributes ├── .gitignore ├── .github ├── issue_template.md ├── pull_request_template.md ├── security.md ├── contributing.md └── workflows │ └── main.yml ├── test ├── test.manifest-fixture.json ├── _helper.js ├── rev.js └── manifest.js ├── .editorconfig ├── license ├── package.json ├── index.js ├── integration.md └── readme.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | #### Steps to reproduce 4 | 5 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Resolves {ISSUE_ID} 2 | 3 | ### What's new? 4 | -------------------------------------------------------------------------------- /test/test.manifest-fixture.json: -------------------------------------------------------------------------------- 1 | { 2 | "app.js": "app-a41d8cd1.js", 3 | "unicorn.css": "unicorn-d41d8cd98f.css" 4 | } 5 | -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | ## Contribution guidelines 2 | 3 | - Be polite. 4 | - Branch naming convention: `{ IssueID }-short-description`. 5 | - Commit naming convention: `{ IssueID } { Issue Type (e.g. Enhancement, Bug, etc.) } short description` 6 | - Run `npm test` 7 | - Run `xo --fix` if you have any [coding style](https://github.com/sindresorhus/xo) issues. Most of them will be fixed for you. 8 | -------------------------------------------------------------------------------- /test/_helper.js: -------------------------------------------------------------------------------- 1 | import {Buffer} from 'node:buffer'; 2 | import Vinyl from 'vinyl'; 3 | 4 | export default function createFile({ 5 | path, 6 | revOrigPath, 7 | revOrigBase, 8 | origName, 9 | revName, 10 | cwd, 11 | base, 12 | contents = '', 13 | }) { 14 | const file = new Vinyl({ 15 | path, 16 | cwd, 17 | base, 18 | contents: Buffer.from(contents), 19 | }); 20 | file.revOrigPath = revOrigPath; 21 | file.revOrigBase = revOrigBase; 22 | file.origName = origName; 23 | file.revName = revName; 24 | 25 | return file; 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 24 14 | - 22 15 | - 20 16 | steps: 17 | - uses: actions/checkout@v5 18 | - uses: actions/setup-node@v6 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm install 22 | - run: npm test 23 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gulp-rev", 3 | "version": "12.0.0", 4 | "description": "Static asset revisioning by appending content hash to filenames: unicorn.css => unicorn-d41d8cd98f.css", 5 | "license": "MIT", 6 | "repository": "sindresorhus/gulp-rev", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": "./index.js", 15 | "sideEffects": false, 16 | "engines": { 17 | "node": ">=20" 18 | }, 19 | "scripts": { 20 | "test": "xo && ava" 21 | }, 22 | "files": [ 23 | "index.js" 24 | ], 25 | "keywords": [ 26 | "gulpplugin", 27 | "rev", 28 | "revving", 29 | "revision", 30 | "hash", 31 | "optimize", 32 | "version", 33 | "versioning", 34 | "cache", 35 | "expire", 36 | "static", 37 | "asset", 38 | "assets" 39 | ], 40 | "dependencies": { 41 | "gulp-plugin-extras": "^1.1.0", 42 | "modify-filename": "^2.0.0", 43 | "rev-hash": "^4.1.0", 44 | "rev-path": "^3.0.0", 45 | "sort-keys": "^6.0.0", 46 | "vinyl": "^3.0.1", 47 | "vinyl-file": "^5.0.0" 48 | }, 49 | "devDependencies": { 50 | "ava": "^6.4.1", 51 | "p-event": "^7.0.0", 52 | "xo": "^1.2.3" 53 | }, 54 | "peerDependencies": { 55 | "gulp": ">=4" 56 | }, 57 | "peerDependenciesMeta": { 58 | "gulp": { 59 | "optional": true 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/rev.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import test from 'ava'; 3 | import {pEvent, pEventIterator} from 'p-event'; 4 | import rev from '../index.js'; 5 | import createFile from './_helper.js'; 6 | 7 | test('revs files', async t => { 8 | const stream = rev(); 9 | const data = pEvent(stream, 'data'); 10 | 11 | stream.end(createFile({ 12 | path: 'unicorn.css', 13 | })); 14 | 15 | const file = await data; 16 | t.is(file.path, 'unicorn-d41d8cd98f.css'); 17 | t.is(file.revOrigPath, 'unicorn.css'); 18 | }); 19 | 20 | test('adds the revision hash before the first `.` in the filename', async t => { 21 | const stream = rev(); 22 | const data = pEvent(stream, 'data'); 23 | 24 | stream.end(createFile({ 25 | path: 'unicorn.css.map', 26 | })); 27 | 28 | const file = await data; 29 | t.is(file.path, 'unicorn-d41d8cd98f.css.map'); 30 | t.is(file.revOrigPath, 'unicorn.css.map'); 31 | }); 32 | 33 | test('stores the hashes for later', async t => { 34 | const stream = rev(); 35 | const data = pEvent(stream, 'data'); 36 | 37 | stream.end(createFile({ 38 | path: 'unicorn.css', 39 | })); 40 | 41 | const file = await data; 42 | t.is(file.path, 'unicorn-d41d8cd98f.css'); 43 | t.is(file.revOrigPath, 'unicorn.css'); 44 | t.is(file.revHash, 'd41d8cd98f'); 45 | }); 46 | 47 | test('handles sourcemaps transparently', async t => { 48 | const stream = rev(); 49 | const data = pEventIterator(stream, 'data', { 50 | resolutionEvents: ['finish'], 51 | }); 52 | 53 | stream.write(createFile({ 54 | path: 'pastissada.css', 55 | })); 56 | 57 | stream.end(createFile({ 58 | path: 'maps/pastissada.css.map', 59 | contents: JSON.stringify({file: 'pastissada.css'}), 60 | })); 61 | 62 | let sourcemapCount = 0; 63 | for await (const file of data) { 64 | if (path.extname(file.path) === '.map') { 65 | t.is(file.path, path.normalize('maps/pastissada-d41d8cd98f.css.map')); 66 | sourcemapCount++; 67 | } 68 | } 69 | 70 | t.is(sourcemapCount, 1); 71 | }); 72 | 73 | test('handles unparseable sourcemaps correctly', async t => { 74 | const stream = rev(); 75 | const data = pEventIterator(stream, 'data', { 76 | resolutionEvents: ['finish'], 77 | }); 78 | 79 | stream.write(createFile({ 80 | path: 'pastissada.css', 81 | })); 82 | 83 | stream.end(createFile({ 84 | path: 'pastissada.css.map', 85 | contents: 'Wait a minute, this is invalid JSON!', 86 | })); 87 | 88 | let sourcemapCount = 0; 89 | for await (const file of data) { 90 | if (path.extname(file.path) === '.map') { 91 | t.is(file.path, 'pastissada-d41d8cd98f.css.map'); 92 | sourcemapCount++; 93 | } 94 | } 95 | 96 | t.is(sourcemapCount, 1); 97 | }); 98 | 99 | test('okay when the optional sourcemap.file is not defined', async t => { 100 | const stream = rev(); 101 | const data = pEventIterator(stream, 'data', { 102 | resolutionEvents: ['finish'], 103 | }); 104 | 105 | stream.write(createFile({ 106 | path: 'pastissada.css', 107 | })); 108 | 109 | stream.end(createFile({ 110 | path: 'pastissada.css.map', 111 | contents: JSON.stringify({}), 112 | })); 113 | 114 | let sourcemapCount = 0; 115 | for await (const file of data) { 116 | if (path.extname(file.path) === '.map') { 117 | t.is(file.path, 'pastissada-d41d8cd98f.css.map'); 118 | sourcemapCount++; 119 | } 120 | } 121 | 122 | t.is(sourcemapCount, 1); 123 | }); 124 | 125 | test('handles a `.` in the folder name', async t => { 126 | const stream = rev(); 127 | const data = pEvent(stream, 'data'); 128 | 129 | stream.end(createFile({ 130 | path: 'mysite.io/unicorn.css', 131 | })); 132 | 133 | const file = await data; 134 | t.is(file.path, path.normalize('mysite.io/unicorn-d41d8cd98f.css')); 135 | t.is(file.revOrigPath, path.normalize('mysite.io/unicorn.css')); 136 | }); 137 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import {Buffer} from 'node:buffer'; 2 | import path from 'node:path'; 3 | import {vinylFile} from 'vinyl-file'; 4 | import revHash from 'rev-hash'; 5 | import {revPath} from 'rev-path'; 6 | import sortKeys from 'sort-keys'; 7 | import modifyFilename from 'modify-filename'; 8 | import Vinyl from 'vinyl'; 9 | import {gulpPlugin} from 'gulp-plugin-extras'; 10 | 11 | function relativePath(base, filePath) { 12 | filePath = filePath.replaceAll('\\', '/'); 13 | base = base.replaceAll('\\', '/'); 14 | 15 | if (!filePath.startsWith(base)) { 16 | return filePath; 17 | } 18 | 19 | const newPath = filePath.slice(base.length); 20 | 21 | if (newPath[0] === '/') { 22 | return newPath.slice(1); 23 | } 24 | 25 | return newPath; 26 | } 27 | 28 | function transformFilename(file) { 29 | // Save the old path for later 30 | file.revOrigPath = file.path; 31 | file.revOrigBase = file.base; 32 | file.revHash = revHash(file.contents); 33 | 34 | file.path = modifyFilename(file.path, (filename, extension) => { 35 | const extIndex = filename.lastIndexOf('.'); 36 | 37 | filename = extIndex === -1 38 | ? revPath(filename, file.revHash) 39 | : revPath(filename.slice(0, extIndex), file.revHash) + filename.slice(extIndex); 40 | 41 | return filename + extension; 42 | }); 43 | } 44 | 45 | const getManifestFile = async options => { 46 | try { 47 | return await vinylFile(options.path, options); 48 | } catch (error) { 49 | if (error.code === 'ENOENT') { 50 | return new Vinyl(options); 51 | } 52 | 53 | throw error; 54 | } 55 | }; 56 | 57 | export default function gulpRev() { 58 | const sourcemaps = []; 59 | const pathMap = {}; 60 | 61 | return gulpPlugin('gulp-rev', file => { 62 | // This is a sourcemap, hold until the end. 63 | if (path.extname(file.path) === '.map') { 64 | sourcemaps.push(file); 65 | return; 66 | } 67 | 68 | const oldPath = file.path; 69 | transformFilename(file); 70 | pathMap[oldPath] = file.revHash; 71 | 72 | return file; 73 | }, { 74 | async * onFinish() { 75 | for (const file of sourcemaps) { 76 | let reverseFilename; 77 | 78 | // Attempt to parse the sourcemap's JSON to get the reverse filename 79 | try { 80 | reverseFilename = JSON.parse(file.contents.toString()).file; 81 | } catch {} 82 | 83 | reverseFilename ||= path.relative(path.dirname(file.path), path.basename(file.path, '.map')); 84 | 85 | if (pathMap[reverseFilename]) { 86 | // Save the old path for later 87 | file.revOrigPath = file.path; 88 | file.revOrigBase = file.base; 89 | 90 | const hash = pathMap[reverseFilename]; 91 | file.path = revPath(file.path.replace(/\.map$/, ''), hash) + '.map'; 92 | } else { 93 | transformFilename(file); 94 | } 95 | 96 | yield file; 97 | } 98 | }, 99 | }); 100 | } 101 | 102 | gulpRev.manifest = (path_, options) => { 103 | if (typeof path_ === 'string') { 104 | path_ = {path: path_}; 105 | } 106 | 107 | options = { 108 | path: 'rev-manifest.json', 109 | merge: false, 110 | transformer: JSON, 111 | ...options, 112 | ...path_, 113 | }; 114 | 115 | let manifest = {}; 116 | 117 | return gulpPlugin('gulp-rev', file => { 118 | // Ignore all non-rev'd files 119 | if (!file.path || !file.revOrigPath) { 120 | return; 121 | } 122 | 123 | const revisionedFile = relativePath(path.resolve(file.cwd, file.base), path.resolve(file.cwd, file.path)); 124 | const originalFile = path.join(path.dirname(revisionedFile), path.basename(file.revOrigPath)).replaceAll('\\', '/'); 125 | 126 | manifest[originalFile] = revisionedFile; 127 | }, { 128 | async * onFinish() { 129 | // No need to write a manifest file if there's nothing to manifest 130 | if (Object.keys(manifest).length === 0) { 131 | return; 132 | } 133 | 134 | const manifestFile = await getManifestFile(options); 135 | 136 | if (options.merge && !manifestFile.isNull()) { 137 | let oldManifest = {}; 138 | 139 | try { 140 | oldManifest = options.transformer.parse(manifestFile.contents.toString()); 141 | } catch {} 142 | 143 | manifest = Object.assign(oldManifest, manifest); 144 | } 145 | 146 | manifestFile.contents = Buffer.from(options.transformer.stringify(sortKeys(manifest), undefined, ' ')); 147 | 148 | yield manifestFile; 149 | }, 150 | }); 151 | }; 152 | -------------------------------------------------------------------------------- /integration.md: -------------------------------------------------------------------------------- 1 | # Integrating gulp-rev into your app 2 | 3 | Outlined below are three common approaches to integrating an asset manifest like the one **gulp-rev** outputs into an application. 4 | 5 | For our examples, we'll assume the following `rev-manifest.json`: 6 | 7 | ```json 8 | { 9 | "js/app.js": "js/app-5c41412f32.js", 10 | "js/lib.js": "js/lib-6d94673e3d.js", 11 | "css/app.css": "css/app-a4ae3dfa4d.css" 12 | } 13 | ``` 14 | 15 | ## Approach #1 - Generate index.html during build 16 | 17 | One approach to working with `rev-manifest.json` is to use a templating language, such as [handlebars](http://handlebarsjs.com), to generate an `index.html` file which contained your fingerprinted files embedded into the page. 18 | 19 | The idea is to read in your app's `rev-manifest.json`, and use the non-fingerprinted path to read in the fingerprinted path and inject it into the page. Note, this approach requires the `'compile-index-html'` task to be run as part of your build process. 20 | 21 | #### `index.hbs` 22 | 23 | ```html+jinja 24 | 25 | 26 | 27 | My App 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ``` 36 | 37 | #### `gulpfile.js` 38 | 39 | ```js 40 | import fs from 'node:fs'; 41 | import gulp from 'gulp'; 42 | import handlebars from 'gulp-compile-handlebars'; 43 | import rename from 'gulp-rename'; 44 | 45 | // Create a handlebars helper to look up 46 | // fingerprinted asset by non-fingerprinted name 47 | const handlebarOptions = { 48 | helpers: { 49 | assetPath: (path, context) => ['/assets', context.data.root[path]].join('/') 50 | } 51 | }; 52 | 53 | export const compileIndexHtml = () => { 54 | // Read in our manifest file 55 | const manifest = JSON.parse(fs.readFileSync('path/to/rev-manifest', 'utf8')); 56 | 57 | // Read in our handlebars template, compile it using 58 | // our manifest, and output it to index.html 59 | return gulp.src('index.hbs') 60 | .pipe(handlebars(manifest, handlebarOptions)) 61 | .pipe(rename('index.html')) 62 | .pipe(gulp.dest('public')); 63 | }; 64 | ``` 65 | 66 | ## Approach #2 - AJAX in manifest, inject assets into the page 67 | 68 | Another approach would be to make a AJAX request to get the manifest JSON blob, then use the manifest to programmatically find the fingerprinted path to any given asset. 69 | 70 | For example, if you wanted to include your JavaScript files into the page: 71 | 72 | ```js 73 | $.getJSON('/path/to/rev-manifest.json', manifest => { 74 | const s = document.getElementsByTagName('script')[0]; 75 | 76 | const assetPath = source => { 77 | source = `js/${source}.js`; 78 | return ['/assets', manifest[source]].join('/'); 79 | }; 80 | 81 | for (const source of ['lib', 'app']) { 82 | const element = document.createElement('script'); 83 | element.async = true; 84 | element.src = assetPath(source); 85 | s.parentNode.insertBefore(element, s); 86 | } 87 | }) 88 | ``` 89 | 90 | The above example assumes your assets live under `/assets` on your server. 91 | 92 | ## Approach #3 - PHP reads the manifest and provides asset names 93 | 94 | This example PHP function provides the correct filename by reading it from the JSON manifest. 95 | 96 | If the file is not present in the manifest it will return the original filename. 97 | 98 | ```php 99 | /** 100 | * @param string $filename 101 | * @return string 102 | */ 103 | function asset_path($filename) { 104 | $manifest_path = 'assets/rev-manifest.json'; 105 | 106 | if (file_exists($manifest_path)) { 107 | $manifest = json_decode(file_get_contents($manifest_path), TRUE); 108 | } else { 109 | $manifest = []; 110 | } 111 | 112 | if (array_key_exists($filename, $manifest)) { 113 | return $manifest[$filename]; 114 | } 115 | 116 | return $filename; 117 | } 118 | ```` 119 | 120 | You can then call `asset_path` to get the rev'd path of your assets: `echo asset_path('js/main.js');` 121 | 122 | Using [blade](http://laravel.com/docs/templates) your templates would look like this: 123 | 124 | ```html+jinja 125 | 126 | 127 | 128 | My App 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | ``` 137 | -------------------------------------------------------------------------------- /test/manifest.js: -------------------------------------------------------------------------------- 1 | import {Buffer} from 'node:buffer'; 2 | import path from 'node:path'; 3 | import {fileURLToPath} from 'node:url'; 4 | import test from 'ava'; 5 | import {pEvent} from 'p-event'; 6 | import rev from '../index.js'; 7 | import createFile from './_helper.js'; 8 | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 | 11 | const manifestFixture = './test.manifest-fixture.json'; 12 | const manifestFixturePath = path.join(__dirname, manifestFixture); 13 | const manifestFixtureRelative = path.join('test', manifestFixture); 14 | 15 | test('builds a rev manifest file', async t => { 16 | const stream = rev.manifest(); 17 | const data = pEvent(stream, 'data'); 18 | 19 | stream.end(createFile({ 20 | path: 'unicorn-d41d8cd98f.css', 21 | revOrigPath: 'unicorn.css', 22 | })); 23 | 24 | const file = await data; 25 | t.is(file.relative, 'rev-manifest.json'); 26 | t.deepEqual( 27 | JSON.parse(file.contents.toString()), 28 | {'unicorn.css': 'unicorn-d41d8cd98f.css'}, 29 | ); 30 | }); 31 | 32 | test('allows naming the manifest file', async t => { 33 | const path = 'manifest.json'; 34 | const stream = rev.manifest({path}); 35 | const data = pEvent(stream, 'data'); 36 | 37 | stream.end(createFile({ 38 | path: 'unicorn-d41d8cd98f.css', 39 | revOrigPath: 'unicorn.css', 40 | })); 41 | 42 | const file = await data; 43 | t.is(file.relative, path); 44 | }); 45 | 46 | test('appends to an existing rev manifest file', async t => { 47 | const stream = rev.manifest({ 48 | path: manifestFixturePath, 49 | merge: true, 50 | }); 51 | const data = pEvent(stream, 'data'); 52 | 53 | stream.end(createFile({ 54 | path: 'unicorn-d41d8cd98f.css', 55 | revOrigPath: 'unicorn.css', 56 | })); 57 | 58 | const file = await data; 59 | t.is(file.relative, manifestFixtureRelative); 60 | t.deepEqual( 61 | JSON.parse(file.contents.toString()), 62 | { 63 | 'app.js': 'app-a41d8cd1.js', 64 | 'unicorn.css': 'unicorn-d41d8cd98f.css', 65 | }, 66 | ); 67 | }); 68 | 69 | test('does not append to an existing rev manifest by default', async t => { 70 | const stream = rev.manifest({path: manifestFixturePath}); 71 | const data = pEvent(stream, 'data'); 72 | 73 | stream.end(createFile({ 74 | path: 'unicorn-d41d8cd98f.css', 75 | revOrigPath: 'unicorn.css', 76 | })); 77 | 78 | const file = await data; 79 | t.is(file.relative, manifestFixtureRelative); 80 | t.deepEqual( 81 | JSON.parse(file.contents.toString()), 82 | {'unicorn.css': 'unicorn-d41d8cd98f.css'}, 83 | ); 84 | }); 85 | 86 | test('sorts the rev manifest keys', async t => { 87 | const stream = rev.manifest({ 88 | path: manifestFixturePath, 89 | merge: true, 90 | }); 91 | const data = pEvent(stream, 'data'); 92 | 93 | stream.write(createFile({ 94 | path: 'unicorn-d41d8cd98f.css', 95 | revOrigPath: 'unicorn.css', 96 | })); 97 | stream.end(createFile({ 98 | path: 'pony-d41d8cd98f.css', 99 | revOrigPath: 'pony.css', 100 | })); 101 | 102 | const file = await data; 103 | t.deepEqual( 104 | Object.keys(JSON.parse(file.contents.toString())), 105 | ['app.js', 'pony.css', 'unicorn.css'], 106 | ); 107 | }); 108 | 109 | test('respects directories', async t => { 110 | const stream = rev.manifest(); 111 | const data = pEvent(stream, 'data'); 112 | 113 | stream.write(createFile({ 114 | cwd: __dirname, 115 | base: __dirname, 116 | path: path.join(__dirname, 'foo', 'unicorn-d41d8cd98f.css'), 117 | revOrigPath: path.join(__dirname, 'foo', 'unicorn.css'), 118 | revOrigBase: __dirname, 119 | origName: 'unicorn.css', 120 | revName: 'unicorn-d41d8cd98f.css', 121 | })); 122 | stream.end(createFile({ 123 | cwd: __dirname, 124 | base: __dirname, 125 | path: path.join(__dirname, 'bar', 'pony-d41d8cd98f.css'), 126 | revOrigBase: __dirname, 127 | revOrigPath: path.join(__dirname, 'bar', 'pony.css'), 128 | origName: 'pony.css', 129 | revName: 'pony-d41d8cd98f.css', 130 | })); 131 | 132 | const MANIFEST = {}; 133 | MANIFEST['foo/unicorn.css'] = 'foo/unicorn-d41d8cd98f.css'; 134 | MANIFEST['bar/pony.css'] = 'bar/pony-d41d8cd98f.css'; 135 | 136 | const file = await data; 137 | t.is(file.relative, 'rev-manifest.json'); 138 | t.deepEqual(JSON.parse(file.contents.toString()), MANIFEST); 139 | }); 140 | 141 | test('respects files coming from directories with different bases', async t => { 142 | const stream = rev.manifest(); 143 | const data = pEvent(stream, 'data'); 144 | 145 | stream.write(createFile({ 146 | cwd: __dirname, 147 | base: path.join(__dirname, 'output'), 148 | path: path.join(__dirname, 'output', 'foo', 'scriptfoo-d41d8cd98f.js'), 149 | contents: Buffer.from(''), 150 | revOrigBase: path.join(__dirname, 'vendor1'), 151 | revOrigPath: path.join(__dirname, 'vendor1', 'foo', 'scriptfoo.js'), 152 | origName: 'scriptfoo.js', 153 | revName: 'scriptfoo-d41d8cd98f.js', 154 | })); 155 | stream.end(createFile({ 156 | cwd: __dirname, 157 | base: path.join(__dirname, 'output'), 158 | path: path.join(__dirname, 'output', 'bar', 'scriptbar-d41d8cd98f.js'), 159 | revOrigBase: path.join(__dirname, 'vendor2'), 160 | revOrigPath: path.join(__dirname, 'vendor2', 'bar', 'scriptbar.js'), 161 | origName: 'scriptfoo.js', 162 | revName: 'scriptfoo-d41d8cd98f.js', 163 | })); 164 | 165 | const MANIFEST = {}; 166 | MANIFEST['foo/scriptfoo.js'] = 'foo/scriptfoo-d41d8cd98f.js'; 167 | MANIFEST['bar/scriptbar.js'] = 'bar/scriptbar-d41d8cd98f.js'; 168 | 169 | const file = await data; 170 | t.is(file.relative, 'rev-manifest.json'); 171 | t.deepEqual(JSON.parse(file.contents.toString()), MANIFEST); 172 | }); 173 | 174 | test('uses correct base path for each file', async t => { 175 | const stream = rev.manifest(); 176 | const data = pEvent(stream, 'data'); 177 | 178 | stream.write(createFile({ 179 | cwd: 'app/', 180 | base: 'app/', 181 | path: path.join('app', 'foo', 'scriptfoo-d41d8cd98f.js'), 182 | revOrigPath: 'scriptfoo.js', 183 | })); 184 | stream.end(createFile({ 185 | cwd: '/', 186 | base: 'assets/', 187 | path: path.join('/assets', 'bar', 'scriptbar-d41d8cd98f.js'), 188 | revOrigPath: 'scriptbar.js', 189 | })); 190 | 191 | const MANIFEST = {}; 192 | MANIFEST['foo/scriptfoo.js'] = 'foo/scriptfoo-d41d8cd98f.js'; 193 | MANIFEST['bar/scriptbar.js'] = 'bar/scriptbar-d41d8cd98f.js'; 194 | 195 | const file = await data; 196 | t.deepEqual(JSON.parse(file.contents.toString()), MANIFEST); 197 | }); 198 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # gulp-rev 2 | 3 | > Static asset revisioning by appending content hash to filenames 4 | > `unicorn.css` → `unicorn-d41d8cd98f.css` 5 | 6 | **This project is feature complete. PRs adding new features will not be accepted.** 7 | 8 | Make sure to set the files to [never expire](http://developer.yahoo.com/performance/rules.html#expires) for this to have an effect. 9 | 10 | ## Install 11 | 12 | ```sh 13 | npm install --save-dev gulp-rev 14 | ``` 15 | 16 | ## Usage 17 | 18 | ```js 19 | import gulp from 'gulp'; 20 | import rev from 'gulp-rev'; 21 | 22 | export default () => ( 23 | gulp.src('src/*.css') 24 | .pipe(rev()) 25 | .pipe(gulp.dest('dist')) 26 | ); 27 | ``` 28 | 29 | ## API 30 | 31 | ### rev() 32 | 33 | ### rev.manifest(path?, options?) 34 | 35 | #### path 36 | 37 | Type: `string`\ 38 | Default: `'rev-manifest.json'` 39 | 40 | Manifest file path. 41 | 42 | #### options 43 | 44 | Type: `object` 45 | 46 | ##### base 47 | 48 | Type: `string`\ 49 | Default: `process.cwd()` 50 | 51 | Override the `base` of the manifest file. 52 | 53 | ##### cwd 54 | 55 | Type: `string`\ 56 | Default: `process.cwd()` 57 | 58 | Override the current working directory of the manifest file. 59 | 60 | ##### merge 61 | 62 | Type: `boolean`\ 63 | Default: `false` 64 | 65 | Merge existing manifest file. 66 | 67 | ##### transformer 68 | 69 | Type: `object`\ 70 | Default: `JSON` 71 | 72 | An object with `parse` and `stringify` methods. This can be used to provide a 73 | custom transformer instead of the default `JSON` for the manifest file. 74 | 75 | ### Original path 76 | 77 | Original file paths are stored at `file.revOrigPath`. This could come in handy for things like rewriting references to the assets. 78 | 79 | ### Asset hash 80 | 81 | The hash of each rev'd file is stored at `file.revHash`. You can use this for customizing the file renaming, or for building different manifest formats. 82 | 83 | ### Asset manifest 84 | 85 | ```js 86 | import gulp from 'gulp'; 87 | import rev from 'gulp-rev'; 88 | 89 | export default () => ( 90 | // By default, Gulp would pick `assets/css` as the base, 91 | // so we need to set it explicitly: 92 | gulp.src(['assets/css/*.css', 'assets/js/*.js'], {base: 'assets'}) 93 | .pipe(gulp.dest('build/assets')) // Copy original assets to build dir 94 | .pipe(rev()) 95 | .pipe(gulp.dest('build/assets')) // Write rev'd assets to build dir 96 | .pipe(rev.manifest()) 97 | .pipe(gulp.dest('build/assets')) // Write manifest to build dir 98 | ); 99 | ``` 100 | 101 | An asset manifest, mapping the original paths to the revisioned paths, will be written to `build/assets/rev-manifest.json`: 102 | 103 | ```json 104 | { 105 | "css/unicorn.css": "css/unicorn-d41d8cd98f.css", 106 | "js/unicorn.js": "js/unicorn-273c2c123f.js" 107 | } 108 | ``` 109 | 110 | By default, `rev-manifest.json` will be replaced as a whole. To merge with an existing manifest, pass `merge: true` and the output destination (as `base`) to `rev.manifest()`: 111 | 112 | ```js 113 | import gulp from 'gulp'; 114 | import rev from 'gulp-rev'; 115 | 116 | export default () => ( 117 | // By default, Gulp would pick `assets/css` as the base, 118 | // so we need to set it explicitly: 119 | gulp.src(['assets/css/*.css', 'assets/js/*.js'], {base: 'assets'}) 120 | .pipe(gulp.dest('build/assets')) 121 | .pipe(rev()) 122 | .pipe(gulp.dest('build/assets')) 123 | .pipe(rev.manifest({ 124 | base: 'build/assets', 125 | merge: true // Merge with the existing manifest if one exists 126 | })) 127 | .pipe(gulp.dest('build/assets')) 128 | ); 129 | ``` 130 | 131 | You can optionally call `rev.manifest('manifest.json')` to give it a different path or filename. 132 | 133 | ## Sourcemaps and `gulp-concat` 134 | 135 | Because of the way `gulp-concat` handles file paths, you may need to set `cwd` and `path` manually on your `gulp-concat` instance to get everything to work correctly: 136 | 137 | ```js 138 | import gulp from 'gulp'; 139 | import rev from 'gulp-rev'; 140 | import sourcemaps from 'gulp-sourcemaps'; 141 | import concat from 'gulp-concat'; 142 | 143 | export default () => ( 144 | gulp.src('src/*.js') 145 | .pipe(sourcemaps.init()) 146 | .pipe(concat({path: 'bundle.js', cwd: ''})) 147 | .pipe(rev()) 148 | .pipe(sourcemaps.write('.')) 149 | .pipe(gulp.dest('dist')) 150 | ); 151 | ``` 152 | 153 | ## Different hash for unchanged files 154 | 155 | Since the order of streams are not guaranteed, some plugins such as `gulp-concat` can cause the final file's content and hash to change. To avoid generating a new hash for unchanged source files, you can: 156 | 157 | - Sort the streams with [gulp-sort](https://github.com/pgilad/gulp-sort) 158 | - Filter unchanged files with [gulp-unchanged](https://github.com/sindresorhus/gulp-changed) 159 | - Read more about [incremental builds](https://github.com/gulpjs/gulp#incremental-builds) 160 | 161 | ## Streaming 162 | 163 | This plugin does not support streaming. If you have files from a streaming source, such as Browserify, you should use [`gulp-buffer`](https://github.com/jeromew/gulp-buffer) before `gulp-rev` in your pipeline: 164 | 165 | ```js 166 | import gulp from 'gulp'; 167 | import browserify from 'browserify'; 168 | import source from 'vinyl-source-stream'; 169 | import buffer from 'gulp-buffer'; 170 | import rev from 'gulp-rev'; 171 | 172 | export default () => ( 173 | browserify('src/index.js') 174 | .bundle({debug: true}) 175 | .pipe(source('index.min.js')) 176 | .pipe(buffer()) 177 | .pipe(rev()) 178 | .pipe(gulp.dest('dist')) 179 | ); 180 | ``` 181 | 182 | ## Integration 183 | 184 | For more info on how to integrate `gulp-rev` into your app, have a look at the [integration guide](integration.md). 185 | 186 | ## Use gulp-rev in combination with one or more of 187 | 188 | It may be useful - and necessary - to use `gulp-rev` with other packages to complete the task. 189 | 190 | - [gulp-rev-rewrite](https://github.com/TheDancingCode/gulp-rev-rewrite) - Rewrite occurrences of filenames which have been renamed 191 | - [gulp-rev-css-url](https://github.com/galkinrost/gulp-rev-css-url) - Override URLs in CSS files with the revved ones 192 | - [gulp-rev-outdated](https://github.com/shonny-ua/gulp-rev-outdated) - Old static asset revision files filter 193 | - [gulp-rev-collector](https://github.com/shonny-ua/gulp-rev-collector) - Static asset revision data collector 194 | - [rev-del](https://github.com/callumacrae/rev-del) - Delete old unused assets 195 | - [gulp-rev-delete-original](https://github.com/nib-health-funds/gulp-rev-delete-original) - Delete original files after rev 196 | - [gulp-rev-dist-clean](https://github.com/alexandre-abrioux/gulp-rev-dist-clean) - Clean up temporary and legacy files created by gulp-rev 197 | - [gulp-rev-loader](https://github.com/adjavaherian/gulp-rev-loader) - Use rev-manifest with webpack 198 | - [gulp-rev-format](https://github.com/atamas101/gulp-rev-format) - Provide hash formatting options for static assets (prefix, suffix, last-extension) 199 | - [gulp-rev-sri](https://github.com/shaunwarman/gulp-rev-sri) - Add subresource integrity field to rev-manifest 200 | --------------------------------------------------------------------------------