html-minifier-next 4.12.2 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -19
- package/dist/htmlminifier.cjs +1477 -1313
- package/dist/htmlminifier.esm.bundle.js +4137 -3973
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/htmlparser.d.ts.map +1 -1
- package/dist/types/lib/attributes.d.ts +29 -0
- package/dist/types/lib/attributes.d.ts.map +1 -0
- package/dist/types/lib/constants.d.ts +83 -0
- package/dist/types/lib/constants.d.ts.map +1 -0
- package/dist/types/lib/content.d.ts +7 -0
- package/dist/types/lib/content.d.ts.map +1 -0
- package/dist/types/lib/elements.d.ts +39 -0
- package/dist/types/lib/elements.d.ts.map +1 -0
- package/dist/types/lib/options.d.ts +17 -0
- package/dist/types/lib/options.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +21 -0
- package/dist/types/lib/utils.d.ts.map +1 -0
- package/dist/types/lib/whitespace.d.ts +7 -0
- package/dist/types/lib/whitespace.d.ts.map +1 -0
- package/dist/types/presets.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/htmlminifier.js +91 -1226
- package/src/htmlparser.js +11 -11
- package/src/lib/attributes.js +511 -0
- package/src/lib/constants.js +213 -0
- package/src/lib/content.js +105 -0
- package/src/lib/elements.js +242 -0
- package/src/lib/index.js +20 -0
- package/src/lib/options.js +252 -0
- package/src/lib/utils.js +90 -0
- package/src/lib/whitespace.js +139 -0
- package/src/presets.js +0 -1
- package/src/tokenchain.js +1 -1
- package/dist/types/utils.d.ts +0 -2
- package/dist/types/utils.d.ts.map +0 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// RegExp patterns (to avoid repeated allocations in hot paths)
|
|
2
|
+
|
|
3
|
+
const RE_WS_START = /^[ \n\r\t\f]+/;
|
|
4
|
+
const RE_WS_END = /[ \n\r\t\f]+$/;
|
|
5
|
+
const RE_ALL_WS_NBSP = /[ \n\r\t\f\xA0]+/g;
|
|
6
|
+
const RE_NBSP_LEADING_GROUP = /(^|\xA0+)[^\xA0]+/g;
|
|
7
|
+
const RE_NBSP_LEAD_GROUP = /(\xA0+)[^\xA0]+/g;
|
|
8
|
+
const RE_NBSP_TRAILING_GROUP = /[^\xA0]+(\xA0+)/g;
|
|
9
|
+
const RE_NBSP_TRAILING_STRIP = /[^\xA0]+$/;
|
|
10
|
+
const RE_CONDITIONAL_COMMENT = /^\[if\s[^\]]+]|\[endif]$/;
|
|
11
|
+
const RE_EVENT_ATTR_DEFAULT = /^on[a-z]{3,}$/;
|
|
12
|
+
const RE_CAN_REMOVE_ATTR_QUOTES = /^[^ \t\n\f\r"'`=<>]+$/;
|
|
13
|
+
const RE_TRAILING_SEMICOLON = /;$/;
|
|
14
|
+
const RE_AMP_ENTITY = /&(#?[0-9a-zA-Z]+;)/g;
|
|
15
|
+
|
|
16
|
+
// Inline element Sets for whitespace handling
|
|
17
|
+
|
|
18
|
+
// Non-empty elements that will maintain whitespace around them
|
|
19
|
+
const inlineElementsToKeepWhitespaceAround = new Set(['a', 'abbr', 'acronym', 'b', 'bdi', 'bdo', 'big', 'button', 'cite', 'code', 'del', 'dfn', 'em', 'font', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'mark', 'math', 'meter', 'nobr', 'object', 'output', 'progress', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'svg', 'textarea', 'time', 'tt', 'u', 'var', 'wbr']);
|
|
20
|
+
|
|
21
|
+
// Non-empty elements that will maintain whitespace within them
|
|
22
|
+
const inlineElementsToKeepWhitespaceWithin = new Set(['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']);
|
|
23
|
+
|
|
24
|
+
// Elements that will always maintain whitespace around them
|
|
25
|
+
const inlineElementsToKeepWhitespace = new Set(['comment', 'img', 'input', 'wbr']);
|
|
26
|
+
|
|
27
|
+
// Default attribute values
|
|
28
|
+
|
|
29
|
+
// Default attribute values (could apply to any element)
|
|
30
|
+
const generalDefaults = {
|
|
31
|
+
autocorrect: 'on',
|
|
32
|
+
fetchpriority: 'auto',
|
|
33
|
+
loading: 'eager',
|
|
34
|
+
popovertargetaction: 'toggle'
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Tag-specific default attribute values
|
|
38
|
+
const tagDefaults = {
|
|
39
|
+
area: { shape: 'rect' },
|
|
40
|
+
button: { type: 'submit' },
|
|
41
|
+
form: {
|
|
42
|
+
enctype: 'application/x-www-form-urlencoded',
|
|
43
|
+
method: 'get'
|
|
44
|
+
},
|
|
45
|
+
html: { dir: 'ltr' },
|
|
46
|
+
img: { decoding: 'auto' },
|
|
47
|
+
input: {
|
|
48
|
+
colorspace: 'limited-srgb',
|
|
49
|
+
type: 'text'
|
|
50
|
+
},
|
|
51
|
+
marquee: {
|
|
52
|
+
behavior: 'scroll',
|
|
53
|
+
direction: 'left'
|
|
54
|
+
},
|
|
55
|
+
style: { media: 'all' },
|
|
56
|
+
textarea: { wrap: 'soft' },
|
|
57
|
+
track: { kind: 'subtitles' }
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Script MIME types
|
|
61
|
+
|
|
62
|
+
// https://mathiasbynens.be/demo/javascript-mime-type
|
|
63
|
+
// https://developer.mozilla.org/en/docs/Web/HTML/Element/script#attr-type
|
|
64
|
+
const executableScriptsMimetypes = new Set([
|
|
65
|
+
'text/javascript',
|
|
66
|
+
'text/ecmascript',
|
|
67
|
+
'text/jscript',
|
|
68
|
+
'application/javascript',
|
|
69
|
+
'application/x-javascript',
|
|
70
|
+
'application/ecmascript',
|
|
71
|
+
'module'
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const keepScriptsMimetypes = new Set([
|
|
75
|
+
'module'
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// Boolean attribute Sets
|
|
79
|
+
|
|
80
|
+
const isSimpleBoolean = new Set(['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']);
|
|
81
|
+
|
|
82
|
+
const isBooleanValue = new Set(['true', 'false']);
|
|
83
|
+
|
|
84
|
+
// `srcset` tags
|
|
85
|
+
|
|
86
|
+
const srcsetTags = new Set(['img', 'source']);
|
|
87
|
+
|
|
88
|
+
// JSON script types
|
|
89
|
+
|
|
90
|
+
const jsonScriptTypes = new Set([
|
|
91
|
+
'application/json',
|
|
92
|
+
'application/ld+json',
|
|
93
|
+
'application/manifest+json',
|
|
94
|
+
'application/vnd.geo+json',
|
|
95
|
+
'application/problem+json',
|
|
96
|
+
'application/merge-patch+json',
|
|
97
|
+
'application/json-patch+json',
|
|
98
|
+
'importmap',
|
|
99
|
+
'speculationrules',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
// Tag omission rules and element Sets
|
|
103
|
+
|
|
104
|
+
// Tag omission rules from https://html.spec.whatwg.org/multipage/syntax.html#optional-tags with the following extensions:
|
|
105
|
+
// - retain `<body>` if followed by `<noscript>`
|
|
106
|
+
// - `<rb>`, `<rt>`, `<rtc>`, `<rp>` follow HTML Ruby Markup Extensions draft (https://www.w3.org/TR/html-ruby-extensions/)
|
|
107
|
+
// - retain all tags which are adjacent to non-standard HTML tags
|
|
108
|
+
|
|
109
|
+
const optionalStartTags = new Set(['html', 'head', 'body', 'colgroup', 'tbody']);
|
|
110
|
+
|
|
111
|
+
const optionalEndTags = new Set(['html', 'head', 'body', 'li', 'dt', 'dd', 'p', 'rb', 'rt', 'rtc', 'rp', 'optgroup', 'option', 'colgroup', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th']);
|
|
112
|
+
|
|
113
|
+
const headerTags = new Set(['meta', 'link', 'script', 'style', 'template', 'noscript']);
|
|
114
|
+
|
|
115
|
+
const descriptionTags = new Set(['dt', 'dd']);
|
|
116
|
+
|
|
117
|
+
const pBlockTags = new Set(['address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'search', 'section', 'table', 'ul']);
|
|
118
|
+
|
|
119
|
+
const pInlineTags = new Set(['a', 'audio', 'del', 'ins', 'map', 'noscript', 'video']);
|
|
120
|
+
|
|
121
|
+
const rubyEndTagOmission = new Set(['rb', 'rt', 'rtc', 'rp']); // `</rb>`, `</rt>`, `</rp>` can be omitted if followed by `<rb>`, `<rt>`, `<rtc>`, or `<rp>`
|
|
122
|
+
|
|
123
|
+
const rubyRtcEndTagOmission = new Set(['rb', 'rtc']); // `</rtc>` can be omitted if followed by `<rb>` or `<rtc>` (not `<rt>` or `<rp>`)
|
|
124
|
+
|
|
125
|
+
const optionTag = new Set(['option', 'optgroup']);
|
|
126
|
+
|
|
127
|
+
const tableContentTags = new Set(['tbody', 'tfoot']);
|
|
128
|
+
|
|
129
|
+
const tableSectionTags = new Set(['thead', 'tbody', 'tfoot']);
|
|
130
|
+
|
|
131
|
+
const cellTags = new Set(['td', 'th']);
|
|
132
|
+
|
|
133
|
+
const topLevelTags = new Set(['html', 'head', 'body']);
|
|
134
|
+
|
|
135
|
+
const compactTags = new Set(['html', 'body']);
|
|
136
|
+
|
|
137
|
+
const looseTags = new Set(['head', 'colgroup', 'caption']);
|
|
138
|
+
|
|
139
|
+
const trailingTags = new Set(['dt', 'thead']);
|
|
140
|
+
|
|
141
|
+
const htmlTags = new Set(['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', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'script', 'search', 'section', 'select', 'selectedcontent', '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']);
|
|
142
|
+
|
|
143
|
+
// Empty attribute regex
|
|
144
|
+
|
|
145
|
+
const reEmptyAttribute = new RegExp(
|
|
146
|
+
'^(?:class|id|style|title|lang|dir|on(?:focus|blur|change|click|dblclick|mouse(' +
|
|
147
|
+
'?:down|up|over|move|out)|key(?:press|down|up)))$');
|
|
148
|
+
|
|
149
|
+
// Special content elements
|
|
150
|
+
|
|
151
|
+
const specialContentTags = new Set(['script', 'style']);
|
|
152
|
+
|
|
153
|
+
// Exports
|
|
154
|
+
|
|
155
|
+
export {
|
|
156
|
+
// RegExp patterns
|
|
157
|
+
RE_WS_START,
|
|
158
|
+
RE_WS_END,
|
|
159
|
+
RE_ALL_WS_NBSP,
|
|
160
|
+
RE_NBSP_LEADING_GROUP,
|
|
161
|
+
RE_NBSP_LEAD_GROUP,
|
|
162
|
+
RE_NBSP_TRAILING_GROUP,
|
|
163
|
+
RE_NBSP_TRAILING_STRIP,
|
|
164
|
+
RE_CONDITIONAL_COMMENT,
|
|
165
|
+
RE_EVENT_ATTR_DEFAULT,
|
|
166
|
+
RE_CAN_REMOVE_ATTR_QUOTES,
|
|
167
|
+
RE_TRAILING_SEMICOLON,
|
|
168
|
+
RE_AMP_ENTITY,
|
|
169
|
+
|
|
170
|
+
// Inline element Sets
|
|
171
|
+
inlineElementsToKeepWhitespaceAround,
|
|
172
|
+
inlineElementsToKeepWhitespaceWithin,
|
|
173
|
+
inlineElementsToKeepWhitespace,
|
|
174
|
+
|
|
175
|
+
// Default values
|
|
176
|
+
generalDefaults,
|
|
177
|
+
tagDefaults,
|
|
178
|
+
|
|
179
|
+
// Script/style constants
|
|
180
|
+
executableScriptsMimetypes,
|
|
181
|
+
keepScriptsMimetypes,
|
|
182
|
+
jsonScriptTypes,
|
|
183
|
+
|
|
184
|
+
// Boolean Sets
|
|
185
|
+
isSimpleBoolean,
|
|
186
|
+
isBooleanValue,
|
|
187
|
+
|
|
188
|
+
// Misc
|
|
189
|
+
srcsetTags,
|
|
190
|
+
|
|
191
|
+
// Tag omission rules
|
|
192
|
+
optionalStartTags,
|
|
193
|
+
optionalEndTags,
|
|
194
|
+
headerTags,
|
|
195
|
+
descriptionTags,
|
|
196
|
+
pBlockTags,
|
|
197
|
+
pInlineTags,
|
|
198
|
+
rubyEndTagOmission,
|
|
199
|
+
rubyRtcEndTagOmission,
|
|
200
|
+
optionTag,
|
|
201
|
+
tableContentTags,
|
|
202
|
+
tableSectionTags,
|
|
203
|
+
cellTags,
|
|
204
|
+
topLevelTags,
|
|
205
|
+
compactTags,
|
|
206
|
+
looseTags,
|
|
207
|
+
trailingTags,
|
|
208
|
+
htmlTags,
|
|
209
|
+
|
|
210
|
+
// Regex
|
|
211
|
+
reEmptyAttribute,
|
|
212
|
+
specialContentTags
|
|
213
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Imports
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
jsonScriptTypes
|
|
5
|
+
} from './constants.js';
|
|
6
|
+
import { replaceAsync } from './utils.js';
|
|
7
|
+
import { trimWhitespace } from './whitespace.js';
|
|
8
|
+
|
|
9
|
+
// CSS processing
|
|
10
|
+
|
|
11
|
+
// Wrap CSS declarations for inline styles and media queries
|
|
12
|
+
// This ensures proper context for CSS minification
|
|
13
|
+
|
|
14
|
+
function wrapCSS(text, type) {
|
|
15
|
+
switch (type) {
|
|
16
|
+
case 'inline':
|
|
17
|
+
return '*{' + text + '}';
|
|
18
|
+
case 'media':
|
|
19
|
+
return '@media ' + text + '{a{top:0}}';
|
|
20
|
+
default:
|
|
21
|
+
return text;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function unwrapCSS(text, type) {
|
|
26
|
+
let matches;
|
|
27
|
+
switch (type) {
|
|
28
|
+
case 'inline':
|
|
29
|
+
matches = text.match(/^\*\{([\s\S]*)\}$/);
|
|
30
|
+
break;
|
|
31
|
+
case 'media':
|
|
32
|
+
matches = text.match(/^@media ([\s\S]*?)\s*{[\s\S]*}$/);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
return matches ? matches[1] : text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function cleanConditionalComment(comment, options, minifyHTML) {
|
|
39
|
+
return options.processConditionalComments
|
|
40
|
+
? await replaceAsync(comment, /^(\[if\s[^\]]+]>)([\s\S]*?)(<!\[endif])$/, async function (match, prefix, text, suffix) {
|
|
41
|
+
return prefix + await minifyHTML(text, options, true) + suffix;
|
|
42
|
+
})
|
|
43
|
+
: comment;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Script processing
|
|
47
|
+
|
|
48
|
+
function minifyJson(text, options) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.stringify(JSON.parse(text));
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (!options.continueOnMinifyError) {
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
options.log && options.log(err);
|
|
57
|
+
return text;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasJsonScriptType(attrs) {
|
|
62
|
+
for (let i = 0, len = attrs.length; i < len; i++) {
|
|
63
|
+
const attrName = attrs[i].name.toLowerCase();
|
|
64
|
+
if (attrName === 'type') {
|
|
65
|
+
const attrValue = trimWhitespace((attrs[i].value || '').split(/;/, 2)[0]).toLowerCase();
|
|
66
|
+
if (jsonScriptTypes.has(attrValue)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function processScript(text, options, currentAttrs, minifyHTML) {
|
|
75
|
+
for (let i = 0, len = currentAttrs.length; i < len; i++) {
|
|
76
|
+
const attrName = currentAttrs[i].name.toLowerCase();
|
|
77
|
+
if (attrName === 'type') {
|
|
78
|
+
const rawValue = currentAttrs[i].value;
|
|
79
|
+
const normalizedValue = trimWhitespace((rawValue || '').split(/;/, 2)[0]).toLowerCase();
|
|
80
|
+
// Minify JSON script types automatically
|
|
81
|
+
if (jsonScriptTypes.has(normalizedValue)) {
|
|
82
|
+
return minifyJson(text, options);
|
|
83
|
+
}
|
|
84
|
+
// Process custom script types if specified
|
|
85
|
+
if (options.processScripts && options.processScripts.indexOf(rawValue) > -1) {
|
|
86
|
+
return await minifyHTML(text, options);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Exports
|
|
94
|
+
|
|
95
|
+
export {
|
|
96
|
+
// CSS
|
|
97
|
+
wrapCSS,
|
|
98
|
+
unwrapCSS,
|
|
99
|
+
cleanConditionalComment,
|
|
100
|
+
|
|
101
|
+
// Scripts
|
|
102
|
+
minifyJson,
|
|
103
|
+
hasJsonScriptType,
|
|
104
|
+
processScript
|
|
105
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Imports
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
headerTags,
|
|
5
|
+
descriptionTags,
|
|
6
|
+
pBlockTags,
|
|
7
|
+
rubyEndTagOmission,
|
|
8
|
+
rubyRtcEndTagOmission,
|
|
9
|
+
optionTag,
|
|
10
|
+
tableContentTags,
|
|
11
|
+
tableSectionTags,
|
|
12
|
+
cellTags
|
|
13
|
+
} from './constants.js';
|
|
14
|
+
import { hasAttrName } from './attributes.js';
|
|
15
|
+
|
|
16
|
+
// Tag omission rules
|
|
17
|
+
|
|
18
|
+
function canRemoveParentTag(optionalStartTag, tag) {
|
|
19
|
+
switch (optionalStartTag) {
|
|
20
|
+
case 'html':
|
|
21
|
+
case 'head':
|
|
22
|
+
return true;
|
|
23
|
+
case 'body':
|
|
24
|
+
return !headerTags.has(tag);
|
|
25
|
+
case 'colgroup':
|
|
26
|
+
return tag === 'col';
|
|
27
|
+
case 'tbody':
|
|
28
|
+
return tag === 'tr';
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isStartTagMandatory(optionalEndTag, tag) {
|
|
34
|
+
switch (tag) {
|
|
35
|
+
case 'colgroup':
|
|
36
|
+
return optionalEndTag === 'colgroup';
|
|
37
|
+
case 'tbody':
|
|
38
|
+
return tableSectionTags.has(optionalEndTag);
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function canRemovePrecedingTag(optionalEndTag, tag) {
|
|
44
|
+
switch (optionalEndTag) {
|
|
45
|
+
case 'html':
|
|
46
|
+
case 'head':
|
|
47
|
+
case 'body':
|
|
48
|
+
case 'colgroup':
|
|
49
|
+
case 'caption':
|
|
50
|
+
return true;
|
|
51
|
+
case 'li':
|
|
52
|
+
case 'optgroup':
|
|
53
|
+
case 'tr':
|
|
54
|
+
return tag === optionalEndTag;
|
|
55
|
+
case 'dt':
|
|
56
|
+
case 'dd':
|
|
57
|
+
return descriptionTags.has(tag);
|
|
58
|
+
case 'p':
|
|
59
|
+
return pBlockTags.has(tag);
|
|
60
|
+
case 'rb':
|
|
61
|
+
case 'rt':
|
|
62
|
+
case 'rp':
|
|
63
|
+
return rubyEndTagOmission.has(tag);
|
|
64
|
+
case 'rtc':
|
|
65
|
+
return rubyRtcEndTagOmission.has(tag);
|
|
66
|
+
case 'option':
|
|
67
|
+
return optionTag.has(tag);
|
|
68
|
+
case 'thead':
|
|
69
|
+
case 'tbody':
|
|
70
|
+
return tableContentTags.has(tag);
|
|
71
|
+
case 'tfoot':
|
|
72
|
+
return tag === 'tbody';
|
|
73
|
+
case 'td':
|
|
74
|
+
case 'th':
|
|
75
|
+
return cellTags.has(tag);
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Element removal logic
|
|
81
|
+
|
|
82
|
+
function canRemoveElement(tag, attrs) {
|
|
83
|
+
switch (tag) {
|
|
84
|
+
case 'textarea':
|
|
85
|
+
return false;
|
|
86
|
+
case 'audio':
|
|
87
|
+
case 'script':
|
|
88
|
+
case 'video':
|
|
89
|
+
if (hasAttrName('src', attrs)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
case 'iframe':
|
|
94
|
+
if (hasAttrName('src', attrs) || hasAttrName('srcdoc', attrs)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
case 'object':
|
|
99
|
+
if (hasAttrName('data', attrs)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
case 'applet':
|
|
104
|
+
if (hasAttrName('code', attrs)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {string} str - Tag name or HTML-like element spec (e.g., “td” or “<span aria-hidden='true'>”)
|
|
114
|
+
* @param {MinifierOptions} options - Options object for name normalization
|
|
115
|
+
* @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
|
|
116
|
+
*/
|
|
117
|
+
function parseElementSpec(str, options) {
|
|
118
|
+
if (typeof str !== 'string') {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const trimmed = str.trim();
|
|
123
|
+
if (!trimmed) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Simple tag name: `td`
|
|
128
|
+
if (!/[<>]/.test(trimmed)) {
|
|
129
|
+
return { tag: options.name(trimmed), attrs: null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// HTML-like markup: `<span aria-hidden='true'>` or `<td></td>`
|
|
133
|
+
// Extract opening tag using regex
|
|
134
|
+
const match = trimmed.match(/^<([a-zA-Z][\w:-]*)((?:\s+[^>]*)?)>/);
|
|
135
|
+
if (!match) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const tag = options.name(match[1]);
|
|
140
|
+
const attrString = match[2];
|
|
141
|
+
|
|
142
|
+
if (!attrString.trim()) {
|
|
143
|
+
return { tag, attrs: null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Parse attributes from string
|
|
147
|
+
const attrs = {};
|
|
148
|
+
const attrRegex = /([a-zA-Z][\w:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>/]+)))?/g;
|
|
149
|
+
let attrMatch;
|
|
150
|
+
|
|
151
|
+
while ((attrMatch = attrRegex.exec(attrString))) {
|
|
152
|
+
const attrName = options.name(attrMatch[1]);
|
|
153
|
+
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4];
|
|
154
|
+
// Boolean attributes have no value (undefined)
|
|
155
|
+
attrs[attrName] = attrValue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
tag,
|
|
160
|
+
attrs: Object.keys(attrs).length > 0 ? attrs : null
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {string[]} input - Array of element specifications from `removeEmptyElementsExcept` option
|
|
166
|
+
* @param {MinifierOptions} options - Options object for parsing
|
|
167
|
+
* @returns {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} Array of parsed element specs
|
|
168
|
+
*/
|
|
169
|
+
function parseRemoveEmptyElementsExcept(input, options) {
|
|
170
|
+
if (!Array.isArray(input)) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return input.map(item => {
|
|
175
|
+
if (typeof item === 'string') {
|
|
176
|
+
const spec = parseElementSpec(item, options);
|
|
177
|
+
if (!spec && options.log) {
|
|
178
|
+
options.log('Warning: Unable to parse “removeEmptyElementsExcept” specification: "' + item + '"');
|
|
179
|
+
}
|
|
180
|
+
return spec;
|
|
181
|
+
}
|
|
182
|
+
if (options.log) {
|
|
183
|
+
options.log('Warning: “removeEmptyElementsExcept” specification must be a string, received: ' + typeof item);
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}).filter(Boolean);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {string} tag - Element tag name
|
|
191
|
+
* @param {HTMLAttribute[]} attrs - Array of element attributes
|
|
192
|
+
* @param {Array<{tag: string, attrs: Object.<string, string|undefined>|null}>} preserveList - Parsed preserve specs
|
|
193
|
+
* @returns {boolean} True if the empty element should be preserved
|
|
194
|
+
*/
|
|
195
|
+
function shouldPreserveEmptyElement(tag, attrs, preserveList) {
|
|
196
|
+
for (const spec of preserveList) {
|
|
197
|
+
// Tag name must match
|
|
198
|
+
if (spec.tag !== tag) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// If no attributes specified in spec, tag match is enough
|
|
203
|
+
if (!spec.attrs) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check if all specified attributes match
|
|
208
|
+
const allAttrsMatch = Object.entries(spec.attrs).every(([name, value]) => {
|
|
209
|
+
const attr = attrs.find(a => a.name === name);
|
|
210
|
+
if (!attr) {
|
|
211
|
+
return false; // Attribute not present
|
|
212
|
+
}
|
|
213
|
+
// Boolean attribute in spec (undefined value) matches if attribute is present
|
|
214
|
+
if (value === undefined) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
// Valued attribute must match exactly
|
|
218
|
+
return attr.value === value;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (allAttrsMatch) {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Exports
|
|
230
|
+
|
|
231
|
+
export {
|
|
232
|
+
// Tag omission
|
|
233
|
+
canRemoveParentTag,
|
|
234
|
+
isStartTagMandatory,
|
|
235
|
+
canRemovePrecedingTag,
|
|
236
|
+
|
|
237
|
+
// Element removal
|
|
238
|
+
canRemoveElement,
|
|
239
|
+
parseElementSpec,
|
|
240
|
+
parseRemoveEmptyElementsExcept,
|
|
241
|
+
shouldPreserveEmptyElement
|
|
242
|
+
};
|
package/src/lib/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Utils
|
|
2
|
+
export * from './utils.js';
|
|
3
|
+
|
|
4
|
+
// Constants
|
|
5
|
+
export * from './constants.js';
|
|
6
|
+
|
|
7
|
+
// Whitespace
|
|
8
|
+
export * from './whitespace.js';
|
|
9
|
+
|
|
10
|
+
// Attributes
|
|
11
|
+
export * from './attributes.js';
|
|
12
|
+
|
|
13
|
+
// Elements
|
|
14
|
+
export * from './elements.js';
|
|
15
|
+
|
|
16
|
+
// Content processors
|
|
17
|
+
export * from './content.js';
|
|
18
|
+
|
|
19
|
+
// Options
|
|
20
|
+
export * from './options.js';
|