preserve whitespace around custom fragments within `<pre>` (#885)
[html-minifier.git] / src / htmlminifier.js
1 'use strict';
2
3 var CleanCSS = require('clean-css');
4 var decode = require('he').decode;
5 var HTMLParser = require('./htmlparser').HTMLParser;
6 var RelateUrl = require('relateurl');
7 var TokenChain = require('./tokenchain');
8 var UglifyJS = require('uglify-js');
9 var utils = require('./utils');
10
11 function trimWhitespace(str) {
12   if (typeof str !== 'string') {
13     return str;
14   }
15   return str.replace(/^[ \n\r\t\f]+/, '').replace(/[ \n\r\t\f]+$/, '');
16 }
17
18 function collapseWhitespaceAll(str) {
19   // Non-breaking space is specifically handled inside the replacer function here:
20   return str && str.replace(/[ \n\r\t\f\xA0]+/g, function(spaces) {
21     return spaces === '\t' ? '\t' : spaces.replace(/(^|\xA0+)[^\xA0]+/g, '$1 ');
22   });
23 }
24
25 function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
26   var lineBreakBefore = '', lineBreakAfter = '';
27
28   if (options.preserveLineBreaks) {
29     str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function() {
30       lineBreakBefore = '\n';
31       return '';
32     }).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function() {
33       lineBreakAfter = '\n';
34       return '';
35     });
36   }
37
38   if (trimLeft) {
39     // Non-breaking space is specifically handled inside the replacer function here:
40     str = str.replace(/^[ \n\r\t\f\xA0]+/, function(spaces) {
41       var conservative = !lineBreakBefore && options.conservativeCollapse;
42       if (conservative && spaces === '\t') {
43         return '\t';
44       }
45       return spaces.replace(/^[^\xA0]+/, '').replace(/(\xA0+)[^\xA0]+/g, '$1 ') || (conservative ? ' ' : '');
46     });
47   }
48
49   if (trimRight) {
50     // Non-breaking space is specifically handled inside the replacer function here:
51     str = str.replace(/[ \n\r\t\f\xA0]+$/, function(spaces) {
52       var conservative = !lineBreakAfter && options.conservativeCollapse;
53       if (conservative && spaces === '\t') {
54         return '\t';
55       }
56       return spaces.replace(/[^\xA0]+(\xA0+)/g, ' $1').replace(/[^\xA0]+$/, '') || (conservative ? ' ' : '');
57     });
58   }
59
60   if (collapseAll) {
61     // strip non space whitespace then compress spaces to one
62     str = collapseWhitespaceAll(str);
63   }
64
65   return lineBreakBefore + str + lineBreakAfter;
66 }
67
68 var createMapFromString = utils.createMapFromString;
69 // non-empty tags that will maintain whitespace around them
70 var inlineTags = createMapFromString('a,abbr,acronym,b,bdi,bdo,big,button,cite,code,del,dfn,em,font,i,ins,kbd,label,mark,math,nobr,object,q,rt,rp,s,samp,select,small,span,strike,strong,sub,sup,svg,textarea,time,tt,u,var');
71 // non-empty tags that will maintain whitespace within them
72 var inlineTextTags = createMapFromString('a,abbr,acronym,b,big,del,em,font,i,ins,kbd,mark,nobr,s,samp,small,span,strike,strong,sub,sup,time,tt,u,var');
73 // self-closing tags that will maintain whitespace around them
74 var selfClosingInlineTags = createMapFromString('comment,img,input,wbr');
75
76 function collapseWhitespaceSmart(str, prevTag, nextTag, options) {
77   var trimLeft = prevTag && !selfClosingInlineTags(prevTag);
78   if (trimLeft && !options.collapseInlineTagWhitespace) {
79     trimLeft = prevTag.charAt(0) === '/' ? !inlineTags(prevTag.slice(1)) : !inlineTextTags(prevTag);
80   }
81   var trimRight = nextTag && !selfClosingInlineTags(nextTag);
82   if (trimRight && !options.collapseInlineTagWhitespace) {
83     trimRight = nextTag.charAt(0) === '/' ? !inlineTextTags(nextTag.slice(1)) : !inlineTags(nextTag);
84   }
85   return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
86 }
87
88 function isConditionalComment(text) {
89   return /^\[if\s[^\]]+]|\[endif]$/.test(text);
90 }
91
92 function isIgnoredComment(text, options) {
93   for (var i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
94     if (options.ignoreCustomComments[i].test(text)) {
95       return true;
96     }
97   }
98   return false;
99 }
100
101 function isEventAttribute(attrName, options) {
102   var patterns = options.customEventAttributes;
103   if (patterns) {
104     for (var i = patterns.length; i--;) {
105       if (patterns[i].test(attrName)) {
106         return true;
107       }
108     }
109     return false;
110   }
111   return /^on[a-z]{3,}$/.test(attrName);
112 }
113
114 function canRemoveAttributeQuotes(value) {
115   // http://mathiasbynens.be/notes/unquoted-attribute-values
116   return /^[^ \t\n\f\r"'`=<>]+$/.test(value);
117 }
118
119 function attributesInclude(attributes, attribute) {
120   for (var i = attributes.length; i--;) {
121     if (attributes[i].name.toLowerCase() === attribute) {
122       return true;
123     }
124   }
125   return false;
126 }
127
128 function isAttributeRedundant(tag, attrName, attrValue, attrs) {
129   attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
130
131   return (
132     tag === 'script' &&
133     attrName === 'language' &&
134     attrValue === 'javascript' ||
135
136     tag === 'form' &&
137     attrName === 'method' &&
138     attrValue === 'get' ||
139
140     tag === 'input' &&
141     attrName === 'type' &&
142     attrValue === 'text' ||
143
144     tag === 'script' &&
145     attrName === 'charset' &&
146     !attributesInclude(attrs, 'src') ||
147
148     tag === 'a' &&
149     attrName === 'name' &&
150     attributesInclude(attrs, 'id') ||
151
152     tag === 'area' &&
153     attrName === 'shape' &&
154     attrValue === 'rect'
155   );
156 }
157
158 // https://mathiasbynens.be/demo/javascript-mime-type
159 // https://developer.mozilla.org/en/docs/Web/HTML/Element/script#attr-type
160 var executableScriptsMimetypes = utils.createMap([
161   'text/javascript',
162   'text/ecmascript',
163   'text/jscript',
164   'application/javascript',
165   'application/x-javascript',
166   'application/ecmascript'
167 ]);
168
169 function isScriptTypeAttribute(attrValue) {
170   attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
171   return attrValue === '' || executableScriptsMimetypes(attrValue);
172 }
173
174 function isExecutableScript(tag, attrs) {
175   if (tag !== 'script') {
176     return false;
177   }
178   for (var i = 0, len = attrs.length; i < len; i++) {
179     var attrName = attrs[i].name.toLowerCase();
180     if (attrName === 'type') {
181       return isScriptTypeAttribute(attrs[i].value);
182     }
183   }
184   return true;
185 }
186
187 function isStyleLinkTypeAttribute(attrValue) {
188   attrValue = trimWhitespace(attrValue).toLowerCase();
189   return attrValue === '' || attrValue === 'text/css';
190 }
191
192 function isStyleSheet(tag, attrs) {
193   if (tag !== 'style') {
194     return false;
195   }
196   for (var i = 0, len = attrs.length; i < len; i++) {
197     var attrName = attrs[i].name.toLowerCase();
198     if (attrName === 'type') {
199       return isStyleLinkTypeAttribute(attrs[i].value);
200     }
201   }
202   return true;
203 }
204
205 var isSimpleBoolean = createMapFromString('allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,default,defaultchecked,defaultmuted,defaultselected,defer,disabled,enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,required,reversed,scoped,seamless,selected,sortable,truespeed,typemustmatch,visible');
206 var isBooleanValue = createMapFromString('true,false');
207
208 function isBooleanAttribute(attrName, attrValue) {
209   return isSimpleBoolean(attrName) || attrName === 'draggable' && !isBooleanValue(attrValue);
210 }
211
212 function isUriTypeAttribute(attrName, tag) {
213   return (
214     /^(?:a|area|link|base)$/.test(tag) && attrName === 'href' ||
215     tag === 'img' && /^(?:src|longdesc|usemap)$/.test(attrName) ||
216     tag === 'object' && /^(?:classid|codebase|data|usemap)$/.test(attrName) ||
217     tag === 'q' && attrName === 'cite' ||
218     tag === 'blockquote' && attrName === 'cite' ||
219     (tag === 'ins' || tag === 'del') && attrName === 'cite' ||
220     tag === 'form' && attrName === 'action' ||
221     tag === 'input' && (attrName === 'src' || attrName === 'usemap') ||
222     tag === 'head' && attrName === 'profile' ||
223     tag === 'script' && (attrName === 'src' || attrName === 'for')
224   );
225 }
226
227 function isNumberTypeAttribute(attrName, tag) {
228   return (
229     /^(?:a|area|object|button)$/.test(tag) && attrName === 'tabindex' ||
230     tag === 'input' && (attrName === 'maxlength' || attrName === 'tabindex') ||
231     tag === 'select' && (attrName === 'size' || attrName === 'tabindex') ||
232     tag === 'textarea' && /^(?:rows|cols|tabindex)$/.test(attrName) ||
233     tag === 'colgroup' && attrName === 'span' ||
234     tag === 'col' && attrName === 'span' ||
235     (tag === 'th' || tag === 'td') && (attrName === 'rowspan' || attrName === 'colspan')
236   );
237 }
238
239 function isLinkType(tag, attrs, value) {
240   if (tag !== 'link') {
241     return false;
242   }
243   for (var i = 0, len = attrs.length; i < len; i++) {
244     if (attrs[i].name === 'rel' && attrs[i].value === value) {
245       return true;
246     }
247   }
248 }
249
250 function isMediaQuery(tag, attrs, attrName) {
251   return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
252 }
253
254 var srcsetTags = createMapFromString('img,source');
255
256 function isSrcset(attrName, tag) {
257   return attrName === 'srcset' && srcsetTags(tag);
258 }
259
260 function cleanAttributeValue(tag, attrName, attrValue, options, attrs) {
261   if (attrValue && isEventAttribute(attrName, options)) {
262     attrValue = trimWhitespace(attrValue).replace(/^javascript:\s*/i, '');
263     return options.minifyJS(attrValue, true);
264   }
265   else if (attrName === 'class') {
266     attrValue = trimWhitespace(attrValue);
267     if (options.sortClassName) {
268       attrValue = options.sortClassName(attrValue);
269     }
270     else {
271       attrValue = collapseWhitespaceAll(attrValue);
272     }
273     return attrValue;
274   }
275   else if (isUriTypeAttribute(attrName, tag)) {
276     attrValue = trimWhitespace(attrValue);
277     return isLinkType(tag, attrs, 'canonical') ? attrValue : options.minifyURLs(attrValue);
278   }
279   else if (isNumberTypeAttribute(attrName, tag)) {
280     return trimWhitespace(attrValue);
281   }
282   else if (attrName === 'style') {
283     attrValue = trimWhitespace(attrValue);
284     if (attrValue) {
285       if (/;$/.test(attrValue) && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
286         attrValue = attrValue.replace(/\s*;$/, ';');
287       }
288       attrValue = unwrapInlineCSS(options.minifyCSS(wrapInlineCSS(attrValue)));
289     }
290     return attrValue;
291   }
292   else if (isSrcset(attrName, tag)) {
293     // https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-srcset
294     attrValue = trimWhitespace(attrValue).split(/\s+,\s*|\s*,\s+/).map(function(candidate) {
295       var url = candidate;
296       var descriptor = '';
297       var match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
298       if (match) {
299         url = url.slice(0, -match[0].length);
300         var num = +match[1].slice(0, -1);
301         var suffix = match[1].slice(-1);
302         if (num !== 1 || suffix !== 'x') {
303           descriptor = ' ' + num + suffix;
304         }
305       }
306       return options.minifyURLs(url) + descriptor;
307     }).join(', ');
308   }
309   else if (isMetaViewport(tag, attrs) && attrName === 'content') {
310     attrValue = attrValue.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, function(numString) {
311       // "0.90000" -> "0.9"
312       // "1.0" -> "1"
313       // "1.0001" -> "1.0001" (unchanged)
314       return (+numString).toString();
315     });
316   }
317   else if (attrValue && options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
318     attrValue = attrValue.replace(/\n+|\r+|\s{2,}/g, '');
319   }
320   else if (tag === 'script' && attrName === 'type') {
321     attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
322   }
323   else if (isMediaQuery(tag, attrs, attrName)) {
324     attrValue = trimWhitespace(attrValue);
325     return unwrapMediaQuery(options.minifyCSS(wrapMediaQuery(attrValue)));
326   }
327   return attrValue;
328 }
329
330 function isMetaViewport(tag, attrs) {
331   if (tag !== 'meta') {
332     return false;
333   }
334   for (var i = 0, len = attrs.length; i < len; i++) {
335     if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
336       return true;
337     }
338   }
339 }
340
341 // Wrap CSS declarations for CleanCSS > 3.x
342 // See https://github.com/jakubpawlowicz/clean-css/issues/418
343 function wrapInlineCSS(text) {
344   return '*{' + text + '}';
345 }
346
347 function unwrapInlineCSS(text) {
348   var matches = text.match(/^\*\{([\s\S]*)\}$/);
349   return matches ? matches[1] : text;
350 }
351
352 function wrapMediaQuery(text) {
353   return '@media ' + text + '{a{top:0}}';
354 }
355
356 function unwrapMediaQuery(text) {
357   var matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
358   return matches ? matches[1] : text;
359 }
360
361 function cleanConditionalComment(comment, options) {
362   return options.processConditionalComments ? comment.replace(/^(\[if\s[^\]]+]>)([\s\S]*?)(<!\[endif])$/, function(match, prefix, text, suffix) {
363     return prefix + minify(text, options, true) + suffix;
364   }) : comment;
365 }
366
367 function processScript(text, options, currentAttrs) {
368   for (var i = 0, len = currentAttrs.length; i < len; i++) {
369     if (currentAttrs[i].name.toLowerCase() === 'type' &&
370         options.processScripts.indexOf(currentAttrs[i].value) > -1) {
371       return minify(text, options);
372     }
373   }
374   return text;
375 }
376
377 // Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags
378 // with the following deviations:
379 // - retain <body> if followed by <noscript>
380 // - </rb>, </rt>, </rtc>, </rp> & </tfoot> follow http://www.w3.org/TR/html5/syntax.html#optional-tags
381 // - retain all tags which are adjacent to non-standard HTML tags
382 var optionalStartTags = createMapFromString('html,head,body,colgroup,tbody');
383 var optionalEndTags = createMapFromString('html,head,body,li,dt,dd,p,rb,rt,rtc,rp,optgroup,option,colgroup,caption,thead,tbody,tfoot,tr,td,th');
384 var headerTags = createMapFromString('meta,link,script,style,template,noscript');
385 var descriptionTags = createMapFromString('dt,dd');
386 var pBlockTags = createMapFromString('address,article,aside,blockquote,details,div,dl,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,hr,main,menu,nav,ol,p,pre,section,table,ul');
387 var pInlineTags = createMapFromString('a,audio,del,ins,map,noscript,video');
388 var rubyTags = createMapFromString('rb,rt,rtc,rp');
389 var rtcTag = createMapFromString('rb,rtc,rp');
390 var optionTag = createMapFromString('option,optgroup');
391 var tableContentTags = createMapFromString('tbody,tfoot');
392 var tableSectionTags = createMapFromString('thead,tbody,tfoot');
393 var cellTags = createMapFromString('td,th');
394 var topLevelTags = createMapFromString('html,head,body');
395 var compactTags = createMapFromString('html,body');
396 var looseTags = createMapFromString('head,colgroup,caption');
397 var trailingTags = createMapFromString('dt,thead');
398 var htmlTags = createMapFromString('a,abbr,acronym,address,applet,area,article,aside,audio,b,base,basefont,bdi,bdo,bgsound,big,blink,blockquote,body,br,button,canvas,caption,center,cite,code,col,colgroup,command,content,data,datalist,dd,del,details,dfn,dialog,dir,div,dl,dt,element,em,embed,fieldset,figcaption,figure,font,footer,form,frame,frameset,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,i,iframe,image,img,input,ins,isindex,kbd,keygen,label,legend,li,link,listing,main,map,mark,marquee,menu,menuitem,meta,meter,multicol,nav,nobr,noembed,noframes,noscript,object,ol,optgroup,option,output,p,param,picture,plaintext,pre,progress,q,rp,rt,rtc,ruby,s,samp,script,section,select,shadow,small,source,spacer,span,strike,strong,style,sub,summary,sup,table,tbody,td,template,textarea,tfoot,th,thead,time,title,tr,track,tt,u,ul,var,video,wbr,xmp');
399
400 function canRemoveParentTag(optionalStartTag, tag) {
401   switch (optionalStartTag) {
402     case 'html':
403     case 'head':
404       return true;
405     case 'body':
406       return !headerTags(tag);
407     case 'colgroup':
408       return tag === 'col';
409     case 'tbody':
410       return tag === 'tr';
411   }
412   return false;
413 }
414
415 function isStartTagMandatory(optionalEndTag, tag) {
416   switch (tag) {
417     case 'colgroup':
418       return optionalEndTag === 'colgroup';
419     case 'tbody':
420       return tableSectionTags(optionalEndTag);
421   }
422   return false;
423 }
424
425 function canRemovePrecedingTag(optionalEndTag, tag) {
426   switch (optionalEndTag) {
427     case 'html':
428     case 'head':
429     case 'body':
430     case 'colgroup':
431     case 'caption':
432       return true;
433     case 'li':
434     case 'optgroup':
435     case 'tr':
436       return tag === optionalEndTag;
437     case 'dt':
438     case 'dd':
439       return descriptionTags(tag);
440     case 'p':
441       return pBlockTags(tag);
442     case 'rb':
443     case 'rt':
444     case 'rp':
445       return rubyTags(tag);
446     case 'rtc':
447       return rtcTag(tag);
448     case 'option':
449       return optionTag(tag);
450     case 'thead':
451     case 'tbody':
452       return tableContentTags(tag);
453     case 'tfoot':
454       return tag === 'tbody';
455     case 'td':
456     case 'th':
457       return cellTags(tag);
458   }
459   return false;
460 }
461
462 var reEmptyAttribute = new RegExp(
463   '^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
464     '?:down|up|over|move|out)|key(?:press|down|up)))$');
465
466 function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
467   var isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
468   if (!isValueEmpty) {
469     return false;
470   }
471   if (typeof options.removeEmptyAttributes === 'function') {
472     return options.removeEmptyAttributes(attrName, tag);
473   }
474   return tag === 'input' && attrName === 'value' || reEmptyAttribute.test(attrName);
475 }
476
477 function hasAttrName(name, attrs) {
478   for (var i = attrs.length - 1; i >= 0; i--) {
479     if (attrs[i].name === name) {
480       return true;
481     }
482   }
483   return false;
484 }
485
486 function canRemoveElement(tag, attrs) {
487   switch (tag) {
488     case 'textarea':
489       return false;
490     case 'audio':
491     case 'script':
492     case 'video':
493       if (hasAttrName('src', attrs)) {
494         return false;
495       }
496       break;
497     case 'iframe':
498       if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
499         return false;
500       }
501       break;
502     case 'object':
503       if (hasAttrName('data', attrs)) {
504         return false;
505       }
506       break;
507     case 'applet':
508       if (hasAttrName('code', attrs)) {
509         return false;
510       }
511       break;
512   }
513   return true;
514 }
515
516 function canCollapseWhitespace(tag) {
517   return !/^(?:script|style|pre|textarea)$/.test(tag);
518 }
519
520 function canTrimWhitespace(tag) {
521   return !/^(?:pre|textarea)$/.test(tag);
522 }
523
524 function normalizeAttr(attr, attrs, tag, options) {
525   var attrName = options.caseSensitive ? attr.name : attr.name.toLowerCase(),
526       attrValue = attr.value;
527
528   if (options.decodeEntities && attrValue) {
529     attrValue = decode(attrValue, { isAttributeValue: true });
530   }
531
532   if (options.removeRedundantAttributes &&
533     isAttributeRedundant(tag, attrName, attrValue, attrs) ||
534     options.removeScriptTypeAttributes && tag === 'script' &&
535     attrName === 'type' && isScriptTypeAttribute(attrValue) ||
536     options.removeStyleLinkTypeAttributes && (tag === 'style' || tag === 'link') &&
537     attrName === 'type' && isStyleLinkTypeAttribute(attrValue)) {
538     return;
539   }
540
541   attrValue = cleanAttributeValue(tag, attrName, attrValue, options, attrs);
542
543   if (options.removeEmptyAttributes &&
544       canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
545     return;
546   }
547
548   if (options.decodeEntities && attrValue) {
549     attrValue = attrValue.replace(/&(#?[0-9a-zA-Z]+;)/g, '&amp;$1');
550   }
551
552   return {
553     attr: attr,
554     name: attrName,
555     value: attrValue
556   };
557 }
558
559 function buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr) {
560   var attrName = normalized.name,
561       attrValue = normalized.value,
562       attr = normalized.attr,
563       attrQuote = attr.quote,
564       attrFragment,
565       emittedAttrValue;
566
567   if (typeof attrValue !== 'undefined' && (!options.removeAttributeQuotes ||
568       ~attrValue.indexOf(uidAttr) || !canRemoveAttributeQuotes(attrValue))) {
569     if (!options.preventAttributesEscaping) {
570       if (typeof options.quoteCharacter === 'undefined') {
571         var apos = (attrValue.match(/'/g) || []).length;
572         var quot = (attrValue.match(/"/g) || []).length;
573         attrQuote = apos < quot ? '\'' : '"';
574       }
575       else {
576         attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
577       }
578       if (attrQuote === '"') {
579         attrValue = attrValue.replace(/"/g, '&#34;');
580       }
581       else {
582         attrValue = attrValue.replace(/'/g, '&#39;');
583       }
584     }
585     emittedAttrValue = attrQuote + attrValue + attrQuote;
586     if (!isLast && !options.removeTagWhitespace) {
587       emittedAttrValue += ' ';
588     }
589   }
590   // make sure trailing slash is not interpreted as HTML self-closing tag
591   else if (isLast && !hasUnarySlash && !/\/$/.test(attrValue)) {
592     emittedAttrValue = attrValue;
593   }
594   else {
595     emittedAttrValue = attrValue + ' ';
596   }
597
598   if (typeof attrValue === 'undefined' || options.collapseBooleanAttributes &&
599       isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase())) {
600     attrFragment = attrName;
601     if (!isLast) {
602       attrFragment += ' ';
603     }
604   }
605   else {
606     attrFragment = attrName + attr.customAssign + emittedAttrValue;
607   }
608
609   return attr.customOpen + attrFragment + attr.customClose;
610 }
611
612 function identity(value) {
613   return value;
614 }
615
616 function processOptions(options) {
617   ['html5', 'includeAutoGeneratedTags'].forEach(function(key) {
618     if (!(key in options)) {
619       options[key] = true;
620     }
621   });
622
623   if (typeof options.log !== 'function') {
624     options.log = identity;
625   }
626
627   if (!options.canCollapseWhitespace) {
628     options.canCollapseWhitespace = canCollapseWhitespace;
629   }
630   if (!options.canTrimWhitespace) {
631     options.canTrimWhitespace = canTrimWhitespace;
632   }
633
634   if (!('ignoreCustomComments' in options)) {
635     options.ignoreCustomComments = [/^!/];
636   }
637
638   if (!('ignoreCustomFragments' in options)) {
639     options.ignoreCustomFragments = [
640       /<%[\s\S]*?%>/,
641       /<\?[\s\S]*?\?>/
642     ];
643   }
644
645   if (!options.minifyURLs) {
646     options.minifyURLs = identity;
647   }
648   if (typeof options.minifyURLs !== 'function') {
649     var minifyURLs = options.minifyURLs;
650     if (typeof minifyURLs === 'string') {
651       minifyURLs = { site: minifyURLs };
652     }
653     else if (typeof minifyURLs !== 'object') {
654       minifyURLs = {};
655     }
656     options.minifyURLs = function(text) {
657       try {
658         return RelateUrl.relate(text, minifyURLs);
659       }
660       catch (err) {
661         options.log(err);
662         return text;
663       }
664     };
665   }
666
667   if (!options.minifyJS) {
668     options.minifyJS = identity;
669   }
670   if (typeof options.minifyJS !== 'function') {
671     var minifyJS = options.minifyJS;
672     if (typeof minifyJS !== 'object') {
673       minifyJS = {};
674     }
675     (minifyJS.parse || (minifyJS.parse = {})).bare_returns = false;
676     options.minifyJS = function(text, inline) {
677       var start = text.match(/^\s*<!--.*/);
678       var code = start ? text.slice(start[0].length).replace(/\n\s*-->\s*$/, '') : text;
679       minifyJS.parse.bare_returns = inline;
680       var result = UglifyJS.minify(code, minifyJS);
681       if (result.error) {
682         options.log(result.error);
683         return text;
684       }
685       return result.code.replace(/;$/, '');
686     };
687   }
688
689   if (!options.minifyCSS) {
690     options.minifyCSS = identity;
691   }
692   if (typeof options.minifyCSS !== 'function') {
693     var minifyCSS = options.minifyCSS;
694     if (typeof minifyCSS !== 'object') {
695       minifyCSS = {};
696     }
697     options.minifyCSS = function(text) {
698       text = text.replace(/(url\s*\(\s*)("|'|)(.*?)\2(\s*\))/ig, function(match, prefix, quote, url, suffix) {
699         return prefix + quote + options.minifyURLs(url) + quote + suffix;
700       });
701       try {
702         return new CleanCSS(minifyCSS).minify(text).styles;
703       }
704       catch (err) {
705         options.log(err);
706         return text;
707       }
708     };
709   }
710 }
711
712 function uniqueId(value) {
713   var id;
714   do {
715     id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
716   } while (~value.indexOf(id));
717   return id;
718 }
719
720 var specialContentTags = createMapFromString('script,style');
721
722 function createSortFns(value, options, uidIgnore, uidAttr) {
723   var attrChains = options.sortAttributes && Object.create(null);
724   var classChain = options.sortClassName && new TokenChain();
725
726   function attrNames(attrs) {
727     return attrs.map(function(attr) {
728       return options.caseSensitive ? attr.name : attr.name.toLowerCase();
729     });
730   }
731
732   function shouldSkipUID(token, uid) {
733     return !uid || token.indexOf(uid) === -1;
734   }
735
736   function shouldSkipUIDs(token) {
737     return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
738   }
739
740   function scan(input) {
741     var currentTag, currentType;
742     new HTMLParser(input, {
743       start: function(tag, attrs) {
744         if (attrChains) {
745           if (!attrChains[tag]) {
746             attrChains[tag] = new TokenChain();
747           }
748           attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
749         }
750         for (var i = 0, len = attrs.length; i < len; i++) {
751           var attr = attrs[i];
752           if (classChain && (options.caseSensitive ? attr.name : attr.name.toLowerCase()) === 'class') {
753             classChain.add(trimWhitespace(attr.value).split(/[ \t\n\f\r]+/).filter(shouldSkipUIDs));
754           }
755           else if (options.processScripts && attr.name.toLowerCase() === 'type') {
756             currentTag = tag;
757             currentType = attr.value;
758           }
759         }
760       },
761       end: function() {
762         currentTag = '';
763       },
764       chars: function(text) {
765         if (options.processScripts && specialContentTags(currentTag) &&
766             options.processScripts.indexOf(currentType) > -1) {
767           scan(text);
768         }
769       }
770     });
771   }
772
773   var log = options.log;
774   options.log = null;
775   options.sortAttributes = false;
776   options.sortClassName = false;
777   scan(minify(value, options));
778   options.log = log;
779   if (attrChains) {
780     var attrSorters = Object.create(null);
781     for (var tag in attrChains) {
782       attrSorters[tag] = attrChains[tag].createSorter();
783     }
784     options.sortAttributes = function(tag, attrs) {
785       var sorter = attrSorters[tag];
786       if (sorter) {
787         var attrMap = Object.create(null);
788         var names = attrNames(attrs);
789         names.forEach(function(name, index) {
790           (attrMap[name] || (attrMap[name] = [])).push(attrs[index]);
791         });
792         sorter.sort(names).forEach(function(name, index) {
793           attrs[index] = attrMap[name].shift();
794         });
795       }
796     };
797   }
798   if (classChain) {
799     var sorter = classChain.createSorter();
800     options.sortClassName = function(value) {
801       return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
802     };
803   }
804 }
805
806 function minify(value, options, partialMarkup) {
807   options = options || {};
808   var optionsStack = [];
809   processOptions(options);
810   if (options.collapseWhitespace) {
811     value = collapseWhitespace(value, options, true, true);
812   }
813
814   var buffer = [],
815       charsPrevTag,
816       currentChars = '',
817       hasChars,
818       currentTag = '',
819       currentAttrs = [],
820       stackNoTrimWhitespace = [],
821       stackNoCollapseWhitespace = [],
822       optionalStartTag = '',
823       optionalEndTag = '',
824       t = Date.now(),
825       ignoredMarkupChunks = [],
826       ignoredCustomMarkupChunks = [],
827       uidIgnore,
828       uidAttr,
829       uidPattern;
830
831   // temporarily replace ignored chunks with comments,
832   // so that we don't have to worry what's there.
833   // for all we care there might be
834   // completely-horribly-broken-alien-non-html-emoj-cthulhu-filled content
835   value = value.replace(/<!-- htmlmin:ignore -->([\s\S]*?)<!-- htmlmin:ignore -->/g, function(match, group1) {
836     if (!uidIgnore) {
837       uidIgnore = uniqueId(value);
838       var pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
839       if (options.ignoreCustomComments) {
840         options.ignoreCustomComments.push(pattern);
841       }
842       else {
843         options.ignoreCustomComments = [pattern];
844       }
845     }
846     var token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
847     ignoredMarkupChunks.push(group1);
848     return token;
849   });
850
851   function escapeFragments(text) {
852     return text.replace(uidPattern, function(match, prefix, index) {
853       var chunks = ignoredCustomMarkupChunks[+index];
854       return chunks[1] + uidAttr + index + chunks[2];
855     });
856   }
857
858   var customFragments = options.ignoreCustomFragments.map(function(re) {
859     return re.source;
860   });
861   if (customFragments.length) {
862     var reCustomIgnore = new RegExp('\\s*(?:' + customFragments.join('|') + ')+\\s*', 'g');
863     // temporarily replace custom ignored fragments with unique attributes
864     value = value.replace(reCustomIgnore, function(match) {
865       if (!uidAttr) {
866         uidAttr = uniqueId(value);
867         uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)(\\s*)', 'g');
868         var minifyCSS = options.minifyCSS;
869         if (minifyCSS) {
870           options.minifyCSS = function(text) {
871             return minifyCSS(escapeFragments(text));
872           };
873         }
874         var minifyJS = options.minifyJS;
875         if (minifyJS) {
876           options.minifyJS = function(text, inline) {
877             return minifyJS(escapeFragments(text), inline);
878           };
879         }
880       }
881       var token = uidAttr + ignoredCustomMarkupChunks.length;
882       ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
883       return '\t' + token + '\t';
884     });
885   }
886
887   if (options.sortAttributes && typeof options.sortAttributes !== 'function' ||
888       options.sortClassName && typeof options.sortClassName !== 'function') {
889     createSortFns(value, options, uidIgnore, uidAttr);
890   }
891
892   function _canCollapseWhitespace(tag, attrs) {
893     return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
894   }
895
896   function _canTrimWhitespace(tag, attrs) {
897     return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
898   }
899
900   function removeStartTag() {
901     var index = buffer.length - 1;
902     while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
903       index--;
904     }
905     buffer.length = Math.max(0, index);
906   }
907
908   function removeEndTag() {
909     var index = buffer.length - 1;
910     while (index > 0 && !/^<\//.test(buffer[index])) {
911       index--;
912     }
913     buffer.length = Math.max(0, index);
914   }
915
916   // look for trailing whitespaces, bypass any inline tags
917   function trimTrailingWhitespace(index, nextTag) {
918     for (var endTag = null; index >= 0 && _canTrimWhitespace(endTag); index--) {
919       var str = buffer[index];
920       var match = str.match(/^<\/([\w:-]+)>$/);
921       if (match) {
922         endTag = match[1];
923       }
924       else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options))) {
925         break;
926       }
927     }
928   }
929
930   // look for trailing whitespaces from previously processed text
931   // which may not be trimmed due to a following comment or an empty
932   // element which has now been removed
933   function squashTrailingWhitespace(nextTag) {
934     var charsIndex = buffer.length - 1;
935     if (buffer.length > 1) {
936       var item = buffer[buffer.length - 1];
937       if (/^(?:<!|$)/.test(item) && item.indexOf(uidIgnore) === -1) {
938         charsIndex--;
939       }
940     }
941     trimTrailingWhitespace(charsIndex, nextTag);
942   }
943
944   new HTMLParser(value, {
945     partialMarkup: partialMarkup,
946     html5: options.html5,
947
948     start: function(tag, attrs, unary, unarySlash, autoGenerated) {
949       var lowerTag = tag.toLowerCase();
950
951       if (lowerTag === 'svg') {
952         optionsStack.push(options);
953         var nextOptions = {};
954         for (var key in options) {
955           nextOptions[key] = options[key];
956         }
957         nextOptions.keepClosingSlash = true;
958         nextOptions.caseSensitive = true;
959         options = nextOptions;
960       }
961
962       tag = options.caseSensitive ? tag : lowerTag;
963
964       currentTag = tag;
965       charsPrevTag = tag;
966       if (!inlineTextTags(tag)) {
967         currentChars = '';
968       }
969       hasChars = false;
970       currentAttrs = attrs;
971
972       var optional = options.removeOptionalTags;
973       if (optional) {
974         var htmlTag = htmlTags(tag);
975         // <html> may be omitted if first thing inside is not comment
976         // <head> may be omitted if first thing inside is an element
977         // <body> may be omitted if first thing inside is not space, comment, <meta>, <link>, <script>, <style> or <template>
978         // <colgroup> may be omitted if first thing inside is <col>
979         // <tbody> may be omitted if first thing inside is <tr>
980         if (htmlTag && canRemoveParentTag(optionalStartTag, tag)) {
981           removeStartTag();
982         }
983         optionalStartTag = '';
984         // end-tag-followed-by-start-tag omission rules
985         if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag)) {
986           removeEndTag();
987           // <colgroup> cannot be omitted if preceding </colgroup> is omitted
988           // <tbody> cannot be omitted if preceding </tbody>, </thead> or </tfoot> is omitted
989           optional = !isStartTagMandatory(optionalEndTag, tag);
990         }
991         optionalEndTag = '';
992       }
993
994       // set whitespace flags for nested tags (eg. <code> within a <pre>)
995       if (options.collapseWhitespace) {
996         if (!stackNoTrimWhitespace.length) {
997           squashTrailingWhitespace(tag);
998         }
999         if (!unary) {
1000           if (!_canTrimWhitespace(tag, attrs) || stackNoTrimWhitespace.length) {
1001             stackNoTrimWhitespace.push(tag);
1002           }
1003           if (!_canCollapseWhitespace(tag, attrs) || stackNoCollapseWhitespace.length) {
1004             stackNoCollapseWhitespace.push(tag);
1005           }
1006         }
1007       }
1008
1009       var openTag = '<' + tag;
1010       var hasUnarySlash = unarySlash && options.keepClosingSlash;
1011
1012       buffer.push(openTag);
1013
1014       if (options.sortAttributes) {
1015         options.sortAttributes(tag, attrs);
1016       }
1017
1018       var parts = [];
1019       for (var i = attrs.length, isLast = true; --i >= 0;) {
1020         var normalized = normalizeAttr(attrs[i], attrs, tag, options);
1021         if (normalized) {
1022           parts.unshift(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
1023           isLast = false;
1024         }
1025       }
1026       if (parts.length > 0) {
1027         buffer.push(' ');
1028         buffer.push.apply(buffer, parts);
1029       }
1030       // start tag must never be omitted if it has any attributes
1031       else if (optional && optionalStartTags(tag)) {
1032         optionalStartTag = tag;
1033       }
1034
1035       buffer.push(buffer.pop() + (hasUnarySlash ? '/' : '') + '>');
1036
1037       if (autoGenerated && !options.includeAutoGeneratedTags) {
1038         removeStartTag();
1039         optionalStartTag = '';
1040       }
1041     },
1042     end: function(tag, attrs, autoGenerated) {
1043       var lowerTag = tag.toLowerCase();
1044       if (lowerTag === 'svg') {
1045         options = optionsStack.pop();
1046       }
1047       tag = options.caseSensitive ? tag : lowerTag;
1048
1049       // check if current tag is in a whitespace stack
1050       if (options.collapseWhitespace) {
1051         if (stackNoTrimWhitespace.length) {
1052           if (tag === stackNoTrimWhitespace[stackNoTrimWhitespace.length - 1]) {
1053             stackNoTrimWhitespace.pop();
1054           }
1055         }
1056         else {
1057           squashTrailingWhitespace('/' + tag);
1058         }
1059         if (stackNoCollapseWhitespace.length &&
1060           tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
1061           stackNoCollapseWhitespace.pop();
1062         }
1063       }
1064
1065       var isElementEmpty = false;
1066       if (tag === currentTag) {
1067         currentTag = '';
1068         isElementEmpty = !hasChars;
1069       }
1070
1071       if (options.removeOptionalTags) {
1072         // <html>, <head> or <body> may be omitted if the element is empty
1073         if (isElementEmpty && topLevelTags(optionalStartTag)) {
1074           removeStartTag();
1075         }
1076         optionalStartTag = '';
1077         // </html> or </body> may be omitted if not followed by comment
1078         // </head> may be omitted if not followed by space or comment
1079         // </p> may be omitted if no more content in non-</a> parent
1080         // except for </dt> or </thead>, end tags may be omitted if no more content in parent element
1081         if (htmlTags(tag) && optionalEndTag && !trailingTags(optionalEndTag) && (optionalEndTag !== 'p' || !pInlineTags(tag))) {
1082           removeEndTag();
1083         }
1084         optionalEndTag = optionalEndTags(tag) ? tag : '';
1085       }
1086
1087       if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
1088         // remove last "element" from buffer
1089         removeStartTag();
1090         optionalStartTag = '';
1091         optionalEndTag = '';
1092       }
1093       else {
1094         if (autoGenerated && !options.includeAutoGeneratedTags) {
1095           optionalEndTag = '';
1096         }
1097         else {
1098           buffer.push('</' + tag + '>');
1099         }
1100         charsPrevTag = '/' + tag;
1101         if (!inlineTags(tag)) {
1102           currentChars = '';
1103         }
1104         else if (isElementEmpty) {
1105           currentChars += '|';
1106         }
1107       }
1108     },
1109     chars: function(text, prevTag, nextTag) {
1110       prevTag = prevTag === '' ? 'comment' : prevTag;
1111       nextTag = nextTag === '' ? 'comment' : nextTag;
1112       if (options.decodeEntities && text && !specialContentTags(currentTag)) {
1113         text = decode(text);
1114       }
1115       if (options.collapseWhitespace) {
1116         if (!stackNoTrimWhitespace.length) {
1117           if (prevTag === 'comment') {
1118             var prevComment = buffer[buffer.length - 1];
1119             if (prevComment.indexOf(uidIgnore) === -1) {
1120               if (!prevComment) {
1121                 prevTag = charsPrevTag;
1122               }
1123               if (buffer.length > 1 && (!prevComment || !options.conservativeCollapse && / $/.test(currentChars))) {
1124                 var charsIndex = buffer.length - 2;
1125                 buffer[charsIndex] = buffer[charsIndex].replace(/\s+$/, function(trailingSpaces) {
1126                   text = trailingSpaces + text;
1127                   return '';
1128                 });
1129               }
1130             }
1131           }
1132           if (prevTag) {
1133             if (prevTag === '/nobr' || prevTag === 'wbr') {
1134               if (/^\s/.test(text)) {
1135                 var tagIndex = buffer.length - 1;
1136                 while (tagIndex > 0 && buffer[tagIndex].lastIndexOf('<' + prevTag) !== 0) {
1137                   tagIndex--;
1138                 }
1139                 trimTrailingWhitespace(tagIndex - 1, 'br');
1140               }
1141             }
1142             else if (inlineTextTags(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
1143               text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
1144             }
1145           }
1146           if (prevTag || nextTag) {
1147             text = collapseWhitespaceSmart(text, prevTag, nextTag, options);
1148           }
1149           else {
1150             text = collapseWhitespace(text, options, true, true);
1151           }
1152           if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
1153             trimTrailingWhitespace(buffer.length - 1, nextTag);
1154           }
1155         }
1156         else if (uidPattern) {
1157           text = text.replace(uidPattern, function(match, prefix, index) {
1158             return ignoredCustomMarkupChunks[+index][0];
1159           });
1160         }
1161         if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
1162           text = collapseWhitespace(text, options, false, false, true);
1163         }
1164       }
1165       if (options.processScripts && specialContentTags(currentTag)) {
1166         text = processScript(text, options, currentAttrs);
1167       }
1168       if (isExecutableScript(currentTag, currentAttrs)) {
1169         text = options.minifyJS(text);
1170       }
1171       if (isStyleSheet(currentTag, currentAttrs)) {
1172         text = options.minifyCSS(text);
1173       }
1174       if (options.removeOptionalTags && text) {
1175         // <html> may be omitted if first thing inside is not comment
1176         // <body> may be omitted if first thing inside is not space, comment, <meta>, <link>, <script>, <style> or <template>
1177         if (optionalStartTag === 'html' || optionalStartTag === 'body' && !/^\s/.test(text)) {
1178           removeStartTag();
1179         }
1180         optionalStartTag = '';
1181         // </html> or </body> may be omitted if not followed by comment
1182         // </head>, </colgroup> or </caption> may be omitted if not followed by space or comment
1183         if (compactTags(optionalEndTag) || looseTags(optionalEndTag) && !/^\s/.test(text)) {
1184           removeEndTag();
1185         }
1186         optionalEndTag = '';
1187       }
1188       charsPrevTag = /^\s*$/.test(text) ? prevTag : 'comment';
1189       if (options.decodeEntities && text && !specialContentTags(currentTag)) {
1190         // semi-colon can be omitted
1191         // https://mathiasbynens.be/notes/ambiguous-ampersands
1192         text = text.replace(/&(#?[0-9a-zA-Z]+;)/g, '&amp$1').replace(/</g, '&lt;');
1193       }
1194       currentChars += text;
1195       if (text) {
1196         hasChars = true;
1197       }
1198       buffer.push(text);
1199     },
1200     comment: function(text, nonStandard) {
1201       var prefix = nonStandard ? '<!' : '<!--';
1202       var suffix = nonStandard ? '>' : '-->';
1203       if (isConditionalComment(text)) {
1204         text = prefix + cleanConditionalComment(text, options) + suffix;
1205       }
1206       else if (options.removeComments) {
1207         if (isIgnoredComment(text, options)) {
1208           text = '<!--' + text + '-->';
1209         }
1210         else {
1211           text = '';
1212         }
1213       }
1214       else {
1215         text = prefix + text + suffix;
1216       }
1217       if (options.removeOptionalTags && text) {
1218         // preceding comments suppress tag omissions
1219         optionalStartTag = '';
1220         optionalEndTag = '';
1221       }
1222       buffer.push(text);
1223     },
1224     doctype: function(doctype) {
1225       buffer.push(options.useShortDoctype ? '<!DOCTYPE html>' : collapseWhitespaceAll(doctype));
1226     },
1227     customAttrAssign: options.customAttrAssign,
1228     customAttrSurround: options.customAttrSurround
1229   });
1230
1231   if (options.removeOptionalTags) {
1232     // <html> may be omitted if first thing inside is not comment
1233     // <head> or <body> may be omitted if empty
1234     if (topLevelTags(optionalStartTag)) {
1235       removeStartTag();
1236     }
1237     // except for </dt> or </thead>, end tags may be omitted if no more content in parent element
1238     if (optionalEndTag && !trailingTags(optionalEndTag)) {
1239       removeEndTag();
1240     }
1241   }
1242   if (options.collapseWhitespace) {
1243     squashTrailingWhitespace('br');
1244   }
1245
1246   var str = joinResultSegments(buffer, options);
1247
1248   if (uidPattern) {
1249     str = str.replace(uidPattern, function(match, prefix, index, suffix) {
1250       var chunk = ignoredCustomMarkupChunks[+index][0];
1251       if (options.collapseWhitespace) {
1252         if (prefix !== '\t') {
1253           chunk = prefix + chunk;
1254         }
1255         if (suffix !== '\t') {
1256           chunk += suffix;
1257         }
1258         return collapseWhitespace(chunk, {
1259           preserveLineBreaks: options.preserveLineBreaks,
1260           conservativeCollapse: !options.trimCustomFragments
1261         }, /^[ \n\r\t\f]/.test(chunk), /[ \n\r\t\f]$/.test(chunk));
1262       }
1263       return chunk;
1264     });
1265   }
1266   if (uidIgnore) {
1267     str = str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function(match, index) {
1268       return ignoredMarkupChunks[+index];
1269     });
1270   }
1271
1272   options.log('minified in: ' + (Date.now() - t) + 'ms');
1273   return str;
1274 }
1275
1276 function joinResultSegments(results, options) {
1277   var str;
1278   var maxLineLength = options.maxLineLength;
1279   if (maxLineLength) {
1280     var token;
1281     var lines = [];
1282     var line = '';
1283     for (var i = 0, len = results.length; i < len; i++) {
1284       token = results[i];
1285       if (line.length + token.length < maxLineLength) {
1286         line += token;
1287       }
1288       else {
1289         lines.push(line.replace(/^\n/, ''));
1290         line = token;
1291       }
1292     }
1293     lines.push(line);
1294
1295     str = lines.join('\n');
1296   }
1297   else {
1298     str = results.join('');
1299   }
1300   return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
1301 }
1302
1303 exports.minify = function(value, options) {
1304   return minify(value, options);
1305 };