UNPKG

9.66 kBJavaScriptView Raw
1"use strict";
2
3/*!
4 * Less - middleware (adapted from the stylus middleware)
5 *
6 * Copyright(c) 2014 Randy Merrill <Zoramite+github@gmail.com>
7 * MIT Licensed
8 */
9
10var extend = require('node.extend');
11var fs = require('fs');
12var less = require('less');
13var mkdirp = require('mkdirp');
14var path = require('path');
15var url = require('url');
16var utilities = require('./utilities');
17
18// Import mapping with mtimes
19var lessFiles = {};
20var cacheFileInitialized = false;
21// Allow tests to force flushing of cacheFile
22var _saveCacheToFile = function() {};
23
24// Check imports for changes.
25var checkImports = function(path, next) {
26 var nodes = lessFiles[path].imports;
27
28 if (!nodes || !nodes.length) {
29 return next();
30 }
31
32 var pending = nodes.length;
33 var changed = [];
34
35 nodes.forEach(function(imported){
36 fs.stat(imported.path, function(err, stat) {
37 // error or newer mtime
38 if (err || !imported.mtime || stat.mtime > imported.mtime) {
39 changed.push(imported.path);
40 }
41
42 --pending || next(changed);
43 });
44 });
45};
46
47var initCacheFile = function(cacheFile, log) {
48 cacheFileInitialized = true;
49 var cacheFileSaved = false;
50 _saveCacheToFile = function() {
51 if (cacheFileSaved) { // We expect to only save to the cache file once, just before exiting
52 log('cache file already appears to be saved, not saving again to', cacheFile);
53 return;
54 } else {
55 cacheFileSaved = true;
56 try {
57 fs.writeFileSync(cacheFile, JSON.stringify(lessFiles));
58 log('successfully cached imports to file', cacheFile);
59 } catch (err) {
60 log('error caching imports to file ' + cacheFile, err);
61 }
62 }
63 };
64 process.on('exit', _saveCacheToFile);
65 process.once('SIGUSR2', function() { // Handle nodemon restarts
66 _saveCacheToFile();
67 process.kill(process.pid, 'SIGUSR2');
68 });
69 process.once('SIGINT', function() {
70 _saveCacheToFile();
71 process.kill(process.pid, 'SIGINT'); // Let other SIGINT handlers run, if there are any
72 });
73
74 fs.readFile(cacheFile, 'utf8', function(err, data) {
75 if (!err) {
76 try {
77 lessFiles = extend(JSON.parse(data), lessFiles);
78 } catch (err) {
79 log('error parsing cached imports in file ' + cacheFile, err);
80 }
81 } else {
82 log('error loading cached imports file ' + cacheFile, err);
83 }
84 });
85}
86
87/**
88 * Return Connect middleware with the given `options`.
89 */
90module.exports = less.middleware = function(source, options){
91 // Source dir is required.
92 if (!source) {
93 throw new Error('less.middleware() requires `source` directory');
94 }
95
96 // Override the defaults for the middleware.
97 options = extend(true, {
98 cacheFile: null,
99 debug: false,
100 dest: source,
101 force: false,
102 once: false,
103 pathRoot: null,
104 postprocess: {
105 css: function(css, req) { return css; },
106 sourcemap: function(sourcemap, req) { return sourcemap; }
107 },
108 preprocess: {
109 less: function(src, req) { return src; },
110 path: function(pathname, req) { return pathname; },
111 importPaths: function(paths, req) { return paths; }
112 },
113 render: {
114 compress: 'auto',
115 yuicompress: false,
116 paths: []
117 },
118 storeCss: function(pathname, css, req, next) {
119 mkdirp(path.dirname(pathname), 511 /* 0777 */, function(err){
120 if (err) return next(err);
121
122 fs.writeFile(pathname, css, next);
123 });
124 },
125 storeSourcemap: function(pathname, sourcemap, req) {
126 mkdirp(path.dirname(pathname), 511 /* 0777 */, function(err){
127 if (err) {
128 utilities.lessError(err);
129 return;
130 }
131
132 fs.writeFile(pathname, sourcemap, function(err) {
133 if (err) throw err;
134 });
135 });
136 }
137 }, options || {});
138
139 // The log function is determined by the debug option.
140 var log = (options.debug ? utilities.logDebug : utilities.log);
141
142 if (options.cacheFile && !cacheFileInitialized) {
143 initCacheFile(options.cacheFile, log);
144 }
145
146 // Expose for testing.
147 less.middleware._saveCacheToFile = _saveCacheToFile;
148
149 // Actual middleware.
150 return function(req, res, next) {
151 if ('GET' != req.method.toUpperCase() && 'HEAD' != req.method.toUpperCase()) { return next(); }
152
153 var pathname = url.parse(req.url).pathname;
154
155 // Only handle the matching files in this middleware.
156 if (utilities.isValidPath(pathname)) {
157 var isSourceMap = utilities.isSourceMap(pathname);
158
159 // Translate source maps to a normal .css request which will update the associated source-map.
160 if( isSourceMap ){
161 pathname = pathname.replace( /\.map$/, '' );
162 }
163 var lessPath = path.join(source, utilities.maybeCompressedSource(pathname));
164 var cssPath = path.join(options.dest, pathname);
165
166 if (options.pathRoot) {
167 pathname = pathname.replace(options.dest, '');
168 cssPath = path.join(options.pathRoot, options.dest, pathname);
169 lessPath = path.join(options.pathRoot, source, utilities.maybeCompressedSource(pathname));
170 }
171
172 var sourcemapPath = cssPath + '.map';
173
174 // Allow for preprocessing the source filename.
175 lessPath = options.preprocess.path(lessPath, req);
176
177 log('pathname', pathname);
178 log('source', lessPath);
179 log('destination', cssPath);
180
181 // Ignore ENOENT to fall through as 404.
182 var error = function(err) {
183 return next('ENOENT' == err.code ? null : err);
184 };
185
186 var compile = function() {
187 fs.readFile(lessPath, 'utf8', function(err, lessSrc){
188 if (err) {
189 return error(err);
190 }
191
192 delete lessFiles[lessPath];
193
194 try {
195 var renderOptions = extend(true, {}, options.render, {
196 filename: lessPath,
197 paths: options.preprocess.importPaths(options.render.paths, req)
198 });
199 lessSrc = options.preprocess.less(lessSrc, req);
200
201 less.render(lessSrc, renderOptions, function(err, output){
202 if (err) {
203 utilities.lessError(err);
204 return next(err);
205 }
206
207 // Determine the imports used and check modified times.
208 var imports = [];
209 output.imports.forEach(function(imported) {
210 // Cannot check times of http(s) imports so ignore them.
211 if (imported.indexOf("://") >= 0) {
212 return;
213 }
214
215 var currentImport = {
216 path: imported,
217 mtime: null
218 };
219
220 imports.push(currentImport);
221
222 // Update the mtime of the import async.
223 fs.stat(imported, function(err, lessStats){
224 if (err) {
225 return error(err);
226 }
227
228 currentImport.mtime = lessStats.mtime;
229 });
230 });
231
232 // Store the less paths for simple cache invalidation.
233 lessFiles[lessPath] = {
234 mtime: Date.now(),
235 imports: imports
236 };
237
238 if(output.map) {
239 // Postprocessing on the sourcemap.
240 var map = options.postprocess.sourcemap(output.map, req);
241
242 // Custom sourcemap storage.
243 options.storeSourcemap(sourcemapPath, map, req);
244 }
245
246 // Postprocessing on the css.
247 var css = options.postprocess.css(output.css, req);
248
249 // Custom css storage.
250 options.storeCss(cssPath, css, req, next);
251 });
252 } catch (err) {
253 utilities.lessError(err);
254 return next(err);
255 }
256 });
257 };
258
259 // Force recompile of all files.
260 if (options.force) {
261 return compile();
262 }
263
264 // Only compile once, disregarding the file changes.
265 if (options.once && lessFiles[lessPath]) {
266 return next();
267 }
268
269 // Compile on (uncached) server restart and new files.
270 if (!lessFiles[lessPath]) {
271 return compile();
272 }
273
274 // Compare mtimes to determine if changed.
275 fs.stat(lessPath, function(err, lessStats){
276 if (err) {
277 return error(err);
278 }
279
280 fs.stat(cssPath, function(err, cssStats){
281 // CSS has not been compiled, compile it!
282 if (err) {
283 if ('ENOENT' == err.code) {
284 log('not found', cssPath);
285
286 // No CSS file found in dest
287 return compile();
288 }
289
290 return next(err);
291 }
292
293 if (lessStats.mtime > cssStats.mtime) {
294 // Source has changed, compile it
295 log('modified', cssPath);
296
297 return compile();
298 } else if (lessStats.mtime > lessFiles[lessPath].mtime) {
299 // This can happen if lessFiles[lessPath] was copied from
300 // cacheFile above, but the cache file was out of date (which
301 // can happen e.g. if node is killed and we were unable to write out
302 // lessFiles on exit). Since imports might have changed, we need to
303 // recompile.
304 log('cache file out of date for', lessPath);
305
306 return compile();
307 } else {
308 // Check if any of the less imports were changed
309 checkImports(lessPath, function(changed){
310 if(typeof changed != "undefined" && changed.length) {
311 log('modified import', changed);
312
313 return compile();
314 }
315
316 return next();
317 });
318 }
319 });
320 });
321 } else {
322 return next();
323 }
324 };
325};