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');
11 function trimWhitespace(str) {
12 if (typeof str !== 'string') {
15 return str.replace(/^[ \n\r\t\f]+/, '').replace(/[ \n\r\t\f]+$/, '');
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 ');
25 function collapseWhitespace(str, options, trimLeft, trimRight, collapseAll) {
26 var lineBreakBefore = '', lineBreakAfter = '';
28 if (options.preserveLineBreaks) {
29 str = str.replace(/^[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*/, function() {
30 lineBreakBefore = '\n';
32 }).replace(/[ \n\r\t\f]*?[\n\r][ \n\r\t\f]*$/, function() {
33 lineBreakAfter = '\n';
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') {
45 return spaces.replace(/^[^\xA0]+/, '').replace(/(\xA0+)[^\xA0]+/g, '$1 ') || (conservative ? ' ' : '');
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') {
56 return spaces.replace(/[^\xA0]+(\xA0+)/g, ' $1').replace(/[^\xA0]+$/, '') || (conservative ? ' ' : '');
61 // strip non space whitespace then compress spaces to one
62 str = collapseWhitespaceAll(str);
65 return lineBreakBefore + str + lineBreakAfter;
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');
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);
81 var trimRight = nextTag && !selfClosingInlineTags(nextTag);
82 if (trimRight && !options.collapseInlineTagWhitespace) {
83 trimRight = nextTag.charAt(0) === '/' ? !inlineTextTags(nextTag.slice(1)) : !inlineTags(nextTag);
85 return collapseWhitespace(str, options, trimLeft, trimRight, prevTag && nextTag);
88 function isConditionalComment(text) {
89 return /^\[if\s[^\]]+]|\[endif]$/.test(text);
92 function isIgnoredComment(text, options) {
93 for (var i = 0, len = options.ignoreCustomComments.length; i < len; i++) {
94 if (options.ignoreCustomComments[i].test(text)) {
101 function isEventAttribute(attrName, options) {
102 var patterns = options.customEventAttributes;
104 for (var i = patterns.length; i--;) {
105 if (patterns[i].test(attrName)) {
111 return /^on[a-z]{3,}$/.test(attrName);
114 function canRemoveAttributeQuotes(value) {
115 // http://mathiasbynens.be/notes/unquoted-attribute-values
116 return /^[^ \t\n\f\r"'`=<>]+$/.test(value);
119 function attributesInclude(attributes, attribute) {
120 for (var i = attributes.length; i--;) {
121 if (attributes[i].name.toLowerCase() === attribute) {
128 function isAttributeRedundant(tag, attrName, attrValue, attrs) {
129 attrValue = attrValue ? trimWhitespace(attrValue.toLowerCase()) : '';
133 attrName === 'language' &&
134 attrValue === 'javascript' ||
137 attrName === 'method' &&
138 attrValue === 'get' ||
141 attrName === 'type' &&
142 attrValue === 'text' ||
145 attrName === 'charset' &&
146 !attributesInclude(attrs, 'src') ||
149 attrName === 'name' &&
150 attributesInclude(attrs, 'id') ||
153 attrName === 'shape' &&
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([
164 'application/javascript',
165 'application/x-javascript',
166 'application/ecmascript'
169 function isScriptTypeAttribute(attrValue) {
170 attrValue = trimWhitespace(attrValue.split(/;/, 2)[0]).toLowerCase();
171 return attrValue === '' || executableScriptsMimetypes(attrValue);
174 function isExecutableScript(tag, attrs) {
175 if (tag !== 'script') {
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);
187 function isStyleLinkTypeAttribute(attrValue) {
188 attrValue = trimWhitespace(attrValue).toLowerCase();
189 return attrValue === '' || attrValue === 'text/css';
192 function isStyleSheet(tag, attrs) {
193 if (tag !== 'style') {
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);
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');
208 function isBooleanAttribute(attrName, attrValue) {
209 return isSimpleBoolean(attrName) || attrName === 'draggable' && !isBooleanValue(attrValue);
212 function isUriTypeAttribute(attrName, tag) {
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')
227 function isNumberTypeAttribute(attrName, tag) {
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')
239 function isLinkType(tag, attrs, value) {
240 if (tag !== 'link') {
243 for (var i = 0, len = attrs.length; i < len; i++) {
244 if (attrs[i].name === 'rel' && attrs[i].value === value) {
250 function isMediaQuery(tag, attrs, attrName) {
251 return attrName === 'media' && (isLinkType(tag, attrs, 'stylesheet') || isStyleSheet(tag, attrs));
254 var srcsetTags = createMapFromString('img,source');
256 function isSrcset(attrName, tag) {
257 return attrName === 'srcset' && srcsetTags(tag);
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);
265 else if (attrName === 'class') {
266 attrValue = trimWhitespace(attrValue);
267 if (options.sortClassName) {
268 attrValue = options.sortClassName(attrValue);
271 attrValue = collapseWhitespaceAll(attrValue);
275 else if (isUriTypeAttribute(attrName, tag)) {
276 attrValue = trimWhitespace(attrValue);
277 return isLinkType(tag, attrs, 'canonical') ? attrValue : options.minifyURLs(attrValue);
279 else if (isNumberTypeAttribute(attrName, tag)) {
280 return trimWhitespace(attrValue);
282 else if (attrName === 'style') {
283 attrValue = trimWhitespace(attrValue);
285 if (/;$/.test(attrValue) && !/&#?[0-9a-zA-Z]+;$/.test(attrValue)) {
286 attrValue = attrValue.replace(/\s*;$/, ';');
288 attrValue = unwrapInlineCSS(options.minifyCSS(wrapInlineCSS(attrValue)));
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) {
297 var match = candidate.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
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;
306 return options.minifyURLs(url) + descriptor;
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"
313 // "1.0001" -> "1.0001" (unchanged)
314 return (+numString).toString();
317 else if (attrValue && options.customAttrCollapse && options.customAttrCollapse.test(attrName)) {
318 attrValue = attrValue.replace(/\n+|\r+|\s{2,}/g, '');
320 else if (tag === 'script' && attrName === 'type') {
321 attrValue = trimWhitespace(attrValue.replace(/\s*;\s*/g, ';'));
323 else if (isMediaQuery(tag, attrs, attrName)) {
324 attrValue = trimWhitespace(attrValue);
325 return unwrapMediaQuery(options.minifyCSS(wrapMediaQuery(attrValue)));
330 function isMetaViewport(tag, attrs) {
331 if (tag !== 'meta') {
334 for (var i = 0, len = attrs.length; i < len; i++) {
335 if (attrs[i].name === 'name' && attrs[i].value === 'viewport') {
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 + '}';
347 function unwrapInlineCSS(text) {
348 var matches = text.match(/^\*\{([\s\S]*)\}$/);
349 return matches ? matches[1] : text;
352 function wrapMediaQuery(text) {
353 return '@media ' + text + '{a{top:0}}';
356 function unwrapMediaQuery(text) {
357 var matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
358 return matches ? matches[1] : text;
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;
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);
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');
400 function canRemoveParentTag(optionalStartTag, tag) {
401 switch (optionalStartTag) {
406 return !headerTags(tag);
408 return tag === 'col';
415 function isStartTagMandatory(optionalEndTag, tag) {
418 return optionalEndTag === 'colgroup';
420 return tableSectionTags(optionalEndTag);
425 function canRemovePrecedingTag(optionalEndTag, tag) {
426 switch (optionalEndTag) {
436 return tag === optionalEndTag;
439 return descriptionTags(tag);
441 return pBlockTags(tag);
445 return rubyTags(tag);
449 return optionTag(tag);
452 return tableContentTags(tag);
454 return tag === 'tbody';
457 return cellTags(tag);
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)))$');
466 function canDeleteEmptyAttribute(tag, attrName, attrValue, options) {
467 var isValueEmpty = !attrValue || /^\s*$/.test(attrValue);
471 if (typeof options.removeEmptyAttributes === 'function') {
472 return options.removeEmptyAttributes(attrName, tag);
474 return tag === 'input' && attrName === 'value' || reEmptyAttribute.test(attrName);
477 function hasAttrName(name, attrs) {
478 for (var i = attrs.length - 1; i >= 0; i--) {
479 if (attrs[i].name === name) {
486 function canRemoveElement(tag, attrs) {
493 if (hasAttrName('src', attrs)) {
498 if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
503 if (hasAttrName('data', attrs)) {
508 if (hasAttrName('code', attrs)) {
516 function canCollapseWhitespace(tag) {
517 return !/^(?:script|style|pre|textarea)$/.test(tag);
520 function canTrimWhitespace(tag) {
521 return !/^(?:pre|textarea)$/.test(tag);
524 function normalizeAttr(attr, attrs, tag, options) {
525 var attrName = options.caseSensitive ? attr.name : attr.name.toLowerCase(),
526 attrValue = attr.value;
528 if (options.decodeEntities && attrValue) {
529 attrValue = decode(attrValue, { isAttributeValue: true });
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)) {
541 attrValue = cleanAttributeValue(tag, attrName, attrValue, options, attrs);
543 if (options.removeEmptyAttributes &&
544 canDeleteEmptyAttribute(tag, attrName, attrValue, options)) {
548 if (options.decodeEntities && attrValue) {
549 attrValue = attrValue.replace(/&(#?[0-9a-zA-Z]+;)/g, '&$1');
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,
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 ? '\'' : '"';
576 attrQuote = options.quoteCharacter === '\'' ? '\'' : '"';
578 if (attrQuote === '"') {
579 attrValue = attrValue.replace(/"/g, '"');
582 attrValue = attrValue.replace(/'/g, ''');
585 emittedAttrValue = attrQuote + attrValue + attrQuote;
586 if (!isLast && !options.removeTagWhitespace) {
587 emittedAttrValue += ' ';
590 // make sure trailing slash is not interpreted as HTML self-closing tag
591 else if (isLast && !hasUnarySlash && !/\/$/.test(attrValue)) {
592 emittedAttrValue = attrValue;
595 emittedAttrValue = attrValue + ' ';
598 if (typeof attrValue === 'undefined' || options.collapseBooleanAttributes &&
599 isBooleanAttribute(attrName.toLowerCase(), attrValue.toLowerCase())) {
600 attrFragment = attrName;
606 attrFragment = attrName + attr.customAssign + emittedAttrValue;
609 return attr.customOpen + attrFragment + attr.customClose;
612 function identity(value) {
616 function processOptions(options) {
617 ['html5', 'includeAutoGeneratedTags'].forEach(function(key) {
618 if (!(key in options)) {
623 if (typeof options.log !== 'function') {
624 options.log = identity;
627 if (!options.canCollapseWhitespace) {
628 options.canCollapseWhitespace = canCollapseWhitespace;
630 if (!options.canTrimWhitespace) {
631 options.canTrimWhitespace = canTrimWhitespace;
634 if (!('ignoreCustomComments' in options)) {
635 options.ignoreCustomComments = [/^!/];
638 if (!('ignoreCustomFragments' in options)) {
639 options.ignoreCustomFragments = [
645 if (!options.minifyURLs) {
646 options.minifyURLs = identity;
648 if (typeof options.minifyURLs !== 'function') {
649 var minifyURLs = options.minifyURLs;
650 if (typeof minifyURLs === 'string') {
651 minifyURLs = { site: minifyURLs };
653 else if (typeof minifyURLs !== 'object') {
656 options.minifyURLs = function(text) {
658 return RelateUrl.relate(text, minifyURLs);
667 if (!options.minifyJS) {
668 options.minifyJS = identity;
670 if (typeof options.minifyJS !== 'function') {
671 var minifyJS = options.minifyJS;
672 if (typeof minifyJS !== 'object') {
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);
682 options.log(result.error);
685 return result.code.replace(/;$/, '');
689 if (!options.minifyCSS) {
690 options.minifyCSS = identity;
692 if (typeof options.minifyCSS !== 'function') {
693 var minifyCSS = options.minifyCSS;
694 if (typeof minifyCSS !== 'object') {
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;
702 return new CleanCSS(minifyCSS).minify(text).styles;
712 function uniqueId(value) {
715 id = Math.random().toString(36).replace(/^0\.[0-9]*/, '');
716 } while (~value.indexOf(id));
720 var specialContentTags = createMapFromString('script,style');
722 function createSortFns(value, options, uidIgnore, uidAttr) {
723 var attrChains = options.sortAttributes && Object.create(null);
724 var classChain = options.sortClassName && new TokenChain();
726 function attrNames(attrs) {
727 return attrs.map(function(attr) {
728 return options.caseSensitive ? attr.name : attr.name.toLowerCase();
732 function shouldSkipUID(token, uid) {
733 return !uid || token.indexOf(uid) === -1;
736 function shouldSkipUIDs(token) {
737 return shouldSkipUID(token, uidIgnore) && shouldSkipUID(token, uidAttr);
740 function scan(input) {
741 var currentTag, currentType;
742 new HTMLParser(input, {
743 start: function(tag, attrs) {
745 if (!attrChains[tag]) {
746 attrChains[tag] = new TokenChain();
748 attrChains[tag].add(attrNames(attrs).filter(shouldSkipUIDs));
750 for (var i = 0, len = attrs.length; i < len; 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));
755 else if (options.processScripts && attr.name.toLowerCase() === 'type') {
757 currentType = attr.value;
764 chars: function(text) {
765 if (options.processScripts && specialContentTags(currentTag) &&
766 options.processScripts.indexOf(currentType) > -1) {
773 var log = options.log;
775 options.sortAttributes = false;
776 options.sortClassName = false;
777 scan(minify(value, options));
780 var attrSorters = Object.create(null);
781 for (var tag in attrChains) {
782 attrSorters[tag] = attrChains[tag].createSorter();
784 options.sortAttributes = function(tag, attrs) {
785 var sorter = attrSorters[tag];
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]);
792 sorter.sort(names).forEach(function(name, index) {
793 attrs[index] = attrMap[name].shift();
799 var sorter = classChain.createSorter();
800 options.sortClassName = function(value) {
801 return sorter.sort(value.split(/[ \n\f\r]+/)).join(' ');
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);
820 stackNoTrimWhitespace = [],
821 stackNoCollapseWhitespace = [],
822 optionalStartTag = '',
825 ignoredMarkupChunks = [],
826 ignoredCustomMarkupChunks = [],
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) {
837 uidIgnore = uniqueId(value);
838 var pattern = new RegExp('^' + uidIgnore + '([0-9]+)$');
839 if (options.ignoreCustomComments) {
840 options.ignoreCustomComments.push(pattern);
843 options.ignoreCustomComments = [pattern];
846 var token = '<!--' + uidIgnore + ignoredMarkupChunks.length + '-->';
847 ignoredMarkupChunks.push(group1);
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];
858 var customFragments = options.ignoreCustomFragments.map(function(re) {
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) {
866 uidAttr = uniqueId(value);
867 uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)(\\s*)', 'g');
868 var minifyCSS = options.minifyCSS;
870 options.minifyCSS = function(text) {
871 return minifyCSS(escapeFragments(text));
874 var minifyJS = options.minifyJS;
876 options.minifyJS = function(text, inline) {
877 return minifyJS(escapeFragments(text), inline);
881 var token = uidAttr + ignoredCustomMarkupChunks.length;
882 ignoredCustomMarkupChunks.push(/^(\s*)[\s\S]*?(\s*)$/.exec(match));
883 return '\t' + token + '\t';
887 if (options.sortAttributes && typeof options.sortAttributes !== 'function' ||
888 options.sortClassName && typeof options.sortClassName !== 'function') {
889 createSortFns(value, options, uidIgnore, uidAttr);
892 function _canCollapseWhitespace(tag, attrs) {
893 return options.canCollapseWhitespace(tag, attrs, canCollapseWhitespace);
896 function _canTrimWhitespace(tag, attrs) {
897 return options.canTrimWhitespace(tag, attrs, canTrimWhitespace);
900 function removeStartTag() {
901 var index = buffer.length - 1;
902 while (index > 0 && !/^<[^/!]/.test(buffer[index])) {
905 buffer.length = Math.max(0, index);
908 function removeEndTag() {
909 var index = buffer.length - 1;
910 while (index > 0 && !/^<\//.test(buffer[index])) {
913 buffer.length = Math.max(0, index);
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:-]+)>$/);
924 else if (/>$/.test(str) || (buffer[index] = collapseWhitespaceSmart(str, null, nextTag, options))) {
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) {
941 trimTrailingWhitespace(charsIndex, nextTag);
944 new HTMLParser(value, {
945 partialMarkup: partialMarkup,
946 html5: options.html5,
948 start: function(tag, attrs, unary, unarySlash, autoGenerated) {
949 var lowerTag = tag.toLowerCase();
951 if (lowerTag === 'svg') {
952 optionsStack.push(options);
953 var nextOptions = {};
954 for (var key in options) {
955 nextOptions[key] = options[key];
957 nextOptions.keepClosingSlash = true;
958 nextOptions.caseSensitive = true;
959 options = nextOptions;
962 tag = options.caseSensitive ? tag : lowerTag;
966 if (!inlineTextTags(tag)) {
970 currentAttrs = attrs;
972 var optional = options.removeOptionalTags;
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)) {
983 optionalStartTag = '';
984 // end-tag-followed-by-start-tag omission rules
985 if (htmlTag && canRemovePrecedingTag(optionalEndTag, tag)) {
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);
994 // set whitespace flags for nested tags (eg. <code> within a <pre>)
995 if (options.collapseWhitespace) {
996 if (!stackNoTrimWhitespace.length) {
997 squashTrailingWhitespace(tag);
1000 if (!_canTrimWhitespace(tag, attrs) || stackNoTrimWhitespace.length) {
1001 stackNoTrimWhitespace.push(tag);
1003 if (!_canCollapseWhitespace(tag, attrs) || stackNoCollapseWhitespace.length) {
1004 stackNoCollapseWhitespace.push(tag);
1009 var openTag = '<' + tag;
1010 var hasUnarySlash = unarySlash && options.keepClosingSlash;
1012 buffer.push(openTag);
1014 if (options.sortAttributes) {
1015 options.sortAttributes(tag, attrs);
1019 for (var i = attrs.length, isLast = true; --i >= 0;) {
1020 var normalized = normalizeAttr(attrs[i], attrs, tag, options);
1022 parts.unshift(buildAttr(normalized, hasUnarySlash, options, isLast, uidAttr));
1026 if (parts.length > 0) {
1028 buffer.push.apply(buffer, parts);
1030 // start tag must never be omitted if it has any attributes
1031 else if (optional && optionalStartTags(tag)) {
1032 optionalStartTag = tag;
1035 buffer.push(buffer.pop() + (hasUnarySlash ? '/' : '') + '>');
1037 if (autoGenerated && !options.includeAutoGeneratedTags) {
1039 optionalStartTag = '';
1042 end: function(tag, attrs, autoGenerated) {
1043 var lowerTag = tag.toLowerCase();
1044 if (lowerTag === 'svg') {
1045 options = optionsStack.pop();
1047 tag = options.caseSensitive ? tag : lowerTag;
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();
1057 squashTrailingWhitespace('/' + tag);
1059 if (stackNoCollapseWhitespace.length &&
1060 tag === stackNoCollapseWhitespace[stackNoCollapseWhitespace.length - 1]) {
1061 stackNoCollapseWhitespace.pop();
1065 var isElementEmpty = false;
1066 if (tag === currentTag) {
1068 isElementEmpty = !hasChars;
1071 if (options.removeOptionalTags) {
1072 // <html>, <head> or <body> may be omitted if the element is empty
1073 if (isElementEmpty && topLevelTags(optionalStartTag)) {
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))) {
1084 optionalEndTag = optionalEndTags(tag) ? tag : '';
1087 if (options.removeEmptyElements && isElementEmpty && canRemoveElement(tag, attrs)) {
1088 // remove last "element" from buffer
1090 optionalStartTag = '';
1091 optionalEndTag = '';
1094 if (autoGenerated && !options.includeAutoGeneratedTags) {
1095 optionalEndTag = '';
1098 buffer.push('</' + tag + '>');
1100 charsPrevTag = '/' + tag;
1101 if (!inlineTags(tag)) {
1104 else if (isElementEmpty) {
1105 currentChars += '|';
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);
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) {
1121 prevTag = charsPrevTag;
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;
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) {
1139 trimTrailingWhitespace(tagIndex - 1, 'br');
1142 else if (inlineTextTags(prevTag.charAt(0) === '/' ? prevTag.slice(1) : prevTag)) {
1143 text = collapseWhitespace(text, options, /(?:^|\s)$/.test(currentChars));
1146 if (prevTag || nextTag) {
1147 text = collapseWhitespaceSmart(text, prevTag, nextTag, options);
1150 text = collapseWhitespace(text, options, true, true);
1152 if (!text && /\s$/.test(currentChars) && prevTag && prevTag.charAt(0) === '/') {
1153 trimTrailingWhitespace(buffer.length - 1, nextTag);
1156 else if (uidPattern) {
1157 text = text.replace(uidPattern, function(match, prefix, index) {
1158 return ignoredCustomMarkupChunks[+index][0];
1161 if (!stackNoCollapseWhitespace.length && nextTag !== 'html' && !(prevTag && nextTag)) {
1162 text = collapseWhitespace(text, options, false, false, true);
1165 if (options.processScripts && specialContentTags(currentTag)) {
1166 text = processScript(text, options, currentAttrs);
1168 if (isExecutableScript(currentTag, currentAttrs)) {
1169 text = options.minifyJS(text);
1171 if (isStyleSheet(currentTag, currentAttrs)) {
1172 text = options.minifyCSS(text);
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)) {
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)) {
1186 optionalEndTag = '';
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, '&$1').replace(/</g, '<');
1194 currentChars += text;
1200 comment: function(text, nonStandard) {
1201 var prefix = nonStandard ? '<!' : '<!--';
1202 var suffix = nonStandard ? '>' : '-->';
1203 if (isConditionalComment(text)) {
1204 text = prefix + cleanConditionalComment(text, options) + suffix;
1206 else if (options.removeComments) {
1207 if (isIgnoredComment(text, options)) {
1208 text = '<!--' + text + '-->';
1215 text = prefix + text + suffix;
1217 if (options.removeOptionalTags && text) {
1218 // preceding comments suppress tag omissions
1219 optionalStartTag = '';
1220 optionalEndTag = '';
1224 doctype: function(doctype) {
1225 buffer.push(options.useShortDoctype ? '<!DOCTYPE html>' : collapseWhitespaceAll(doctype));
1227 customAttrAssign: options.customAttrAssign,
1228 customAttrSurround: options.customAttrSurround
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)) {
1237 // except for </dt> or </thead>, end tags may be omitted if no more content in parent element
1238 if (optionalEndTag && !trailingTags(optionalEndTag)) {
1242 if (options.collapseWhitespace) {
1243 squashTrailingWhitespace('br');
1246 var str = joinResultSegments(buffer, options);
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;
1255 if (suffix !== '\t') {
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));
1267 str = str.replace(new RegExp('<!--' + uidIgnore + '([0-9]+)-->', 'g'), function(match, index) {
1268 return ignoredMarkupChunks[+index];
1272 options.log('minified in: ' + (Date.now() - t) + 'ms');
1276 function joinResultSegments(results, options) {
1278 var maxLineLength = options.maxLineLength;
1279 if (maxLineLength) {
1283 for (var i = 0, len = results.length; i < len; i++) {
1285 if (line.length + token.length < maxLineLength) {
1289 lines.push(line.replace(/^\n/, ''));
1295 str = lines.join('\n');
1298 str = results.join('');
1300 return options.collapseWhitespace ? collapseWhitespace(str, options, true, true) : str;
1303 exports.minify = function(value, options) {
1304 return minify(value, options);