diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..84679ca --- /dev/null +++ b/.babelrc @@ -0,0 +1,5 @@ +{ + "plugins": [ + "transform-async-to-generator" + ] +} diff --git a/.npmignore b/.npmignore index 63cf84a..e349110 100644 --- a/.npmignore +++ b/.npmignore @@ -1,4 +1,4 @@ -*.coffee +src spec .pairs .travis.yml diff --git a/Gruntfile.coffee b/Gruntfile.coffee deleted file mode 100644 index e01d66d..0000000 --- a/Gruntfile.coffee +++ /dev/null @@ -1,38 +0,0 @@ -module.exports = (grunt) -> - grunt.initConfig - pkg: grunt.file.readJSON('package.json') - - coffee: - glob_to_multiple: - expand: true - cwd: 'src' - src: ['*.coffee'] - dest: 'lib' - ext: '.js' - - coffeelint: - options: - no_empty_param_list: - level: 'error' - max_line_length: - level: 'ignore' - - src: ['src/*.coffee'] - test: ['spec/*.coffee'] - - shell: - test: - command: 'node node_modules/jasmine-focused/bin/jasmine-focused --captureExceptions --coffee spec' - options: - stdout: true - stderr: true - failOnError: true - - grunt.loadNpmTasks('grunt-contrib-coffee') - grunt.loadNpmTasks('grunt-shell') - grunt.loadNpmTasks('grunt-coffeelint') - - grunt.registerTask 'clean', -> require('fs-plus').removeSync('lib') - grunt.registerTask('lint', ['coffeelint:src', 'coffeelint:test']) - grunt.registerTask('default', ['coffeelint', 'coffee']) - grunt.registerTask('test', ['default', 'coffeelint:test', 'shell:test']) diff --git a/README.md b/README.md index 7851c7e..48335a1 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,17 @@ Caches the compiled `.less` files as `.css`. npm install less-cache ``` -```coffeescript -LessCache = require 'less-cache' +```javascript +const LessCache = require('less-cache') +const cache = new LessCache({cacheDir: '/tmp/less-cache'}) -cache = new LessCache(cacheDir: '/tmp/less-cache') -css = cache.readFileSync('/Users/me/apps/static/styles.less') +// This method returns a {Promise}, but you can avoid waiting on it as it will +// be waited upon automatically when calling `readFile` or `cssForFile` later. +cache.load() +const css1 = await cache.readFile('/Users/me/apps/static/styles.less') + +// Similarly to `load`, this method will return a {Promise} and subsequent calls +// to `readFile` or `cssForFile` will automatically wait on it. +cache.setImportPaths(['path-1', 'path-2']) +const css2 = await cache.readFile('/Users/me/apps/static/styles.less') ``` diff --git a/package.json b/package.json index 3dfd0c3..5adcce7 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,10 @@ "name": "less-cache", "version": "1.0.0", "description": "Less compile cache", - "main": "./lib/less-cache", + "main": "./lib/less-cache.js", "scripts": { - "prepublish": "grunt clean lint coffee", - "test": "grunt test" + "test": "mocha test/**/*.test.js --compilers js:babel-register", + "prepublish": "rimraf lib && babel src -d lib" }, "repository": { "type": "git", @@ -33,14 +33,14 @@ "walkdir": "0.0.11" }, "devDependencies": { - "fstream": "^1.0.10", - "grunt": "^1.0.1", - "grunt-cli": "^1.2.0", - "grunt-coffeelint": "0.0.16", - "grunt-contrib-coffee": "^1.0.0", - "grunt-shell": "^1.3.0", - "jasmine-focused": "1.x", - "temp": "^0.8.3", - "tmp": "0.0.28" + "babel-cli": "^6.23.0", + "babel-plugin-transform-async-to-generator": "^6.22.0", + "babel-register": "^6.23.0", + "dedent": "^0.7.0", + "expect.js": "^0.3.1", + "mocha": "^3.2.0", + "rimraf": "^2.6.1", + "sinon": "^1.17.7", + "temp": "^0.8.3" } } diff --git a/spec/less-cache-spec.coffee b/spec/less-cache-spec.coffee deleted file mode 100644 index 35df10a..0000000 --- a/spec/less-cache-spec.coffee +++ /dev/null @@ -1,361 +0,0 @@ -fs = require 'fs' -{dirname, join} = require 'path' - -tmp = require 'tmp' -temp = require('temp').track() -fstream = require 'fstream' - -LessCache = require '../src/less-cache' - -describe "LessCache", -> - [cache, fixturesDir] = [] - - beforeEach -> - fixturesDir = null - tmp.dir (error, tempDir) -> - reader = fstream.Reader(join(__dirname, 'fixtures')) - reader.on 'end', -> - fixturesDir = tempDir - cacheConfig = - importPaths: [join(fixturesDir, 'imports-1'), join(fixturesDir, 'imports-2')] - cacheDir: join(tempDir, 'cache') - cache = new LessCache(cacheConfig) - reader.pipe(fstream.Writer(tempDir)) - - waitsFor -> fixturesDir? - - describe "::cssForFile(filePath)", -> - filePath = null - fileLess = """ - @import "a"; - @import "b"; - @import "c"; - @import "d"; - - body { - a: @a; - b: @b; - c: @c; - d: @d; - } - """ - - beforeEach -> - filePath = join(fixturesDir, 'imports.less') - - it "returns the compiled CSS for a given path and Less content", -> - css = cache.cssForFile(filePath, fileLess) - expect(css).toBe """ - body { - a: 1; - b: 2; - c: 3; - d: 4; - } - - """ - - describe "::readFileSync(filePath)", -> - [css] = [] - - beforeEach -> - css = cache.readFileSync(join(fixturesDir, 'imports.less')) - expect(cache.stats.hits).toBe 0 - expect(cache.stats.misses).toBe 1 - - it "returns the compiled CSS for a given Less file path", -> - expect(css).toBe """ - body { - a: 1; - b: 2; - c: 3; - d: 4; - } - - """ - - it "returns the cached CSS for a given Less file path", -> - spyOn(cache, 'parseLess').andCallThrough() - expect(cache.readFileSync(join(fixturesDir, 'imports.less'))).toBe """ - body { - a: 1; - b: 2; - c: 3; - d: 4; - } - - """ - expect(cache.parseLess.callCount).toBe 0 - expect(cache.stats.hits).toBe 1 - expect(cache.stats.misses).toBe 1 - - it "reflects changes to the file being read", -> - fs.writeFileSync(join(fixturesDir, 'imports.less'), 'body { display: block; }') - css = cache.readFileSync(join(fixturesDir, 'imports.less')) - expect(css).toBe """ - body { - display: block; - } - - """ - - it "reflects changes to files imported by the file being read", -> - fs.writeFileSync(join(fixturesDir, 'b.less'), '@b: 20;') - css = cache.readFileSync(join(fixturesDir, 'imports.less')) - expect(css).toBe """ - body { - a: 1; - b: 20; - c: 3; - d: 4; - } - - """ - - it "reflects changes to files on the import path", -> - fs.writeFileSync(join(fixturesDir, 'imports-1', 'd.less'), '@d: 40;') - cache.setImportPaths(cache.getImportPaths()) - css = cache.readFileSync(join(fixturesDir, 'imports.less')) - expect(css).toBe """ - body { - a: 1; - b: 2; - c: 3; - d: 40; - } - - """ - - fs.unlinkSync(join(fixturesDir, 'imports-1', 'c.less')) - cache.setImportPaths(cache.getImportPaths()) - css = cache.readFileSync(join(fixturesDir, 'imports.less')) - expect(css).toBe """ - body { - a: 1; - b: 2; - c: 30; - d: 40; - } - - """ - - fs.writeFileSync(join(fixturesDir, 'imports-1', 'd.less'), '@d: 400;') - cache.setImportPaths(cache.getImportPaths()) - css = cache.readFileSync(join(fixturesDir, 'imports.less')) - expect(css).toBe """ - body { - a: 1; - b: 2; - c: 30; - d: 400; - } - - """ - - it "reflect changes to the import paths array", -> - spyOn(cache, 'parseLess').andCallThrough() - cache.setImportPaths([join(fixturesDir, 'imports-1'), join(fixturesDir, 'imports-2')]) - cache.readFileSync(join(fixturesDir, 'imports.less')) - expect(cache.parseLess.callCount).toBe 0 - - cache.setImportPaths([join(fixturesDir, 'imports-2'), join(fixturesDir, 'imports-1'), join(fixturesDir, 'import-does-not-exist')]) - css = cache.readFileSync(join(fixturesDir, 'imports.less')) - expect(css).toBe """ - body { - a: 1; - b: 2; - c: 30; - d: 4; - } - - """ - expect(cache.parseLess.callCount).toBe 1 - - cache.parseLess.reset() - cache.setImportPaths([join(fixturesDir, 'imports-1'), join(fixturesDir, 'imports-2')]) - expect(cache.readFileSync(join(fixturesDir, 'imports.less'))).toBe """ - body { - a: 1; - b: 2; - c: 3; - d: 4; - } - - """ - expect(cache.parseLess.callCount).toBe 0 - - it "reuses cached CSS across cache instances", -> - cache2 = new LessCache(cacheDir: cache.getDirectory(), importPaths: cache.getImportPaths()) - spyOn(cache2, 'parseLess').andCallThrough() - cache2.readFileSync(join(fixturesDir, 'imports.less')) - expect(cache2.parseLess.callCount).toBe 0 - - it "throws compile errors", -> - expect(-> cache.readFileSync(join(fixturesDir, 'invalid.less'))).toThrow() - - it "throws file not found errors", -> - expect(-> cache.readFileSync(join(fixturesDir, 'does-not-exist.less'))).toThrow() - - it "relativizes cache paths based on the configured resource path", -> - cache2 = new LessCache(cacheDir: cache.getDirectory(), importPaths: cache.getImportPaths(), resourcePath: fixturesDir) - expect(fs.existsSync(join(cache2.importsCacheDir, 'content', 'imports.json'))).toBeFalsy() - cache2.readFileSync(join(fixturesDir, 'imports.less')) - expect(fs.existsSync(join(cache2.importsCacheDir, 'content', 'imports.json'))).toBeTruthy() - - it "uses the fallback directory when no cache entry is found in the primary directory", -> - cache2 = new LessCache - cacheDir: join(dirname(cache.getDirectory()), 'cache2') - importPaths: cache.getImportPaths() - fallbackDir: cache.getDirectory() - resourcePath: fixturesDir - cache2.readFileSync(join(fixturesDir, 'imports.less')) - - cache3 = new LessCache - cacheDir: join(dirname(cache.getDirectory()), 'cache3') - importPaths: cache2.getImportPaths() - fallbackDir: cache2.getDirectory() - resourcePath: fixturesDir - - spyOn(cache3, 'parseLess').andCallThrough() - cache3.readFileSync(join(fixturesDir, 'imports.less')) - expect(cache3.parseLess.callCount).toBe 0 - - describe "when syncCaches option is set to true", -> - it "writes the cache entry to the fallback cache when initially uncached", -> - fallback = new LessCache - cacheDir: join(dirname(cache.getDirectory()), 'fallback') - resourcePath: fixturesDir - - cache = new LessCache - cacheDir: join(dirname(cache.getDirectory()), 'synced') - syncCaches: true - fallbackDir: join(dirname(cache.getDirectory()), 'fallback') - resourcePath: fixturesDir - - cacheCss = cache.readFileSync(join(fixturesDir, 'a.less')) - expect(cache.stats.hits).toBe 0 - expect(cache.stats.misses).toBe 1 - - fallbackCss = fallback.readFileSync(join(fixturesDir, 'a.less')) - expect(fallback.stats.hits).toBe 1 - expect(fallback.stats.misses).toBe 0 - - expect(cacheCss).toBe fallbackCss - - it "writes the cache entry to the fallback cache when read from the main cache", -> - cache = new LessCache - cacheDir: join(dirname(cache.getDirectory()), 'synced') - resourcePath: fixturesDir - - fallback = new LessCache - cacheDir: join(dirname(cache.getDirectory()), 'fallback') - resourcePath: fixturesDir - - cacheWithFallback = new LessCache - cacheDir: join(dirname(cache.getDirectory()), 'synced') - syncCaches: true - fallbackDir: join(dirname(cache.getDirectory()), 'fallback') - resourcePath: fixturesDir - - # Prime main cache - cache.readFileSync(join(fixturesDir, 'a.less')) - - # Read from main cache with write to fallback - cacheWithFallback.readFileSync(join(fixturesDir, 'a.less')) - - # Read from fallback cache - fallback.readFileSync(join(fixturesDir, 'a.less')) - - expect(fallback.stats.hits).toBe 1 - expect(fallback.stats.misses).toBe 0 - - it "writes the cache entry to the main cache when read from the fallback cache", -> - cache = new LessCache - cacheDir: join(dirname(cache.getDirectory()), 'synced') - resourcePath: fixturesDir - - fallback = new LessCache - cacheDir: join(dirname(cache.getDirectory()), 'fallback') - resourcePath: fixturesDir - - cacheWithFallback = new LessCache - cacheDir: join(dirname(cache.getDirectory()), 'synced') - syncCaches: true - fallbackDir: join(dirname(cache.getDirectory()), 'fallback') - resourcePath: fixturesDir - - # Prime fallback cache - fallback.readFileSync(join(fixturesDir, 'a.less')) - - # Read from fallback with write to main cache - cacheWithFallback.readFileSync(join(fixturesDir, 'a.less')) - - # Read from main cache - cache.readFileSync(join(fixturesDir, 'a.less')) - - expect(cache.stats.hits).toBe 1 - expect(cache.stats.misses).toBe 0 - - describe "when providing a resource path and less sources by relative file path", -> - it "reads from the provided sources first, and falls back to reading from disk if a valid source isn't available", -> - cacheDir = temp.mkdirSync() - cache1 = new LessCache - cacheDir: cacheDir - importPaths: [join(fixturesDir, 'imports-1'), join(fixturesDir, 'imports-2')] - resourcePath: fixturesDir - lessSourcesByRelativeFilePath: { - 'imports.less': """ - @import "a"; - @import "b"; - @import "c"; - @import "d"; - - some-selector { - prop-1: @a; - prop-2: @b; - prop-3: @c; - prop-4: @d; - } - """ - } - - expect(cache1.readFileSync(join(fixturesDir, 'imports.less'))).toBe(""" - some-selector { - prop-1: 1; - prop-2: 2; - prop-3: 3; - prop-4: 4; - }\n - """) - - cache2 = new LessCache - cacheDir: cacheDir - importPaths: [join(fixturesDir, 'imports-1'), join(fixturesDir, 'imports-2')] - resourcePath: fixturesDir - lessSourcesByRelativeFilePath: { - 'imports.less': """ - @import "a"; - @import "b"; - @import "c"; - @import "d"; - - some-selector { - prop-1: @a; - prop-2: @b; - prop-3: @c; - prop-4: @d; - } - """, - 'imports-1/c.less': """ - @c: "changed"; - """ - } - - expect(cache2.readFileSync(join(fixturesDir, 'imports.less'))).toBe(""" - some-selector { - prop-1: 1; - prop-2: 2; - prop-3: "changed"; - prop-4: 4; - }\n - """) diff --git a/src/less-cache.coffee b/src/less-cache.coffee deleted file mode 100644 index 60d7d79..0000000 --- a/src/less-cache.coffee +++ /dev/null @@ -1,214 +0,0 @@ -crypto = require 'crypto' -{basename, dirname, extname, join, relative} = require 'path' - -_ = require 'underscore-plus' -fs = require 'fs-plus' -less = null # Defer until it is actually used -lessFs = null # Defer until it is actually used -walkdir = require('walkdir').sync - -cacheVersion = 1 - -module.exports = -class LessCache - # Create a new Less cache with the given options. - # - # options - An object with following keys - # * cacheDir: A string path to the directory to store cached files in (required) - # - # * importPaths: An array of strings to configure the Less parser with (optional) - # - # * resourcePath: A string path to use for relativizing paths. This is useful if - # you want to make caches transferable between directories or - # machines. (optional) - # - # * fallbackDir: A string path to a directory containing a readable cache to read - # from an entry is not found in this cache (optional) - constructor: ({@cacheDir, @importPaths, @resourcePath, @fallbackDir, @syncCaches, @lessSourcesByRelativeFilePath}={}) -> - @lessSourcesByRelativeFilePath ?= {} - @importsCacheDir = @cacheDirectoryForImports(@importPaths) - if @fallbackDir - @importsFallbackDir = join(@fallbackDir, basename(@importsCacheDir)) - - try - {@importedFiles} = @readJson(join(@importsCacheDir, 'imports.json')) - - @setImportPaths(@importPaths) - - @stats = - hits: 0 - misses: 0 - - cacheDirectoryForImports: (importPaths=[]) -> - if @resourcePath - importPaths = importPaths.map (importPath) => - @relativize(@resourcePath, importPath) - join(@cacheDir, @digestForContent(importPaths.join('\n'))) - - getDirectory: -> @cacheDir - - getImportPaths: -> _.clone(@importPaths) - - getImportedFiles: (importPaths) -> - importedFiles = [] - for importPath in importPaths - try - walkdir importPath, no_return: true, (filePath, stat) => - return unless stat.isFile() - filePath = @relativize(@resourcePath, filePath) if @resourcePath - importedFiles.push(filePath) - catch error - continue - - importedFiles - - setImportPaths: (importPaths=[]) -> - importedFiles = @getImportedFiles(importPaths) - - pathsChanged = not _.isEqual(@importPaths, importPaths) - filesChanged = not _.isEqual(@importedFiles, importedFiles) - if pathsChanged - @importsCacheDir = @cacheDirectoryForImports(importPaths) - if @fallbackDir - @importsFallbackDir = join(@fallbackDir, basename(@importsCacheDir)) - else if filesChanged - try - fs.removeSync(@importsCacheDir) - catch error - if error?.code is 'ENOENT' - try - fs.removeSync(@importsCacheDir) # Retry once - - @writeJson(join(@importsCacheDir, 'imports.json'), {importedFiles}) - - @importedFiles = importedFiles - @importPaths = importPaths - - observeImportedFilePaths: (callback) -> - importedPaths = [] - lessFs ?= require 'less/lib/less-node/fs.js' - originalFsReadFileSync = lessFs.readFileSync - lessFs.readFileSync = (filePath, args...) => - relativeFilePath = @relativize(@resourcePath, filePath) if @resourcePath - content = @lessSourcesByRelativeFilePath[relativeFilePath] ? originalFsReadFileSync(filePath, args...) - importedPaths.push({path: relativeFilePath ? filePath, digest: @digestForContent(content)}) - content - - try - callback() - finally - lessFs.readFileSync = originalFsReadFileSync - - importedPaths - - readJson: (filePath) -> JSON.parse(fs.readFileSync(filePath)) - - writeJson: (filePath, object) -> fs.writeFileSync(filePath, JSON.stringify(object)) - - digestForPath: (relativeFilePath) -> - lessSource = @lessSourcesByRelativeFilePath[relativeFilePath] - unless lessSource? - absoluteFilePath = null - if @resourcePath and not fs.isAbsolute(relativeFilePath) - absoluteFilePath = join(@resourcePath, relativeFilePath) - else - absoluteFilePath = relativeFilePath - lessSource = fs.readFileSync(absoluteFilePath) - - @digestForContent(lessSource) - - digestForContent: (content) -> - crypto.createHash('SHA1').update(content, 'utf8').digest('hex') - - relativize: (from, to) -> - relativePath = relative(from, to) - if relativePath.indexOf('..') is 0 - to - else - relativePath - - getCachePath: (directory, filePath) -> - cacheFile = "#{basename(filePath, extname(filePath))}.json" - directoryPath = dirname(filePath) - directoryPath = @relativize(@resourcePath, directoryPath) if @resourcePath - directoryPath = @digestForContent(directoryPath) if directoryPath - join(directory, 'content', directoryPath, cacheFile) - - getCachedCss: (filePath, digest) -> - try - cacheEntry = @readJson(@getCachePath(@importsCacheDir, filePath)) - catch error - if @importsFallbackDir? - try - cacheEntry = @readJson(@getCachePath(@importsFallbackDir, filePath)) - fallbackDirUsed = true - - return unless digest is cacheEntry?.digest - - for {path, digest} in cacheEntry.imports - try - return if @digestForPath(path) isnt digest - catch error - return - - if @syncCaches - if fallbackDirUsed - @writeJson(@getCachePath(@importsCacheDir, filePath), cacheEntry) - else if @importsFallbackDir? - @writeJson(@getCachePath(@importsFallbackDir, filePath), cacheEntry) - - cacheEntry.css - - putCachedCss: (filePath, digest, css, imports) -> - cacheEntry = {digest, css, imports, version: cacheVersion} - @writeJson(@getCachePath(@importsCacheDir, filePath), cacheEntry) - - if @syncCaches and @importsFallbackDir? - @writeJson(@getCachePath(@importsFallbackDir, filePath), cacheEntry) - - parseLess: (filePath, contents) -> - css = null - options = filename: filePath, syncImport: true, paths: @importPaths - less ?= require('less') - imports = @observeImportedFilePaths -> - less.render contents, options, (error, result) -> - if error? - throw error - else - {css} = result - {imports, css} - - # Read the Less file at the current path and return either the cached CSS or the newly - # compiled CSS. This method caches the compiled CSS after it is generated. This cached - # CSS will be returned as long as the Less file and any of its imports are unchanged. - # - # filePath: A string path to a Less file. - # - # Returns the compiled CSS for the given path. - readFileSync: (absoluteFilePath) -> - fileContents = null - if @resourcePath and fs.isAbsolute(absoluteFilePath) - relativeFilePath = @relativize(@resourcePath, absoluteFilePath) - fileContents = @lessSourcesByRelativeFilePath[relativeFilePath] - - @cssForFile(absoluteFilePath, fileContents ? fs.readFileSync(absoluteFilePath, 'utf8')) - - # Return either cached CSS or the newly - # compiled CSS from `lessContent`. This method caches the compiled CSS after it is generated. This cached - # CSS will be returned as long as the Less file and any of its imports are unchanged. - # - # filePath: A string path to the Less file. - # lessContent: The contents of the filePath - # - # Returns the compiled CSS for the given path and lessContent - cssForFile: (filePath, lessContent) -> - digest = @digestForContent(lessContent) - css = @getCachedCss(filePath, digest) - if css? - @stats.hits++ - return css - - @stats.misses++ - {imports, css} = @parseLess(filePath, lessContent) - @putCachedCss(filePath, digest, css, imports) - css diff --git a/src/less-cache.js b/src/less-cache.js new file mode 100644 index 0000000..1d82287 --- /dev/null +++ b/src/less-cache.js @@ -0,0 +1,347 @@ +const crypto = require('crypto') +const {basename, dirname, extname, join, relative} = require('path') + +const _ = require('underscore-plus') +const fs = require('fs-plus') +let less = null // Defer until it is actually used +let lessFs = null // Defer until it is actually used +const walkdir = require('walkdir') + +const cacheVersion = 1 + +module.exports = +class LessCache { + // Create a new Less cache with the given options. + // + // options - An object with following keys + // * cacheDir: A string path to the directory to store cached files in (required) + // + // * importPaths: An array of strings to configure the Less parser with (optional) + // + // * resourcePath: A string path to use for relativizing paths. This is useful if + // you want to make caches transferable between directories or + // machines. (optional) + // + // * fallbackDir: A string path to a directory containing a readable cache to read + // from an entry is not found in this cache (optional) + constructor ({cacheDir, importPaths, resourcePath, fallbackDir, syncCaches, lessSourcesByRelativeFilePath} = {}) { + this.cacheDir = cacheDir + this.importPaths = importPaths + this.resourcePath = resourcePath + this.fallbackDir = fallbackDir + this.syncCaches = syncCaches + this.lessSourcesByRelativeFilePath = lessSourcesByRelativeFilePath + if (this.lessSourcesByRelativeFilePath == null) { this.lessSourcesByRelativeFilePath = {} } + this.importsCacheDir = this.cacheDirectoryForImports(this.importPaths) + if (this.fallbackDir) { + this.importsFallbackDir = join(this.fallbackDir, basename(this.importsCacheDir)) + } + this.stats = { + hits: 0, + misses: 0 + } + } + + load () { + return this.setImportPaths(this.importPaths, true) + } + + cacheDirectoryForImports (importPaths = []) { + if (this.resourcePath) { + importPaths = importPaths.map(importPath => this.relativize(this.resourcePath, importPath)) + } + return join(this.cacheDir, this.digestForContent(importPaths.join('\n'))) + } + + getDirectory () { return this.cacheDir } + + getImportPaths () { return _.clone(this.importPaths) } + + async getImportedFiles (importPaths) { + let importedFiles = [] + for (let i = 0; i < importPaths.length; i++) { + try { + const filePaths = await this.getFilePathsAtImportPath(importPaths[i]) + importedFiles = importedFiles.concat(filePaths) + } catch (error) { + continue + } + } + return importedFiles + } + + getFilePathsAtImportPath (importPath) { + return new Promise((resolve, reject) => { + const filePaths = [] + const emitter = walkdir(importPath, (filePath, stat) => { + if (stat.isFile()) { + if (this.resourcePath) { + filePath = this.relativize(this.resourcePath, filePath) + } + filePaths.push(filePath) + } + }) + const disposeEmitter = () => { + emitter.removeAllListeners('error') + emitter.removeAllListeners('end') + } + emitter.on('error', (error) => { + disposeEmitter() + reject(error) + }) + emitter.on('end', () => { + disposeEmitter() + resolve(filePaths) + }) + }) + } + + async setImportPaths (importPaths, firstLoad) { + if (this.importPathsPromise) { + this.importPathsPromise = this.importPathsPromise.then(() => this._setImportPaths(importPaths, firstLoad)) + } else { + this.importPathsPromise = this._setImportPaths(importPaths, firstLoad) + } + + return this.importPathsPromise + } + + async _setImportPaths (importPaths = [], firstLoad = false) { + const importedFiles = await this.getImportedFiles(importPaths) + const pathsChanged = !_.isEqual(this.importPaths, importPaths) + const filesChanged = !_.isEqual(this.importedFiles, importedFiles) + if (pathsChanged) { + this.importsCacheDir = this.cacheDirectoryForImports(importPaths) + if (this.fallbackDir) { + this.importsFallbackDir = join(this.fallbackDir, basename(this.importsCacheDir)) + } + } else if (filesChanged && !firstLoad) { + try { + await deletePath(this.importsCacheDir) + } catch (error) { + if (error && error.code === 'ENOENT') { + try { + await deletePath(this.importsCacheDir) // Retry once + } catch (error) {} + } + } + } + + await this.writeJSON(join(this.importsCacheDir, 'imports.json'), {importedFiles}) + this.importedFiles = importedFiles + this.importPaths = importPaths + } + + observeImportedFilePaths (callback) { + const importedPaths = [] + if (lessFs == null) { lessFs = require('less/lib/less-node/fs.js') } + const originalFsReadFileSync = lessFs.readFileSync + lessFs.readFileSync = (filePath, ...args) => { + let relativeFilePath + if (this.resourcePath) { relativeFilePath = this.relativize(this.resourcePath, filePath) } + const content = this.lessSourcesByRelativeFilePath[relativeFilePath] != null ? + this.lessSourcesByRelativeFilePath[relativeFilePath] : + originalFsReadFileSync(filePath, ...args) + importedPaths.push({path: relativeFilePath != null ? relativeFilePath : filePath, digest: this.digestForContent(content)}) + return content + } + + try { + callback() + } finally { + lessFs.readFileSync = originalFsReadFileSync + } + + return importedPaths + } + + readJSON (filePath) { + return readFile(filePath).then((content) => JSON.parse(content)) + } + + writeJSON (filePath, object) { + return writeFile(filePath, JSON.stringify(object)) + } + + async digestForPath (relativeFilePath) { + let lessSource = this.lessSourcesByRelativeFilePath[relativeFilePath] + if (lessSource == null) { + let absoluteFilePath = null + if (this.resourcePath && !fs.isAbsolute(relativeFilePath)) { + absoluteFilePath = join(this.resourcePath, relativeFilePath) + } else { + absoluteFilePath = relativeFilePath + } + lessSource = await readFile(absoluteFilePath) + } + + return this.digestForContent(lessSource) + } + + digestForContent (content) { + return crypto.createHash('SHA1').update(content, 'utf8').digest('hex') + } + + relativize (from, to) { + const relativePath = relative(from, to) + if (relativePath.indexOf('..') === 0) { + return to + } else { + return relativePath + } + } + + getCachePath (directory, filePath) { + const cacheFile = `${basename(filePath, extname(filePath))}.json` + let directoryPath = dirname(filePath) + if (this.resourcePath) { directoryPath = this.relativize(this.resourcePath, directoryPath) } + if (directoryPath) { directoryPath = this.digestForContent(directoryPath) } + return join(directory, 'content', directoryPath, cacheFile) + } + + async getCachedCss (filePath, digest) { + let cacheEntry, fallbackDirUsed, path + try { + cacheEntry = await this.readJSON(this.getCachePath(this.importsCacheDir, filePath)) + } catch (error) { + if (this.importsFallbackDir != null) { + try { + cacheEntry = await this.readJSON(this.getCachePath(this.importsFallbackDir, filePath)) + fallbackDirUsed = true + } catch (error) {} + } + } + + if (!cacheEntry || digest !== cacheEntry.digest) { + return + } + + for (const {path, digest} of cacheEntry.imports) { + try { + const digestForPath = await this.digestForPath(path) + if (digestForPath !== digest) { + return + } + } catch (error) { + return + } + } + + if (this.syncCaches) { + if (fallbackDirUsed) { + await this.writeJSON(this.getCachePath(this.importsCacheDir, filePath), cacheEntry) + } else if (this.importsFallbackDir != null) { + await this.writeJSON(this.getCachePath(this.importsFallbackDir, filePath), cacheEntry) + } + } + + return cacheEntry.css + } + + async putCachedCss (filePath, digest, css, imports) { + const cacheEntry = {digest, css, imports, version: cacheVersion} + await this.writeJSON(this.getCachePath(this.importsCacheDir, filePath), cacheEntry) + + if (this.syncCaches && (this.importsFallbackDir != null)) { + await this.writeJSON(this.getCachePath(this.importsFallbackDir, filePath), cacheEntry) + } + } + + parseLess (filePath, contents) { + let css = null + const options = {filename: filePath, syncImport: true, paths: this.importPaths} + if (less == null) { less = require('less') } + const imports = this.observeImportedFilePaths(() => + less.render(contents, options, function (error, result) { + if (error != null) { + throw error + } else { + css = result.css + } + }) + ) + return {imports, css} + } + + // Read the Less file at the current path and return either the cached CSS or the newly + // compiled CSS. This method caches the compiled CSS after it is generated. This cached + // CSS will be returned as long as the Less file and any of its imports are unchanged. + // + // filePath: A string path to a Less file. + // + // Returns the compiled CSS for the given path. + async readFile (absoluteFilePath) { + let fileContents = null + if (this.resourcePath && fs.isAbsolute(absoluteFilePath)) { + const relativeFilePath = this.relativize(this.resourcePath, absoluteFilePath) + fileContents = this.lessSourcesByRelativeFilePath[relativeFilePath] + } + + if (fileContents == null) { + fileContents = await readFile(absoluteFilePath) + } + + return this.cssForFile(absoluteFilePath, fileContents) + } + + // Return either cached CSS or the newly + // compiled CSS from `lessContent`. This method caches the compiled CSS after it is generated. This cached + // CSS will be returned as long as the Less file and any of its imports are unchanged. + // + // filePath: A string path to the Less file. + // lessContent: The contents of the filePath + // + // Returns the compiled CSS for the given path and lessContent + async cssForFile (filePath, lessContent) { + await this.importPathsPromise + + let imports + const digest = this.digestForContent(lessContent) + let css = await this.getCachedCss(filePath, digest) + if (css != null) { + this.stats.hits++ + return css + } + + this.stats.misses++ + ({imports, css} = this.parseLess(filePath, lessContent)) + await this.putCachedCss(filePath, digest, css, imports) + return css + } +} + +function readFile (filePath) { + return new Promise((resolve, reject) => { + fs.readFile(filePath, 'utf8', (error, contents) => { + if (error) { + reject(error) + } else { + resolve(contents) + } + }) + }) +} + +function writeFile (filePath, contents) { + return new Promise((resolve, reject) => { + fs.writeFile(filePath, contents, (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +function deletePath (path) { + return new Promise((resolve, reject) => { + fs.remove(path, (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} diff --git a/spec/fixtures/a.less b/test/fixtures/a.less similarity index 100% rename from spec/fixtures/a.less rename to test/fixtures/a.less diff --git a/spec/fixtures/b.less b/test/fixtures/b.less similarity index 100% rename from spec/fixtures/b.less rename to test/fixtures/b.less diff --git a/spec/fixtures/imports-1/c.less b/test/fixtures/imports-1/c.less similarity index 100% rename from spec/fixtures/imports-1/c.less rename to test/fixtures/imports-1/c.less diff --git a/spec/fixtures/imports-2/c.less b/test/fixtures/imports-2/c.less similarity index 100% rename from spec/fixtures/imports-2/c.less rename to test/fixtures/imports-2/c.less diff --git a/spec/fixtures/imports-2/d.less b/test/fixtures/imports-2/d.less similarity index 100% rename from spec/fixtures/imports-2/d.less rename to test/fixtures/imports-2/d.less diff --git a/spec/fixtures/imports.less b/test/fixtures/imports.less similarity index 100% rename from spec/fixtures/imports.less rename to test/fixtures/imports.less diff --git a/spec/fixtures/invalid.less b/test/fixtures/invalid.less similarity index 100% rename from spec/fixtures/invalid.less rename to test/fixtures/invalid.less diff --git a/test/less-cache.test.js b/test/less-cache.test.js new file mode 100644 index 0000000..9d78b80 --- /dev/null +++ b/test/less-cache.test.js @@ -0,0 +1,401 @@ +const expect = require('expect.js') +const sinon = require('sinon') + +const fs = require('fs-plus') +const {dirname, join} = require('path') + +const temp = require('temp').track() +const dedent = require('dedent') + +const LessCache = require('../src/less-cache') + +describe('LessCache', function () { + let [cache, fixturesDir] = [] + + beforeEach(function () { + fixturesDir = temp.path() + fs.copySync(join(__dirname, 'fixtures'), fixturesDir) + cache = new LessCache({ + importPaths: [join(fixturesDir, 'imports-1'), join(fixturesDir, 'imports-2')], + cacheDir: join(fixturesDir, 'cache') + }) + cache.load() + }) + + describe('::cssForFile(filePath)', function () { + it('returns the compiled CSS for a given path and Less content', async function () { + const fileLess = dedent` + @import "a"; + @import "b"; + @import "c"; + @import "d"; + + body { + a: @a; + b: @b; + c: @c; + d: @d; + } + ` + const css = await cache.cssForFile(join(fixturesDir, 'imports.less'), fileLess) + expect(css).to.be(dedent` + body { + a: 1; + b: 2; + c: 3; + d: 4; + }\n + `) + }) + }) + + describe('::readFile(filePath)', function () { + let [css] = [] + + beforeEach(async function () { + css = await cache.readFile(join(fixturesDir, 'imports.less')) + expect(cache.stats.hits).to.be(0) + expect(cache.stats.misses).to.be(1) + }) + + it('returns the compiled CSS for a given Less file path', () => + expect(css).to.be(dedent` + body { + a: 1; + b: 2; + c: 3; + d: 4; + }\n + `) + ) + + it('returns the cached CSS for a given Less file path', async function () { + sinon.spy(cache, 'parseLess') + expect(await cache.readFile(join(fixturesDir, 'imports.less'))).to.be(dedent` + body { + a: 1; + b: 2; + c: 3; + d: 4; + }\n + `) + expect(cache.parseLess.callCount).to.be(0) + expect(cache.stats.hits).to.be(1) + expect(cache.stats.misses).to.be(1) + }) + + it('reflects changes to the file being read', async function () { + fs.writeFileSync(join(fixturesDir, 'imports.less'), 'body { display: block; }') + css = await cache.readFile(join(fixturesDir, 'imports.less')) + expect(css).to.be(dedent` + body { + display: block; + }\n + `) + }) + + it('reflects changes to files imported by the file being read', async function () { + fs.writeFileSync(join(fixturesDir, 'b.less'), '@b: 20;') + css = await cache.readFile(join(fixturesDir, 'imports.less')) + expect(css).to.be(dedent` + body { + a: 1; + b: 20; + c: 3; + d: 4; + }\n + `) + }) + + it('reflects changes to files on the import path', async function () { + fs.writeFileSync(join(fixturesDir, 'imports-1', 'd.less'), '@d: 40;') + cache.setImportPaths(cache.getImportPaths()) + css = await cache.readFile(join(fixturesDir, 'imports.less')) + expect(css).to.be(dedent` + body { + a: 1; + b: 2; + c: 3; + d: 40; + }\n + `) + + fs.unlinkSync(join(fixturesDir, 'imports-1', 'c.less')) + cache.setImportPaths(cache.getImportPaths()) + css = await cache.readFile(join(fixturesDir, 'imports.less')) + expect(css).to.be(dedent` + body { + a: 1; + b: 2; + c: 30; + d: 40; + }\n + `) + + fs.writeFileSync(join(fixturesDir, 'imports-1', 'd.less'), '@d: 400;') + cache.setImportPaths(cache.getImportPaths()) + css = await cache.readFile(join(fixturesDir, 'imports.less')) + expect(css).to.be(dedent` + body { + a: 1; + b: 2; + c: 30; + d: 400; + }\n + `) + }) + + it('reflect changes to the import paths array', async function () { + sinon.spy(cache, 'parseLess') + cache.setImportPaths([join(fixturesDir, 'imports-1'), join(fixturesDir, 'imports-2')]) + await cache.readFile(join(fixturesDir, 'imports.less')) + expect(cache.parseLess.callCount).to.be(0) + + cache.setImportPaths([join(fixturesDir, 'imports-2'), join(fixturesDir, 'imports-1'), join(fixturesDir, 'import-does-not-exist')]) + css = await cache.readFile(join(fixturesDir, 'imports.less')) + expect(css).to.be(dedent` + body { + a: 1; + b: 2; + c: 30; + d: 4; + }\n + `) + expect(cache.parseLess.callCount).to.be(1) + + cache.parseLess.reset() + cache.setImportPaths([join(fixturesDir, 'imports-1'), join(fixturesDir, 'imports-2')]) + expect(await cache.readFile(join(fixturesDir, 'imports.less'))).to.be(dedent` + body { + a: 1; + b: 2; + c: 3; + d: 4; + }\n + `) + expect(cache.parseLess.callCount).to.be(0) + }) + + it('reuses cached CSS across cache instances', async function () { + const cache2 = new LessCache({cacheDir: cache.getDirectory(), importPaths: cache.getImportPaths()}) + cache2.load() + sinon.spy(cache2, 'parseLess') + await cache2.readFile(join(fixturesDir, 'imports.less')) + expect(cache2.parseLess.callCount).to.be(0) + }) + + it('throws compile errors', async function () { + let threwError = false + try { + await cache.readFile(join(fixturesDir, 'invalid.less')) + } catch (e) { + threwError = true + } + expect(threwError).to.be(true) + }) + + it('throws file not found errors', async function () { + let threwError = false + try { + await cache.readFile(join(fixturesDir, 'does-not-exist.less')) + } catch (e) { + threwError = true + } + expect(threwError).to.be(true) + }) + + it('relativizes cache paths based on the configured resource path', async function () { + const cache2 = new LessCache({cacheDir: cache.getDirectory(), importPaths: cache.getImportPaths(), resourcePath: fixturesDir}) + await cache2.load() + expect(fs.existsSync(join(cache2.importsCacheDir, 'content', 'imports.json'))).to.be(false) + await cache2.readFile(join(fixturesDir, 'imports.less')) + expect(fs.existsSync(join(cache2.importsCacheDir, 'content', 'imports.json'))).to.be(true) + }) + + it('uses the fallback directory when no cache entry is found in the primary directory', async function () { + const cache2 = new LessCache({ + cacheDir: join(dirname(cache.getDirectory()), 'cache2'), + importPaths: cache.getImportPaths(), + fallbackDir: cache.getDirectory(), + resourcePath: fixturesDir + }) + cache2.load() + await cache2.readFile(join(fixturesDir, 'imports.less')) + + const cache3 = new LessCache({ + cacheDir: join(dirname(cache.getDirectory()), 'cache3'), + importPaths: cache2.getImportPaths(), + fallbackDir: cache2.getDirectory(), + resourcePath: fixturesDir + }) + cache3.load() + + sinon.spy(cache3, 'parseLess') + await cache3.readFile(join(fixturesDir, 'imports.less')) + expect(cache3.parseLess.callCount).to.be(0) + }) + }) + + describe('when syncCaches option is set to true', function () { + it('writes the cache entry to the fallback cache when initially uncached', async function () { + const fallback = new LessCache({ + cacheDir: join(dirname(cache.getDirectory()), 'fallback'), + resourcePath: fixturesDir + }) + fallback.load() + + cache = new LessCache({ + cacheDir: join(dirname(cache.getDirectory()), 'synced'), + syncCaches: true, + fallbackDir: join(dirname(cache.getDirectory()), 'fallback'), + resourcePath: fixturesDir + }) + cache.load() + + const cacheCss = await cache.readFile(join(fixturesDir, 'a.less')) + expect(cache.stats.hits).to.be(0) + expect(cache.stats.misses).to.be(1) + + const fallbackCss = await fallback.readFile(join(fixturesDir, 'a.less')) + expect(fallback.stats.hits).to.be(1) + expect(fallback.stats.misses).to.be(0) + + expect(cacheCss).to.be(fallbackCss) + }) + + it('writes the cache entry to the fallback cache when read from the main cache', async function () { + cache = new LessCache({ + cacheDir: join(dirname(cache.getDirectory()), 'synced'), + resourcePath: fixturesDir + }) + cache.load() + + const fallback = new LessCache({ + cacheDir: join(dirname(cache.getDirectory()), 'fallback'), + resourcePath: fixturesDir + }) + fallback.load() + + const cacheWithFallback = new LessCache({ + cacheDir: join(dirname(cache.getDirectory()), 'synced'), + syncCaches: true, + fallbackDir: join(dirname(cache.getDirectory()), 'fallback'), + resourcePath: fixturesDir + }) + cacheWithFallback.load() + + // Prime main cache + await cache.readFile(join(fixturesDir, 'a.less')) + + // Read from main cache with write to fallback + await cacheWithFallback.readFile(join(fixturesDir, 'a.less')) + + // Read from fallback cache + await fallback.readFile(join(fixturesDir, 'a.less')) + + expect(fallback.stats.hits).to.be(1) + expect(fallback.stats.misses).to.be(0) + }) + + it('writes the cache entry to the main cache when read from the fallback cache', async function () { + cache = new LessCache({ + cacheDir: join(dirname(cache.getDirectory()), 'synced'), + resourcePath: fixturesDir + }) + cache.load() + + const fallback = new LessCache({ + cacheDir: join(dirname(cache.getDirectory()), 'fallback'), + resourcePath: fixturesDir + }) + fallback.load() + + const cacheWithFallback = new LessCache({ + cacheDir: join(dirname(cache.getDirectory()), 'synced'), + syncCaches: true, + fallbackDir: join(dirname(cache.getDirectory()), 'fallback'), + resourcePath: fixturesDir + }) + cacheWithFallback.load() + + // Prime fallback cache + await fallback.readFile(join(fixturesDir, 'a.less')) + + // Read from fallback with write to main cache + await cacheWithFallback.readFile(join(fixturesDir, 'a.less')) + + // Read from main cache + await cache.readFile(join(fixturesDir, 'a.less')) + + expect(cache.stats.hits).to.be(1) + expect(cache.stats.misses).to.be(0) + }) + }) + + return describe('when providing a resource path and less sources by relative file path', () => + it("reads from the provided sources first, and falls back to reading from disk if a valid source isn't available", async function () { + const cacheDir = temp.mkdirSync() + const cache1 = new LessCache({ + cacheDir, + importPaths: [join(fixturesDir, 'imports-1'), join(fixturesDir, 'imports-2')], + resourcePath: fixturesDir, + lessSourcesByRelativeFilePath: { + 'imports.less': dedent` + @import "a"; + @import "b"; + @import "c"; + @import "d"; + + some-selector { + prop-1: @a; + prop-2: @b; + prop-3: @c; + prop-4: @d; + }\n + ` + } + }) + cache1.load() + + expect(await cache1.readFile(join(fixturesDir, 'imports.less'))).to.be(dedent` + some-selector { + prop-1: 1; + prop-2: 2; + prop-3: 3; + prop-4: 4; + }\n + `) + + const cache2 = new LessCache({ + cacheDir, + importPaths: [join(fixturesDir, 'imports-1'), join(fixturesDir, 'imports-2')], + resourcePath: fixturesDir, + lessSourcesByRelativeFilePath: { + 'imports.less': dedent` + @import "a"; + @import "b"; + @import "c"; + @import "d"; + + some-selector { + prop-1: @a; + prop-2: @b; + prop-3: @c; + prop-4: @d; + }\n + `, + 'imports-1/c.less': '@c: "changed";\n' + }}) + cache2.load() + + expect(await cache2.readFile(join(fixturesDir, 'imports.less'))).to.be(dedent` + some-selector { + prop-1: 1; + prop-2: 2; + prop-3: "changed"; + prop-4: 4; + }\n + `) + }) + ) +})