Change from pnpm to npm, add ./link.sh shortcut for npm style package linking
[html-minifier.git] / benchmark.js
1 #!/usr/bin/env node
2
3 'use strict';
4
5 var packages = require('./package.json').benchmarkDependencies;
6 packages = Object.keys(packages).map(function(name) {
7   return name + '@' + packages[name];
8 });
9 packages.unshift('install', '--no-save', '--no-optional');
10 var installed = require('child_process').spawnSync('npm', packages, {
11   encoding: 'utf-8',
12   shell: true
13 });
14 if (installed.error) {
15   throw installed.error;
16 }
17 else if (installed.status) {
18   console.log(installed.stdout);
19   console.error(installed.stderr);
20   process.exit(installed.status);
21 }
22
23 var brotli = require('brotli'),
24     chalk = require('chalk'),
25     fork = require('child_process').fork,
26     fs = require('fs'),
27     https = require('https'),
28     lzma = require('lzma'),
29     Minimize = require('minimize'),
30     path = require('path'),
31     Progress = require('progress'),
32     querystring = require('querystring'),
33     Table = require('cli-table'),
34     url = require('url'),
35     zlib = require('zlib');
36
37 var urls = require('./benchmarks');
38 var fileNames = Object.keys(urls);
39
40 var minimize = new Minimize();
41
42 var progress = new Progress('[:bar] :etas :fileName', {
43   width: 50,
44   total: fileNames.length
45 });
46
47 var table = new Table({
48   head: ['File', 'Before', 'After', 'Minimize', 'Will Peavy', 'htmlcompressor.com', 'Savings', 'Time'],
49   colWidths: [fileNames.reduce(function(length, fileName) {
50     return Math.max(length, fileName.length);
51   }, 0) + 2, 25, 25, 25, 25, 25, 20, 10]
52 });
53
54 function toKb(size, precision) {
55   return (size / 1024).toFixed(precision || 0);
56 }
57
58 function redSize(size) {
59   return chalk.red.bold(size) + chalk.white(' (' + toKb(size, 2) + ' KB)');
60 }
61
62 function greenSize(size) {
63   return chalk.green.bold(size) + chalk.white(' (' + toKb(size, 2) + ' KB)');
64 }
65
66 function blueSavings(oldSize, newSize) {
67   var savingsPercent = (1 - newSize / oldSize) * 100;
68   var savings = oldSize - newSize;
69   return chalk.cyan.bold(savingsPercent.toFixed(2)) + chalk.white('% (' + toKb(savings, 2) + ' KB)');
70 }
71
72 function blueTime(time) {
73   return chalk.cyan.bold(time) + chalk.white(' ms');
74 }
75
76 function readBuffer(filePath, callback) {
77   fs.readFile(filePath, function(err, data) {
78     if (err) {
79       throw new Error('There was an error reading ' + filePath);
80     }
81     callback(data);
82   });
83 }
84
85 function readText(filePath, callback) {
86   fs.readFile(filePath, { encoding: 'utf8' }, function(err, data) {
87     if (err) {
88       throw new Error('There was an error reading ' + filePath);
89     }
90     callback(data);
91   });
92 }
93
94 function writeBuffer(filePath, data, callback) {
95   fs.writeFile(filePath, data, function(err) {
96     if (err) {
97       throw new Error('There was an error writing ' + filePath);
98     }
99     callback();
100   });
101 }
102
103 function writeText(filePath, data, callback) {
104   fs.writeFile(filePath, data, { encoding: 'utf8' }, function(err) {
105     if (err) {
106       throw new Error('There was an error writing ' + filePath);
107     }
108     if (callback) {
109       callback();
110     }
111   });
112 }
113
114 function readSize(filePath, callback) {
115   fs.stat(filePath, function(err, stats) {
116     if (err) {
117       throw new Error('There was an error reading ' + filePath);
118     }
119     callback(stats.size);
120   });
121 }
122
123 function gzip(inPath, outPath, callback) {
124   fs.createReadStream(inPath).pipe(zlib.createGzip({
125     level: zlib.Z_BEST_COMPRESSION
126   })).pipe(fs.createWriteStream(outPath)).on('finish', callback);
127 }
128
129 function run(tasks, done) {
130   var i = 0;
131
132   function callback() {
133     if (i < tasks.length) {
134       tasks[i++](callback);
135     }
136     else {
137       done();
138     }
139   }
140
141   callback();
142 }
143
144 var rows = {};
145
146 function generateMarkdownTable() {
147   var headers = [
148     'Site',
149     'Original size *(KB)*',
150     'HTMLMinifier',
151     'minimize',
152     'Will Peavy',
153     'htmlcompressor.com'
154   ];
155   fileNames.forEach(function(fileName) {
156     var row = rows[fileName].report;
157     row[2] = '**' + row[2] + '**';
158   });
159   var widths = headers.map(function(header, index) {
160     var width = header.length;
161     fileNames.forEach(function(fileName) {
162       width = Math.max(width, rows[fileName].report[index].length);
163     });
164     return width;
165   });
166   var content = '';
167
168   function output(row) {
169     widths.forEach(function(width, index) {
170       var text = row[index];
171       content += '| ' + text + new Array(width - text.length + 2).join(' ');
172     });
173     content += '|\n';
174   }
175
176   output(headers);
177   widths.forEach(function(width, index) {
178     content += '|';
179     content += index === 1 ? ':' : ' ';
180     content += new Array(width + 1).join('-');
181     content += index === 0 ? ' ' : ':';
182   });
183   content += '|\n';
184   fileNames.sort(function(a, b) {
185     var r = +rows[a].report[1];
186     var s = +rows[b].report[1];
187     return r < s ? -1 : r > s ? 1 : a < b ? -1 : a > b ? 1 : 0;
188   }).forEach(function(fileName) {
189     output(rows[fileName].report);
190   });
191   return content;
192 }
193
194 function displayTable() {
195   fileNames.forEach(function(fileName) {
196     table.push(rows[fileName].display);
197   });
198   console.log();
199   console.log(table.toString());
200 }
201
202 run(fileNames.map(function(fileName) {
203   var filePath = path.join('benchmarks/', fileName + '.html');
204
205   function processFile(site, done) {
206     var original = {
207       filePath: filePath,
208       gzFilePath: path.join('benchmarks/generated/', fileName + '.html.gz'),
209       lzFilePath: path.join('benchmarks/generated/', fileName + '.html.lz'),
210       brFilePath: path.join('benchmarks/generated/', fileName + '.html.br')
211     };
212     var infos = {};
213     ['minifier', 'minimize', 'willpeavy', 'compressor'].forEach(function(name) {
214       infos[name] = {
215         filePath: path.join('benchmarks/generated/', fileName + '.' + name + '.html'),
216         gzFilePath: path.join('benchmarks/generated/', fileName + '.' + name + '.html.gz'),
217         lzFilePath: path.join('benchmarks/generated/', fileName + '.' + name + '.html.lz'),
218         brFilePath: path.join('benchmarks/generated/', fileName + '.' + name + '.html.br')
219       };
220     });
221
222     function readSizes(info, done) {
223       info.endTime = Date.now();
224       run([
225         // Apply Gzip on minified output
226         function(done) {
227           gzip(info.filePath, info.gzFilePath, function() {
228             info.gzTime = Date.now();
229             // Open and read the size of the minified+gzip output
230             readSize(info.gzFilePath, function(size) {
231               info.gzSize = size;
232               done();
233             });
234           });
235         },
236         // Apply LZMA on minified output
237         function(done) {
238           readBuffer(info.filePath, function(data) {
239             lzma.compress(data, 1, function(result, error) {
240               if (error) {
241                 throw error;
242               }
243               writeBuffer(info.lzFilePath, new Buffer(result), function() {
244                 info.lzTime = Date.now();
245                 // Open and read the size of the minified+lzma output
246                 readSize(info.lzFilePath, function(size) {
247                   info.lzSize = size;
248                   done();
249                 });
250               });
251             });
252           });
253         },
254         // Apply Brotli on minified output
255         function(done) {
256           readBuffer(info.filePath, function(data) {
257             var output = new Buffer(brotli.compress(data, true).buffer);
258             writeBuffer(info.brFilePath, output, function() {
259               info.brTime = Date.now();
260               // Open and read the size of the minified+brotli output
261               readSize(info.brFilePath, function(size) {
262                 info.brSize = size;
263                 done();
264               });
265             });
266           });
267         },
268         // Open and read the size of the minified output
269         function(done) {
270           readSize(info.filePath, function(size) {
271             info.size = size;
272             done();
273           });
274         }
275       ], done);
276     }
277
278     function testHTMLMinifier(done) {
279       var info = infos.minifier;
280       info.startTime = Date.now();
281       var args = [filePath, '-c', 'sample-cli-config-file.conf', '--minify-urls', site, '-o', info.filePath];
282       fork('./cli', args).on('exit', function() {
283         readSizes(info, done);
284       });
285     }
286
287     function testMinimize(done) {
288       readBuffer(filePath, function(data) {
289         minimize.parse(data, function(error, data) {
290           var info = infos.minimize;
291           writeBuffer(info.filePath, data, function() {
292             readSizes(info, done);
293           });
294         });
295       });
296     }
297
298     function testWillPeavy(done) {
299       readText(filePath, function(data) {
300         var options = url.parse('https://www.willpeavy.com/minifier/');
301         options.method = 'POST';
302         options.headers = {
303           'Content-Type': 'application/x-www-form-urlencoded'
304         };
305         https.request(options, function(res) {
306           res.setEncoding('utf8');
307           var response = '';
308           res.on('data', function(chunk) {
309             response += chunk;
310           }).on('end', function() {
311             var info = infos.willpeavy;
312             if (res.statusCode === 200) {
313               // Extract result from <textarea/>
314               var start = response.indexOf('>', response.indexOf('<textarea'));
315               var end = response.lastIndexOf('</textarea>');
316               var result = response.slice(start + 1, end).replace(/<\\\//g, '</');
317               writeText(info.filePath, result, function() {
318                 readSizes(info, done);
319               });
320             }
321             // Site refused to process content
322             else {
323               info.size = 0;
324               info.gzSize = 0;
325               info.lzSize = 0;
326               info.brSize = 0;
327               done();
328             }
329           });
330         }).end(querystring.stringify({
331           html: data
332         }));
333       });
334     }
335
336     function testHTMLCompressor(done) {
337       readText(filePath, function(data) {
338         var options = url.parse('https://htmlcompressor.com/compress_ajax_v2.php');
339         options.method = 'POST';
340         options.headers = {
341           'Accept-Encoding': 'gzip',
342           'Content-Type': 'application/x-www-form-urlencoded'
343         };
344         var info = infos.compressor;
345
346         function failed() {
347           // Site refused to process content
348           if (info) {
349             info.size = 0;
350             info.gzSize = 0;
351             info.lzSize = 0;
352             info.brSize = 0;
353             info = null;
354             done();
355           }
356         }
357
358         https.request(options, function(res) {
359           if (res.headers['content-encoding'] === 'gzip') {
360             res = res.pipe(zlib.createGunzip());
361           }
362           res.setEncoding('utf8');
363           var response = '';
364           res.on('data', function(chunk) {
365             response += chunk;
366           }).on('end', function() {
367             try {
368               response = JSON.parse(response);
369             }
370             catch (e) {
371               response = {};
372             }
373             if (info && response.success) {
374               writeText(info.filePath, response.result, function() {
375                 readSizes(info, done);
376               });
377             }
378             // Site refused to process content
379             else {
380               failed();
381             }
382           });
383         }).on('error', failed).end(querystring.stringify({
384           code_type: 'html',
385           html_level: 3,
386           html_strip_quotes: 1,
387           minimize_style: 1,
388           minimize_events: 1,
389           minimize_js_href: 1,
390           minimize_css: 1,
391           minimize_js: 1,
392           html_optional_cdata: 1,
393           js_engine: 'yui',
394           js_fallback: 1,
395           code: data
396         }));
397       });
398     }
399
400     run([
401       function(done) {
402         readSizes(original, done);
403       },
404       testHTMLMinifier,
405       testMinimize,
406       testWillPeavy,
407       testHTMLCompressor
408     ], function() {
409       var display = [
410         [fileName, '+ gzip', '+ lzma', '+ brotli'].join('\n'),
411         [redSize(original.size), redSize(original.gzSize), redSize(original.lzSize), redSize(original.brSize)].join('\n')
412       ];
413       var report = [
414         '[' + fileName + '](' + urls[fileName] + ')',
415         toKb(original.size)
416       ];
417       for (var name in infos) {
418         var info = infos[name];
419         display.push([greenSize(info.size), greenSize(info.gzSize), greenSize(info.lzSize), greenSize(info.brSize)].join('\n'));
420         report.push(info.size ? toKb(info.size) : 'n/a');
421       }
422       display.push(
423         [
424           blueSavings(original.size, infos.minifier.size),
425           blueSavings(original.gzSize, infos.minifier.gzSize),
426           blueSavings(original.lzSize, infos.minifier.lzSize),
427           blueSavings(original.brSize, infos.minifier.brSize)
428         ].join('\n'),
429         [
430           blueTime(infos.minifier.endTime - infos.minifier.startTime),
431           blueTime(original.gzTime - original.endTime),
432           blueTime(original.lzTime - original.gzTime),
433           blueTime(original.brTime - original.lzTime)
434         ].join('\n')
435       );
436       rows[fileName] = {
437         display: display,
438         report: report
439       };
440       progress.tick({ fileName: '' });
441       done();
442     });
443   }
444
445   function get(site, callback) {
446     var options = url.parse(site);
447     https.get(options, function(res) {
448       var status = res.statusCode;
449       if (status === 200) {
450         if (res.headers['content-encoding'] === 'gzip') {
451           res = res.pipe(zlib.createGunzip());
452         }
453         res.pipe(fs.createWriteStream(filePath)).on('finish', function() {
454           callback(site);
455         });
456       }
457       else if (status >= 300 && status < 400 && res.headers.location) {
458         get(url.resolve(site, res.headers.location), callback);
459       }
460       else {
461         throw new Error('HTTP error ' + status + '\n' + site);
462       }
463     });
464   }
465
466   return function(done) {
467     progress.tick(0, { fileName: fileName });
468     get(urls[fileName], function(site) {
469       processFile(site, done);
470     });
471   };
472 }), function() {
473   displayTable();
474   var content = generateMarkdownTable();
475   var readme = './README.md';
476   readText(readme, function(data) {
477     var start = data.indexOf('## Minification comparison');
478     start = data.indexOf('|', start);
479     var end = data.indexOf('##', start);
480     end = data.lastIndexOf('|\n', end) + '|\n'.length;
481     data = data.slice(0, start) + content + data.slice(end);
482     writeText(readme, data);
483   });
484 });