html-minifier-next 7.5.3 → 7.6.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 +43 -4
- package/cli.js +27 -6
- package/dist/types/htmlminifier.d.ts +18 -0
- package/dist/types/htmlminifier.d.ts.map +1 -1
- package/dist/types/lib/constants.d.ts +2 -1
- package/dist/types/lib/constants.d.ts.map +1 -1
- package/dist/types/lib/elements.d.ts +1 -1
- package/dist/types/lib/option-definitions.d.ts +4 -0
- package/dist/types/lib/option-definitions.d.ts.map +1 -1
- package/dist/types/lib/options.d.ts +32 -4
- package/dist/types/lib/options.d.ts.map +1 -1
- package/dist/types/lib/unused-css.d.ts +42 -0
- package/dist/types/lib/unused-css.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +9 -1
- package/dist/types/lib/utils.d.ts.map +1 -1
- package/html-minifier-next.schema.json +7 -3
- package/package.json +2 -2
- package/src/htmlminifier.js +39 -26
- package/src/lib/attributes.js +2 -2
- package/src/lib/constants.js +2 -0
- package/src/lib/elements.js +3 -3
- package/src/lib/option-definitions.js +7 -3
- package/src/lib/options.js +127 -12
- package/src/lib/unused-css.js +394 -0
- package/src/lib/utils.js +23 -0
package/src/lib/attributes.js
CHANGED
|
@@ -470,7 +470,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
|
|
|
470
470
|
attrValue = attrValue.replace(/\s*;$/, ';');
|
|
471
471
|
}
|
|
472
472
|
const originalAttrValue = attrValue;
|
|
473
|
-
const cssResult = options.minifyCSS(attrValue, 'inline');
|
|
473
|
+
const cssResult = options.minifyCSS(attrValue, 'inline', options.cssContext);
|
|
474
474
|
if (isThenable(cssResult)) {
|
|
475
475
|
return cssResult
|
|
476
476
|
.then((/** @type {string} */ minified) => {
|
|
@@ -581,7 +581,7 @@ function cleanAttributeValue(tag, attrName, attrValue, options, attrs, minifyHTM
|
|
|
581
581
|
return attrValue;
|
|
582
582
|
}
|
|
583
583
|
const originalAttrValue = attrValue;
|
|
584
|
-
const cssResult = options.minifyCSS(attrValue, 'media');
|
|
584
|
+
const cssResult = options.minifyCSS(attrValue, 'media', options.cssContext);
|
|
585
585
|
if (isThenable(cssResult)) {
|
|
586
586
|
return cssResult.catch((/** @type {Error} */ err) => {
|
|
587
587
|
if (!options.continueOnMinifyError) throw err;
|
package/src/lib/constants.js
CHANGED
|
@@ -16,6 +16,7 @@ const RE_ESCAPE_LT = /</g;
|
|
|
16
16
|
const RE_ATTR_WS_CHECK = /[ \n\r\t\f]/;
|
|
17
17
|
const RE_ATTR_WS_COLLAPSE = /[ \n\r\t\f]+/g;
|
|
18
18
|
const RE_ATTR_WS_TRIM = /^[ \n\r\t\f]+|[ \n\r\t\f]+$/g;
|
|
19
|
+
const RE_STYLE_ELEMENT = /<style[\s/>]/i;
|
|
19
20
|
|
|
20
21
|
// Inline element sets for whitespace handling
|
|
21
22
|
|
|
@@ -192,6 +193,7 @@ export {
|
|
|
192
193
|
RE_ATTR_WS_CHECK,
|
|
193
194
|
RE_ATTR_WS_COLLAPSE,
|
|
194
195
|
RE_ATTR_WS_TRIM,
|
|
196
|
+
RE_STYLE_ELEMENT,
|
|
195
197
|
// Inline element sets
|
|
196
198
|
inlineElementsToKeepWhitespaceAround,
|
|
197
199
|
inlineElementsToKeepWhitespaceWithin,
|
package/src/lib/elements.js
CHANGED
|
@@ -137,7 +137,7 @@ function canRemoveElement(tag, attrs) {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
|
-
* @param {string} str - Tag name or HTML-like element spec (e.g.,
|
|
140
|
+
* @param {string} str - Tag name or HTML-like element spec (e.g., `td` or `<span aria-hidden='true'>`)
|
|
141
141
|
* @param {ProcessedOptions} options - Options object for name normalization
|
|
142
142
|
* @returns {{tag: string, attrs: Object.<string, string|undefined>|null}|null} Parsed spec or null if invalid
|
|
143
143
|
*/
|
|
@@ -203,12 +203,12 @@ function parseRemoveEmptyElementsExcept(input, options) {
|
|
|
203
203
|
if (typeof item === 'string') {
|
|
204
204
|
const spec = parseElementSpec(item, options);
|
|
205
205
|
if (!spec && options.log) {
|
|
206
|
-
options.log('Warning: Unable to parse
|
|
206
|
+
options.log('Warning: Unable to parse `removeEmptyElementsExcept` specification: “' + item + '”');
|
|
207
207
|
}
|
|
208
208
|
return spec;
|
|
209
209
|
}
|
|
210
210
|
if (options.log) {
|
|
211
|
-
options.log('Warning:
|
|
211
|
+
options.log('Warning: `removeEmptyElementsExcept` specification must be a string, received: ' + typeof item);
|
|
212
212
|
}
|
|
213
213
|
return null;
|
|
214
214
|
}).filter(Boolean));
|
|
@@ -104,15 +104,15 @@ const optionDefinitions = {
|
|
|
104
104
|
},
|
|
105
105
|
minifyCSS: {
|
|
106
106
|
description: 'Minify CSS in `style` elements and attributes (uses Lightning CSS)',
|
|
107
|
-
type: '
|
|
107
|
+
type: 'jsonObject'
|
|
108
108
|
},
|
|
109
109
|
minifyJS: {
|
|
110
110
|
description: 'Minify JavaScript in `script` elements and event attributes (uses Terser or SWC; pass `{"engine": "swc"}` for SWC)',
|
|
111
|
-
type: '
|
|
111
|
+
type: 'jsonObject'
|
|
112
112
|
},
|
|
113
113
|
minifySVG: {
|
|
114
114
|
description: 'Minify SVG elements (uses SVGO)',
|
|
115
|
-
type: '
|
|
115
|
+
type: 'jsonObject'
|
|
116
116
|
},
|
|
117
117
|
minifyURLs: {
|
|
118
118
|
description: 'Minify URLs in various attributes',
|
|
@@ -178,6 +178,10 @@ const optionDefinitions = {
|
|
|
178
178
|
description: 'Remove space between attributes whenever possible; note that this will result in invalid HTML',
|
|
179
179
|
type: 'boolean'
|
|
180
180
|
},
|
|
181
|
+
removeUnusedCSS: {
|
|
182
|
+
description: 'Remove rules from `style` elements whose class or ID selectors the document never references (requires `--minify-css`); note that class names only applied by external scripts cannot be detected—use `{"safelist": […]}` for those',
|
|
183
|
+
type: 'jsonObject'
|
|
184
|
+
},
|
|
181
185
|
sortAttributes: {
|
|
182
186
|
description: 'Sort attributes by frequency',
|
|
183
187
|
type: 'boolean'
|
package/src/lib/options.js
CHANGED
|
@@ -3,6 +3,7 @@ import { LRU, MAX_CACHE_ENTRY_SIZE, stableStringify, hashContent, identity, lowe
|
|
|
3
3
|
import { RE_TRAILING_SEMICOLON } from './constants.js';
|
|
4
4
|
import { canCollapseWhitespace, canTrimWhitespace } from './whitespace.js';
|
|
5
5
|
import { wrapCSS, unwrapCSS } from './content.js';
|
|
6
|
+
import { findUnusedSymbols, normalizeUnusedCSSOptions } from './unused-css.js';
|
|
6
7
|
import { getPreset, getPresetNames } from '../presets.js';
|
|
7
8
|
import { optionDefinitions, optionDefaults } from './option-definitions.js';
|
|
8
9
|
|
|
@@ -10,6 +11,21 @@ import { optionDefinitions, optionDefaults } from './option-definitions.js';
|
|
|
10
11
|
|
|
11
12
|
// Type definitions
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Per-document state handed to `minifyCSS`. Its closure hangs off the memoized
|
|
16
|
+
* options object that every `minify()` call with those options shares, so state
|
|
17
|
+
* belonging to one document has to be passed in rather than captured.
|
|
18
|
+
*
|
|
19
|
+
* @typedef {{usedSymbols?: Set<string>, warned: Set<string>}} CSSContext
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Minified style sheet plus the warnings its transform produced, cached together
|
|
24
|
+
* so that a cache hit can report what the transform reported
|
|
25
|
+
*
|
|
26
|
+
* @typedef {{css: string, warnings: string[]}} CSSResult
|
|
27
|
+
*/
|
|
28
|
+
|
|
13
29
|
/**
|
|
14
30
|
* Options object produced by `processOptions` and consumed by `minifyHTML` and
|
|
15
31
|
* the `lib/` helpers; normalization guarantees that the function-valued options
|
|
@@ -17,16 +33,18 @@ import { optionDefinitions, optionDefaults } from './option-definitions.js';
|
|
|
17
33
|
* minification adds writable internal state on top of the public options
|
|
18
34
|
* (set on prototype-chain forks during SVG/MathML namespace transitions)
|
|
19
35
|
*
|
|
20
|
-
* @typedef {Omit<MinifierOptions, 'preset' | 'canCollapseWhitespace' | 'canTrimWhitespace' | 'ignoreCustomComments' | 'log' | 'minifyCSS' | 'minifyJS' | 'minifyURLs' | 'minifySVG'> & {
|
|
36
|
+
* @typedef {Omit<MinifierOptions, 'preset' | 'canCollapseWhitespace' | 'canTrimWhitespace' | 'ignoreCustomComments' | 'log' | 'minifyCSS' | 'minifyJS' | 'minifyURLs' | 'minifySVG' | 'removeUnusedCSS'> & {
|
|
21
37
|
* name: (name: string) => string,
|
|
22
38
|
* log: (message: any) => unknown,
|
|
23
39
|
* ignoreCustomComments: RegExp[],
|
|
24
40
|
* canCollapseWhitespace: (tag: string, attrs: HTMLAttribute[], defaultFn: (tag: string) => boolean) => boolean,
|
|
25
41
|
* canTrimWhitespace: (tag: string, attrs: HTMLAttribute[], defaultFn: (tag: string) => boolean) => boolean,
|
|
26
|
-
* minifyCSS: (text: string, type?: string) => string | Promise<string>,
|
|
42
|
+
* minifyCSS: (text: string, type?: string, context?: CSSContext) => string | Promise<string>,
|
|
27
43
|
* minifyJS: (text: string, inline?: boolean, isModule?: boolean) => string | Promise<string>,
|
|
28
44
|
* minifyURLs: (text: string) => string | Promise<string>,
|
|
29
45
|
* minifySVG: ((svgContent: string) => string | Promise<string>) | null,
|
|
46
|
+
* removeUnusedCSS: {safelist: Array<string | RegExp>, scripts: boolean} | null,
|
|
47
|
+
* cssContext?: CSSContext,
|
|
30
48
|
* nameParent?: (name: string) => string,
|
|
31
49
|
* nameHTML?: (name: string) => string,
|
|
32
50
|
* insideSVG?: boolean,
|
|
@@ -82,6 +100,9 @@ const presetNamesWarned = new Set();
|
|
|
82
100
|
// The custom-fragment ReDoS warning is security-relevant, so it reaches the
|
|
83
101
|
// console even without a `log` hook—once per process, like the warnings above
|
|
84
102
|
let customFragmentQuantifierWarned = false;
|
|
103
|
+
const unusedCSSWarned = new Set();
|
|
104
|
+
// Object-valued options handed a string, warned about once per distinct value
|
|
105
|
+
const stringValuesWarned = new Set();
|
|
85
106
|
|
|
86
107
|
// Main options processor
|
|
87
108
|
|
|
@@ -101,7 +122,8 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
101
122
|
minifyCSS: identity,
|
|
102
123
|
minifyJS: identity,
|
|
103
124
|
minifyURLs: identity,
|
|
104
|
-
minifySVG: null
|
|
125
|
+
minifySVG: null,
|
|
126
|
+
removeUnusedCSS: null
|
|
105
127
|
};
|
|
106
128
|
|
|
107
129
|
const parseRegExpArray = (/** @type {unknown} */ arr) => {
|
|
@@ -136,7 +158,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
136
158
|
Object.keys(inputOptions).forEach(function (key) {
|
|
137
159
|
if (!Object.hasOwn(optionDefinitions, key) && !optionKeysExtra.has(key) && !optionKeysWarned.has(key)) {
|
|
138
160
|
optionKeysWarned.add(key);
|
|
139
|
-
warn(`HTML Minifier Next: Ignoring unknown or deprecated option
|
|
161
|
+
warn(`HTML Minifier Next: Ignoring unknown or deprecated option \`${key}\` (see README for available options)`);
|
|
140
162
|
}
|
|
141
163
|
});
|
|
142
164
|
|
|
@@ -166,10 +188,30 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
166
188
|
return;
|
|
167
189
|
}
|
|
168
190
|
|
|
191
|
+
// A string carries no configuration for these options. The CLI parses config
|
|
192
|
+
// values as JSON first, so only a value that is not JSON reaches this from
|
|
193
|
+
// there. (`minifyURLs` is deliberately excluded—there, a string names the site.)
|
|
194
|
+
const definition = /** @type {Record<string, {type?: string}>} */ (optionDefinitions)[key];
|
|
195
|
+
if (typeof option === 'string' && definition?.type === 'jsonObject') {
|
|
196
|
+
const message = `HTML Minifier Next: Ignoring \`${key}\`—it takes a boolean or an object, not a string (“${option}”)`;
|
|
197
|
+
if (!stringValuesWarned.has(message)) {
|
|
198
|
+
stringValuesWarned.add(message);
|
|
199
|
+
warn(message);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
169
204
|
if (key === 'caseSensitive') {
|
|
170
205
|
if (option) {
|
|
171
206
|
options.name = identity;
|
|
172
207
|
}
|
|
208
|
+
} else if (key === 'removeUnusedCSS') {
|
|
209
|
+
optionsDynamic.removeUnusedCSS = normalizeUnusedCSSOptions(option, message => {
|
|
210
|
+
if (!unusedCSSWarned.has(message)) {
|
|
211
|
+
unusedCSSWarned.add(message);
|
|
212
|
+
warn(`HTML Minifier Next: ${message}`);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
173
215
|
} else if (key === 'log') {
|
|
174
216
|
if (typeof option === 'function') {
|
|
175
217
|
options.log = option;
|
|
@@ -184,12 +226,34 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
184
226
|
const cssLoader = getLightningCSS;
|
|
185
227
|
const cssCache = cssMinifyCache;
|
|
186
228
|
|
|
187
|
-
options.minifyCSS = async function (/** @type {string} */ text, /** @type {string | undefined} */ type) {
|
|
229
|
+
options.minifyCSS = async function (/** @type {string} */ text, /** @type {string | undefined} */ type, /** @type {CSSContext | undefined} */ context) {
|
|
188
230
|
// Fast path: Nothing to minify
|
|
189
231
|
if (!text || !text.trim()) {
|
|
190
232
|
return text;
|
|
191
233
|
}
|
|
192
234
|
|
|
235
|
+
// Warnings are stored with the minified result and replayed on every hit, so a
|
|
236
|
+
// second document with the same defect hears about it, too; `context.warned`
|
|
237
|
+
// then keeps one document from repeating itself. Reporting from the cache
|
|
238
|
+
// rather than only from the transform keeps the output independent of cache
|
|
239
|
+
// size and eviction. They are built and cached even when `log` is the default
|
|
240
|
+
// no-op, so a later document that does pass a `log` hook still gets them.
|
|
241
|
+
const report = (/** @type {string[]} */ messages) => {
|
|
242
|
+
if (!messages.length || options.log === identity) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const warned = context?.warned;
|
|
246
|
+
for (const message of messages) {
|
|
247
|
+
if (warned) {
|
|
248
|
+
if (warned.has(message)) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
warned.add(message);
|
|
252
|
+
}
|
|
253
|
+
options.log(message);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
193
257
|
// Optimization: Only process URLs if minification is enabled (not identity function)
|
|
194
258
|
// This avoids expensive `replaceAsync` when URL minification is disabled
|
|
195
259
|
if (options.minifyURLs !== identity) {
|
|
@@ -213,9 +277,22 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
213
277
|
);
|
|
214
278
|
}
|
|
215
279
|
|
|
216
|
-
//
|
|
280
|
+
// Unused-symbol removal applies to style sheets only
|
|
281
|
+
const unusedCSSConfig = type === undefined ? options.removeUnusedCSS : undefined;
|
|
282
|
+
const unusedSymbols = (unusedCSSConfig && context?.usedSymbols)
|
|
283
|
+
? findUnusedSymbols(text, context.usedSymbols, unusedCSSConfig.safelist)
|
|
284
|
+
: undefined;
|
|
285
|
+
|
|
286
|
+
// Cache key: Content + type + options signature; large inputs are hashed to avoid huge Map keys.
|
|
287
|
+
// The symbol list belongs in the signature: The cache outlives a single `minify()` call, so
|
|
288
|
+
// identical style sheets in differently marked-up documents must not share an entry.
|
|
217
289
|
const inputCSS = wrapCSS(text, type);
|
|
218
|
-
const cssSig = stableStringify({
|
|
290
|
+
const cssSig = stableStringify({
|
|
291
|
+
type,
|
|
292
|
+
opts: lightningCssOptions,
|
|
293
|
+
cont: !!options.continueOnMinifyError,
|
|
294
|
+
unused: unusedSymbols && unusedSymbols.length ? unusedSymbols.slice().sort() : undefined
|
|
295
|
+
});
|
|
219
296
|
const isCacheable = inputCSS.length <= MAX_CACHE_ENTRY_SIZE;
|
|
220
297
|
const cssKey = isCacheable
|
|
221
298
|
? (inputCSS.length > 2048
|
|
@@ -225,10 +302,12 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
225
302
|
|
|
226
303
|
try {
|
|
227
304
|
if (cssKey !== undefined) {
|
|
228
|
-
const cached = /** @type {
|
|
305
|
+
const cached = /** @type {CSSResult | Promise<CSSResult> | undefined} */ (cssCache.get(cssKey));
|
|
229
306
|
if (cached !== undefined) {
|
|
230
307
|
// Support both resolved values and in-flight promises
|
|
231
|
-
|
|
308
|
+
const settled = await cached;
|
|
309
|
+
report(settled.warnings);
|
|
310
|
+
return settled.css;
|
|
232
311
|
}
|
|
233
312
|
}
|
|
234
313
|
|
|
@@ -242,9 +321,26 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
242
321
|
code: Buffer.from(inputCSS),
|
|
243
322
|
minify: true,
|
|
244
323
|
errorRecovery: !!options.continueOnMinifyError,
|
|
245
|
-
...lightningCssOptions
|
|
324
|
+
...lightningCssOptions,
|
|
325
|
+
// Union, so that a manually supplied `unusedSymbols` list survives
|
|
326
|
+
...(unusedSymbols && unusedSymbols.length
|
|
327
|
+
? { unusedSymbols: lightningCssOptions.unusedSymbols ? [...new Set([...lightningCssOptions.unusedSymbols, ...unusedSymbols])] : unusedSymbols }
|
|
328
|
+
: {})
|
|
246
329
|
});
|
|
247
330
|
|
|
331
|
+
// With `errorRecovery` enabled, Lightning CSS reports what it takes issue
|
|
332
|
+
// with instead of throwing—dropping the rule in some cases (`@property`
|
|
333
|
+
// with a bad `syntax`) and passing it through in others (an unknown
|
|
334
|
+
// at-rule), which is why the wording stops at “reported”
|
|
335
|
+
/** @type {string[]} */
|
|
336
|
+
const warnings = [];
|
|
337
|
+
if (result.warnings) {
|
|
338
|
+
for (const warning of result.warnings) {
|
|
339
|
+
const at = warning.loc ? ` (line ${warning.loc.line}, column ${warning.loc.column})` : '';
|
|
340
|
+
warnings.push(`Warning: Lightning CSS reported invalid CSS${at}: ${warning.message}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
248
344
|
const outputCSS = unwrapCSS(result.code.toString(), type);
|
|
249
345
|
|
|
250
346
|
// If Lightning CSS removed significant content that looks like template syntax or UIDs, return original
|
|
@@ -259,13 +355,15 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
259
355
|
|
|
260
356
|
// Preserve if output is empty and input had template syntax or UIDs
|
|
261
357
|
// This catches cases where Lightning CSS removed content that should be preserved
|
|
262
|
-
|
|
358
|
+
const css = (text.trim() && !outputCSS.trim() && (looksLikeTemplate || hasUID)) ? text : outputCSS;
|
|
359
|
+
return { css, warnings };
|
|
263
360
|
})();
|
|
264
361
|
|
|
265
362
|
if (cssKey !== undefined) cssCache.set(cssKey, inFlight);
|
|
266
363
|
const resolved = await inFlight;
|
|
267
364
|
if (cssKey !== undefined) cssCache.set(cssKey, resolved);
|
|
268
|
-
|
|
365
|
+
report(resolved.warnings);
|
|
366
|
+
return resolved.css;
|
|
269
367
|
} catch (err) {
|
|
270
368
|
if (cssKey !== undefined) cssCache.delete(cssKey);
|
|
271
369
|
if (!options.continueOnMinifyError) {
|
|
@@ -515,6 +613,23 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
|
|
|
515
613
|
optionsDynamic[key] = option;
|
|
516
614
|
}
|
|
517
615
|
});
|
|
616
|
+
|
|
617
|
+
// Unused-CSS removal rides along with Lightning CSS, so it silently does nothing
|
|
618
|
+
// when `minifyCSS` is off or replaced by a function—say so rather than let it pass
|
|
619
|
+
if (options.removeUnusedCSS) {
|
|
620
|
+
const cssOption = /** @type {Record<string, any>} */ (effectiveInput).minifyCSS;
|
|
621
|
+
const reason = typeof cssOption === 'function'
|
|
622
|
+
? 'it does not apply when `minifyCSS` is a function'
|
|
623
|
+
: (options.minifyCSS === identity ? 'it requires `minifyCSS` (`--minify-css`)' : '');
|
|
624
|
+
if (reason) {
|
|
625
|
+
if (!unusedCSSWarned.has(reason)) {
|
|
626
|
+
unusedCSSWarned.add(reason);
|
|
627
|
+
warn(`HTML Minifier Next: Ignoring \`removeUnusedCSS\`—${reason}`);
|
|
628
|
+
}
|
|
629
|
+
options.removeUnusedCSS = null;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
518
633
|
return options;
|
|
519
634
|
};
|
|
520
635
|
|