diff --git a/src/index.js b/src/index.js index 9d09dd0..402705e 100644 --- a/src/index.js +++ b/src/index.js @@ -13,13 +13,16 @@ module.exports = postcss.plugin('postcss-url', (options) => { options = options || {}; return function(styles, result) { + const promises = []; const opts = result.opts; const from = opts.from ? path.dirname(opts.from) : '.'; const to = opts.to ? path.dirname(opts.to) : from; styles.walkDecls((decl) => - declProcessor(from, to, options, result, decl) + promises.push(declProcessor(from, to, options, result, decl)) ); + + return Promise.all(promises); }; }); diff --git a/src/lib/decl-processor.js b/src/lib/decl-processor.js index 0a7cbc0..6c40b32 100644 --- a/src/lib/decl-processor.js +++ b/src/lib/decl-processor.js @@ -80,14 +80,14 @@ const getPattern = (decl) => * @param {Options} options * @param {Result} result * @param {Decl} decl - * @returns {String|undefined} + * @returns {Promise} */ const replaceUrl = (url, dir, options, result, decl) => { const asset = prepareAsset(url, dir, decl); const matchedOptions = matchOptions(asset, options); - if (!matchedOptions) return; + if (!matchedOptions) return Promise.resolve(); const process = (option) => { const wrappedUrlProcessor = wrapUrlProcessor(getUrlProcessor(option.url), result, decl); @@ -95,13 +95,27 @@ const replaceUrl = (url, dir, options, result, decl) => { return wrappedUrlProcessor(asset, dir, option); }; + let resultPromise = Promise.resolve(); + if (Array.isArray(matchedOptions)) { - matchedOptions.forEach((option) => asset.url = process(option)); + for (let i = 0; i < matchedOptions.length; i++) { + resultPromise = resultPromise + .then(() => process(matchedOptions[i])) + .then((newUrl) => { + asset.url = newUrl; + + return newUrl; + }); + } } else { - asset.url = process(matchedOptions); + resultPromise = process(matchedOptions); } - return asset.url; + return resultPromise.then((newUrl) => { + asset.url = newUrl; + + return newUrl; + }); }; /** @@ -110,31 +124,41 @@ const replaceUrl = (url, dir, options, result, decl) => { * @param {PostcssUrl~Options} options * @param {Result} result * @param {Decl} decl - * @returns {PostcssUrl~DeclProcessor} + * @returns {Promise} */ const declProcessor = (from, to, options, result, decl) => { const dir = { from, to, file: getDirDeclFile(decl) }; const pattern = getPattern(decl); - if (!pattern) return; + if (!pattern) return Promise.resolve(); + + const promises = []; decl.value = decl.value .replace(pattern, (matched, before, url, after) => { - const newUrl = replaceUrl(url, dir, options, result, decl); + const newUrlPromise = replaceUrl(url, dir, options, result, decl); + + promises.push( + newUrlPromise + .then((newUrl) => { + if (!newUrl) return matched; - if (!newUrl) return matched; + if (WITH_QUOTES.test(newUrl) && WITH_QUOTES.test(after)) { + before = before.slice(0, -1); + after = after.slice(1); + } - if (WITH_QUOTES.test(newUrl) && WITH_QUOTES.test(after)) { - before = before.slice(0, -1); - after = after.slice(1); - } + decl.value = decl.value.replace(matched, `${before}${newUrl}${after}`); + }) + ); - return `${before}${newUrl}${after}`; + return matched; }); + + return Promise.all(promises); }; module.exports = { - replaceUrl, declProcessor }; diff --git a/src/lib/get-file.js b/src/lib/get-file.js index 7bb4b02..8da6c34 100644 --- a/src/lib/get-file.js +++ b/src/lib/get-file.js @@ -5,31 +5,74 @@ const mime = require('mime'); const getPathByBasePath = require('./paths').getPathByBasePath; +const readFileAsync = (filePath) => { + return new Promise((resolve, reject) => { + fs.readFile(filePath, (err, data) => { + if (err) { + reject(err); + } + resolve(data); + }); + }); +}; + +const existFileAsync = (filePath) => { + return new Promise((resolve) => + fs.access(filePath, (err) => { + resolve(!err); + }) + ); +}; + +const findExistsPath = (paths) => { + let resolved = false; + + return new Promise((resolve, reject) => { + const findPromises = paths.map((path) => { + return existFileAsync(path).then((isExists) => { + if (!resolved && isExists) { + resolved = true; + resolve(path); + } + }); + }); + + Promise.all(findPromises).then(() => { + if (!resolved) { + reject(); + } + }); + }); +}; + /** * * @param {PostcssUrl~Asset} asset * @param {PostcssUrl~Options} options * @param {PostcssUrl~Dir} dir * @param {Function} warn - * @returns {PostcssUrl~File} + * @returns {Promise} */ const getFile = (asset, options, dir, warn) => { const paths = options.basePath ? getPathByBasePath(options.basePath, dir.from, asset.pathname) : [asset.absolutePath]; - const filePath = paths.find(fs.existsSync); - - if (!filePath) { - warn(`Can't read file '${paths.join()}', ignoring`); - return; - } + return findExistsPath(paths) + .then((path) => readFileAsync(path) + .then((contents) => { + return { + path, + contents, + mimeType: mime.getType(path) + }; + }) + ) + .catch(() => { + warn(`Can't read file '${paths.join()}', ignoring`); - return { - path: filePath, - contents: fs.readFileSync(filePath), - mimeType: mime.getType(filePath) - }; + return; + }); }; module.exports = getFile; diff --git a/src/type/copy.js b/src/type/copy.js index 2f1da06..5eb7bb0 100644 --- a/src/type/copy.js +++ b/src/type/copy.js @@ -14,8 +14,31 @@ const normalize = paths.normalize; const getHashName = (file, options) => (options && options.append ? (`${path.basename(file.path, path.extname(file.path))}_`) : '') - + calcHash(file.contents, options) - + path.extname(file.path); + + calcHash(file.contents, options) + + path.extname(file.path); + +const createDirAsync = (dirPath) => { + return new Promise((resolve, reject) => { + mkdirp(dirPath, (err) => { + if (err) { + reject(err); + } + + resolve(); + }); + }); +}; + +const writeFileAsync = (file, dest) => { + return new Promise((resolve, reject) => { + fs.writeFile(dest, file.contents, { flag: 'wx' }, (err) => { + if (err) { + err.code === 'EEXIST' ? resolve() : reject(err); + } + resolve(); + }); + }); +}; /** * Copy images from readed from url() to an specific assets destination @@ -33,37 +56,36 @@ const getHashName = (file, options) => * @param {Result} result * @param {Function} addDependency * - * @returns {String|Undefined} + * @returns {Promise} */ + module.exports = function processCopy(asset, dir, options, decl, warn, result, addDependency) { if (!options.assetsPath && dir.from === dir.to) { warn('Option `to` of postcss is required, ignoring'); - return; + return Promise.resolve(); } - const file = getFile(asset, options, dir, warn); - - if (!file) return; - - const assetRelativePath = options.useHash - ? getHashName(file, options.hashOptions) - : asset.relativePath; + return getFile(asset, options, dir, warn) + .then((file) => { + if (!file) return; - const targetDir = getTargetDir(dir); - const newAssetBaseDir = getAssetsPath(targetDir, options.assetsPath); - const newAssetPath = path.join(newAssetBaseDir, assetRelativePath); - const newRelativeAssetPath = normalize( - path.relative(targetDir, newAssetPath) - ); + const assetRelativePath = options.useHash + ? getHashName(file, options.hashOptions) + : asset.relativePath; - mkdirp.sync(path.dirname(newAssetPath)); - - if (!fs.existsSync(newAssetPath)) { - fs.writeFileSync(newAssetPath, file.contents); - } + const targetDir = getTargetDir(dir); + const newAssetBaseDir = getAssetsPath(targetDir, options.assetsPath); + const newAssetPath = path.join(newAssetBaseDir, assetRelativePath); + const newRelativeAssetPath = normalize(path.relative(targetDir, newAssetPath)); - addDependency(file.path); + return createDirAsync(path.dirname(newAssetPath)) + .then(() => writeFileAsync(file, newAssetPath)) + .then(() => { + addDependency(file.path); - return `${newRelativeAssetPath}${asset.search}${asset.hash}`; + return `${newRelativeAssetPath}${asset.search}${asset.hash}`; + }); + } + ); }; diff --git a/src/type/custom.js b/src/type/custom.js index 4c610d4..d274601 100644 --- a/src/type/custom.js +++ b/src/type/custom.js @@ -6,8 +6,8 @@ * @param {PostcssUrl~Dir} dir * @param {PostcssUrl~Option} options * - * @returns {String|Undefined} + * @returns {Promise} */ module.exports = function getCustomProcessor(asset, dir, options) { - return options.url.apply(null, arguments); + return Promise.resolve().then(() => options.url.apply(null, arguments)); }; diff --git a/src/type/inline.js b/src/type/inline.js index d25c29c..416ea97 100644 --- a/src/type/inline.js +++ b/src/type/inline.js @@ -1,7 +1,5 @@ 'use strict'; -const fs = require('fs'); - const processCopy = require('./copy'); const processRebase = require('./rebase'); @@ -25,10 +23,33 @@ function processFallback(originUrl, dir, options) { case 'rebase': return processRebase.apply(null, arguments); default: - return; + return Promise.resolve(); } } +const inlineProcess = (file, asset, warn, addDependency, options) => { + const isSvg = file.mimeType === 'image/svg+xml'; + const defaultEncodeType = isSvg ? 'encodeURIComponent' : 'base64'; + const encodeType = options.encodeType || defaultEncodeType; + + // Warn for svg with hashes/fragments + if (isSvg && asset.hash && !options.ignoreFragmentWarning) { + // eslint-disable-next-line max-len + warn(`Image type is svg and link contains #. Postcss-url cant handle svg fragments. SVG file fully inlined. ${file.path}`); + } + + addDependency(file.path); + + const optimizeSvgEncode = isSvg && options.optimizeSvgEncode; + const encodedStr = encodeFile(file, encodeType, optimizeSvgEncode); + const resultValue = options.includeUriFragment && asset.hash + ? encodedStr + asset.hash + : encodedStr; + + // wrap url by quotes if percent-encoded svg + return isSvg && encodeType !== 'base64' ? `"${resultValue}"` : resultValue; +}; + /** * Inline image in url() * @@ -41,48 +62,30 @@ function processFallback(originUrl, dir, options) { * @param {Result} result * @param {Function} addDependency * - * @returns {String|Undefined} + * @returns {Promise} */ // eslint-disable-next-line complexity module.exports = function(asset, dir, options, decl, warn, result, addDependency) { - const file = getFile(asset, options, dir, warn); + return getFile(asset, options, dir, warn) + .then((file) => { + if (!file) return; - if (!file) return; + if (!file.mimeType) { + warn(`Unable to find asset mime-type for ${file.path}`); - if (!file.mimeType) { - warn(`Unable to find asset mime-type for ${file.path}`); + return; + } - return; - } - - const maxSize = (options.maxSize || 0) * 1024; - - if (maxSize) { - const stats = fs.statSync(file.path); + const maxSize = (options.maxSize || 0) * 1024; - if (stats.size >= maxSize) { - return processFallback.apply(this, arguments); - } - } + if (maxSize) { + const size = Buffer.byteLength(file.contents); - const isSvg = file.mimeType === 'image/svg+xml'; - const defaultEncodeType = isSvg ? 'encodeURIComponent' : 'base64'; - const encodeType = options.encodeType || defaultEncodeType; + if (size >= maxSize) { + return processFallback.apply(this, arguments); + } + } - // Warn for svg with hashes/fragments - if (isSvg && asset.hash && !options.ignoreFragmentWarning) { - // eslint-disable-next-line max-len - warn(`Image type is svg and link contains #. Postcss-url cant handle svg fragments. SVG file fully inlined. ${file.path}`); - } - - addDependency(file.path); - - const optimizeSvgEncode = isSvg && options.optimizeSvgEncode; - const encodedStr = encodeFile(file, encodeType, optimizeSvgEncode); - const resultValue = options.includeUriFragment && asset.hash - ? encodedStr + asset.hash - : encodedStr; - - // wrap url by quotes if percent-encoded svg - return isSvg && encodeType !== 'base64' ? `"${resultValue}"` : resultValue; + return inlineProcess(file, asset, warn, addDependency, options); + }); }; diff --git a/src/type/rebase.js b/src/type/rebase.js index d327618..9caa37e 100644 --- a/src/type/rebase.js +++ b/src/type/rebase.js @@ -13,7 +13,7 @@ const getAssetsPath = paths.getAssetsPath; * @param {PostcssUrl~Dir} dir * @param {PostcssUrl~Option} options * - * @returns {String|Undefined} + * @returns {Promise} */ module.exports = function(asset, dir, options) { const dest = getAssetsPath(dir.to, options && options.assetsPath || ''); @@ -21,5 +21,5 @@ module.exports = function(asset, dir, options) { path.relative(dest, asset.absolutePath) ); - return `${rebasedUrl}${asset.search}${asset.hash}`; + return Promise.resolve().then(() => `${rebasedUrl}${asset.search}${asset.hash}`); }; diff --git a/test/lib/get-file.js b/test/lib/get-file.js index 41112b5..9f04fa2 100644 --- a/test/lib/get-file.js +++ b/test/lib/get-file.js @@ -14,13 +14,15 @@ describe('get-file', () => { pathname: '../pixel.gif', absolutePath: 'test/fixtures/pixel.gif' }; - const file = getFile(asset, {}, dir, warn); - assert.deepEqual(file, { - path: 'test/fixtures/pixel.gif', - contents: fileContent, - mimeType: 'image/gif' - }); + return getFile(asset, {}, dir, warn) + .then((file) => { + assert.deepEqual(file, { + path: 'test/fixtures/pixel.gif', + contents: fileContent, + mimeType: 'image/gif' + }); + }); }); it('should show warn message when can\'t read file', () => { @@ -30,12 +32,13 @@ describe('get-file', () => { }; let warnMessage = false; - getFile(asset, {}, dir, (message) => warnMessage = message); - - assert.equal( - warnMessage, - 'Can\'t read file \'test/fixtures/pixel-no-exists.gif\', ignoring' - ); + return getFile(asset, {}, dir, (message) => warnMessage = message) + .then(() => { + assert.equal( + warnMessage, + 'Can\'t read file \'test/fixtures/pixel-no-exists.gif\', ignoring' + ); + }); }); it('should read file with basePath option', () => { @@ -44,13 +47,15 @@ describe('get-file', () => { pathname: '../pixel.gif', absolutePath: 'test/fixtures/pixel-not-exists.gif' }; - const file = getFile(asset, options, dir, warn); - assert.deepEqual(file, { - path: path.resolve('test/fixtures/pixel.gif'), - contents: fileContent, - mimeType: 'image/gif' - }); + return getFile(asset, options, dir, warn) + .then((file) => { + assert.deepEqual(file, { + path: path.resolve('test/fixtures/pixel.gif'), + contents: fileContent, + mimeType: 'image/gif' + }); + }); }); it('should read file with multiple basePath option', () => { @@ -62,12 +67,14 @@ describe('get-file', () => { pathname: '../pixel.gif', absolutePath: 'test/fixtures/pixel-not-exists.gif' }; - const file = getFile(asset, options, dir, warn); - assert.deepEqual(file, { - path: path.resolve('test/fixtures/pixel.gif'), - contents: fileContent, - mimeType: 'image/gif' - }); + return getFile(asset, options, dir, warn) + .then((file) => { + assert.deepEqual(file, { + path: path.resolve('test/fixtures/pixel.gif'), + contents: fileContent, + mimeType: 'image/gif' + }); + }); }); }); diff --git a/test/misc/messages.js b/test/misc/messages.js index de2a593..13d076e 100644 --- a/test/misc/messages.js +++ b/test/misc/messages.js @@ -13,7 +13,7 @@ describe('misc', () => { .then((result) => { const dependencies = result.messages.filter((m) => m.type === 'dependency'); - assert.deepEqual(dependencies, [ + const expected = [ { type: 'dependency', file: path.resolve('test/fixtures/imported/pixel.png'), @@ -24,7 +24,12 @@ describe('misc', () => { file: path.resolve('test/fixtures/pixel.gif'), parent: path.resolve('test/fixtures/copy.css') } - ]); + ]; + + assert.deepEqual( + dependencies.sort((a, b) => a.file.length - b.file.length), + expected.sort((a, b) => a.file.length - b.file.length) + ); }); }); @@ -40,7 +45,7 @@ describe('misc', () => { .then((result) => { const dependencies = result.messages.filter((m) => m.type === 'dependency'); - assert.deepEqual(dependencies, [ + const expected = [ { type: 'dependency', file: path.resolve('test/fixtures/imported/pixel.png'), @@ -51,7 +56,12 @@ describe('misc', () => { file: path.resolve('test/fixtures/pixel.gif'), parent: path.resolve('test/fixtures/copy.css') } - ]); + ]; + + assert.deepEqual( + dependencies.sort((a, b) => a.file.length - b.file.length), + expected.sort((a, b) => a.file.length - b.file.length) + ); }); }); }); diff --git a/test/setup.js b/test/setup.js index c759a55..47e5a6d 100644 --- a/test/setup.js +++ b/test/setup.js @@ -42,5 +42,5 @@ function processedCss(fixtures, urlOpts, postcssOpts) { return postcss() .use(url(urlOpts)) .process(read(fixtures), postcssOpts) - .css; + .then((res) => res.css); } diff --git a/test/type/copy.js b/test/type/copy.js index 24c9dec..f2e98a3 100644 --- a/test/type/copy.js +++ b/test/type/copy.js @@ -96,49 +96,54 @@ function testCopy(opts, postcssOpts) { describe('should copy asset from the source (`from`) to the assets destination (`to` + `assetsPath`)', () => { it('rebase the url', () => { - const css = processedCss('fixtures/copy', opts, postcssOpts); - - matchAll(css, ['copyPixelPng', 'copyPixelGif']); + return processedCss('fixtures/copy', opts, postcssOpts) + .then((css) => { + matchAll(css, ['copyPixelPng', 'copyPixelGif']); + }); }); it('rebase the url keeping parameters', () => { - const css = processedCss('fixtures/copy-parameters', opts, postcssOpts); - - matchAll(css, [ - 'copyParamsPixelPngHash', - 'copyParamsPixelPngParam', - 'copyParamsPixelGif' - ]); + return processedCss('fixtures/copy-parameters', opts, postcssOpts) + .then((css) => { + matchAll(css, [ + 'copyParamsPixelPngHash', + 'copyParamsPixelPngParam', + 'copyParamsPixelGif' + ]); + }); }); it('rebase the url using a hash name', () => { - const css = processedCss( + return processedCss( 'fixtures/copy-hash', optsWithHash, postcssOpts - ); - - matchAll(css, ['copyXXHashPixel8']); + ) + .then((css) => { + matchAll(css, ['copyXXHashPixel8']); + }); }); it('rebase the url using a hash name keeping parameters', () => { - const css = processedCss( + return processedCss( 'fixtures/copy-hash-parameters', optsWithHash, postcssOpts - ); - - matchAll(css, ['copyXXHashParamsPixel8']); + ) + .then((css) => { + matchAll(css, ['copyXXHashParamsPixel8']); + }); }); it('rebase the url using a hash and prepending the original filename', () => { - const css = processedCss( + return processedCss( 'fixtures/copy-hash', optsWithAppendHash, postcssOpts - ); - - matchAll(css, ['copyXXHashPrependPixel8']); + ) + .then((css) => { + matchAll(css, ['copyXXHashPrependPixel8']); + }); }); }); } diff --git a/test/type/inline.js b/test/type/inline.js index 0c3c215..96c88a2 100644 --- a/test/type/inline.js +++ b/test/type/inline.js @@ -30,29 +30,31 @@ describe('inline', () => { ); it('should inline url from dirname(from)', () => { - const css = processedCss('fixtures/inline-from', opts, postcssOpts); - - assert.ok(css.match(/;base64/)); + return processedCss('fixtures/inline-from', opts, postcssOpts) + .then((css) => { + assert.ok(css.match(/;base64/)); + }); }); it('should not inline big files from dirname(from)', () => { - const css = processedCss( + return processedCss( 'fixtures/inline-from', { url: 'inline', maxSize: 0.0001 }, { from: 'test/fixtures/here' } - ); - - assert.notOk(css.match(/;base64/)); + ).then((css) => { + assert.notOk(css.match(/;base64/)); + }); }); it('SVGs shouldn\'t be encoded in base64', () => { - const css = processedCss( + return processedCss( 'fixtures/inline-svg', { url: 'inline' }, postcssOpts - ); - - assert.notOk(css.match(/;base64/)); + ) + .then((css) => { + assert.notOk(css.match(/;base64/)); + }); }); compareFixtures( @@ -70,7 +72,7 @@ describe('inline', () => { ); it('should inline url of imported files', () => { - postcss() + return postcss() .use(require('postcss-import')()) .use(postcssUrl(opts)) .process(read('fixtures/inline-imported'), { from: 'test/fixtures/here' }) @@ -80,48 +82,49 @@ describe('inline', () => { }); it('should inline files matching the minimatch pattern', () => { - const css = processedCss( + return processedCss( 'fixtures/inline-by-type', { url: 'inline', filter: '**/*.svg' }, postcssOpts - ); - - assert.ok(css.match(/data\:image\/svg\+xml/)); - assert.notOk( - css.match(/data:image\/gif/), - 'shouldn\'t inline files not matching the minimatch pattern' - ); + ).then((css) => { + assert.ok(css.match(/data\:image\/svg\+xml/)); + assert.notOk( + css.match(/data:image\/gif/), + 'shouldn\'t inline files not matching the minimatch pattern' + ); + }); }); it('should inline files matching the regular expression', () => { - const css = processedCss( + return processedCss( 'fixtures/inline-by-type', { url: 'inline', filter: /\.svg$/ }, postcssOpts - ); - - assert.ok(css.match(/data\:image\/svg\+xml/)); - assert.notOk( - css.match(/data:image\/gif/), - 'shouldn\'t inline files not matching the regular expression' - ); + ).then((css) => { + assert.ok(css.match(/data\:image\/svg\+xml/)); + assert.notOk( + css.match(/data:image\/gif/), + 'shouldn\'t inline files not matching the regular expression' + ); + }); }); it('should inline files matching by custom function', () => { - const customFilterFunction = function(asset) { + const customFilterFunction = (asset) => { return /\.svg$/.test(asset.absolutePath); }; - const css = processedCss( + + return processedCss( 'fixtures/inline-by-type', { url: 'inline', filter: customFilterFunction }, postcssOpts - ); - - assert.ok(css.match(/data\:image\/svg\+xml/)); - assert.notOk( - css.match(/data:image\/gif/), - 'shouldn\'t inline files not matching the regular expression' - ); + ).then((css) => { + assert.ok(css.match(/data\:image\/svg\+xml/)); + assert.notOk( + css.match(/data:image\/gif/), + 'shouldn\'t inline files not matching the regular expression' + ); + }); }); describe('function when inline fallback', () => { @@ -151,7 +154,7 @@ describe('inline', () => { }); it('should find files in basePaths', () => { - const css = processedCss( + return processedCss( 'fixtures/inline-by-base-paths', { url: 'inline', @@ -159,8 +162,8 @@ describe('inline', () => { basePath: [path.resolve('test/fixtures/baseDir1'), 'baseDir2'] }, postcssOpts - ); - - assert.equal(css.match(/data:image\/png/g).length, 2); + ).then((css) => { + assert.equal(css.match(/data:image\/png/g).length, 2); + }); }); }); diff --git a/test/type/rebase.js b/test/type/rebase.js index 643dd6d..098b8d9 100644 --- a/test/type/rebase.js +++ b/test/type/rebase.js @@ -5,19 +5,19 @@ describe('rebase', () => { describe('base unit', () => { it('should calc relative path', () => { - const res = rebase({ + return rebase({ absolutePath: '/project/blocks/item/1.png', search: '', hash: '' }, { to: '/project/build' + }).then((res) => { + assert.equal(res, '../blocks/item/1.png'); }); - - assert.equal(res, '../blocks/item/1.png'); }); it('should calc relative path by assetsPath option', () => { - const res = rebase({ + return rebase({ absolutePath: '/project/blocks/item/1.png', search: '', hash: '' @@ -27,18 +27,21 @@ describe('rebase', () => { from: '/project/blocks/item/1.png' }, { assetsPath: '/project/build' - }); - - assert.equal(res, '../blocks/item/1.png'); + }) + .then((res) => { + assert.equal(res, '../blocks/item/1.png'); + }); }); }); it('rebase with empty options', () => { - processedCss( + return processedCss( 'fixtures/copy-hash', undefined, { from: 'test/fixtures/here' } - ).css; + ).then((css) => { + assert(css); + }); }); compareFixtures(