html-minifier-next 8.5.0 → 8.5.2

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 CHANGED
@@ -151,18 +151,18 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
151
151
  | `customEventAttributes`<br>`--custom-event-attributes` | Array of regexes that allow to support custom event attributes (e.g., `ng-click`)—use with `minifyJS` | `[ /^on[a-z]{3,}$/ ]` |
152
152
  | `decodeEntities`<br>`--decode-entities` | Use direct Unicode characters whenever possible | `false` |
153
153
  | `ignoreCustomComments`<br>`--ignore-custom-comments` | Array of regexes that allow to ignore matching comments | `[ /^!/, /^\s*#/ ]` |
154
- | `ignoreCustomFragments`<br>`--ignore-custom-fragments` | Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php … ?>`, `{{ … }}`, etc.) | `[ /<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/ ]` |
154
+ | `ignoreCustomFragments`<br>`--ignore-custom-fragments` | Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php … ?>`, `{{ … }}`, etc.; see [“Minifying server-side templates”](#minifying-server-side-templates)) | `[ /<%[\s\S]*?%>/, /<\?[\s\S]*?\?>/ ]` |
155
155
  | `includeAutoGeneratedTags`<br>`--include-auto-generated-tags` | Insert elements generated by HTML parser | `false` |
156
156
  | `inlineCustomElements`<br>`--inline-custom-elements` | Array of names of custom elements which are inline, for whitespace handling—use with `collapseWhitespace` | `[]` |
157
157
  | `keepClosingSlash`<br>`--keep-closing-slash` | Keep the trailing slash on void elements and other start tags that carry one, and read it as closing the element | `false` |
158
158
  | `maxInputLength`<br>`--max-input-length` | Maximum input length to prevent ReDoS attacks (disabled by default) | `undefined` |
159
- | `maxLineLength`<br>`--max-line-length` | Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points | `undefined` |
159
+ | `maxLineLength`<br>`--max-line-length` | Specify a maximum line length; output will be split by newlines in tags only (between attributes or before a tag’s `>`), where they can’t change the page, so lines may run longer | `undefined` |
160
160
  | `mergeScripts`<br>`--merge-scripts` | Merge consecutive inline `script` elements into one (only merges compatible scripts with same `type`, matching `async`/`defer`/`nomodule`/`nonce`) | `false` |
161
161
  | `minifyCSS`<br>`--minify-css` | Minify CSS in `style` elements and attributes (uses [Lightning CSS](https://lightningcss.dev/)) | `false` (could be `true`, `Object`, `Function(text, type)`) |
162
162
  | `minifyJS`<br>`--minify-js` | Minify JavaScript in `script` elements and event attributes (uses [Terser](https://terser.org/) or [SWC](https://swc.rs/)) | `false` (could be `true`, `Object`, `Function(text, inline)`) |
163
163
  | `minifySVG`<br>`--minify-svg` | Minify SVG elements (uses [SVGO](https://svgo.dev/)) | `false` (could be `true`, `Object`) |
164
164
  | `minifyURLs`<br>`--minify-urls` | Minify URLs in various attributes | `false` (could be `true`, `String`, `Object`, `Function(text)`) |
165
- | `noNewlinesBeforeTagClose`<br>`--no-newlines-before-tag-close` | Never add a newline before a tag that closes an element—use with `maxLineLength` | `false` |
165
+ | `noNewlinesBeforeTagClose`<br>`--no-newlines-before-tag-close` | Never split a line in a tag that closes an element—use with `maxLineLength` | `false` |
166
166
  | `partialMarkup`<br>`--partial-markup` | Treat input as a partial HTML fragment, preserving stray end tags (closing tags without opening tags) and preventing auto-closing of unclosed tags at end of input | `false` |
167
167
  | `preserveLineBreaks`<br>`--preserve-line-breaks` | Always collapse to one line break (never remove it entirely) when whitespace between tags includes a line break—use with [`collapseWhitespace`](#combining-whitespace-options) | `false` |
168
168
  | `preventAttributesEscaping`<br>`--prevent-attributes-escaping` | Prevents the escaping of the values of attributes | `false` |
@@ -181,7 +181,7 @@ Options can be used in config files (camelCase) or via CLI flags (kebab-case wit
181
181
  | `sortAttributes`<br>`--sort-attributes` | [Sort attributes by frequency](#sorting-attributes-and-style-classes) | `false` |
182
182
  | `sortClassNames`<br>`--sort-class-names` | [Sort style classes by frequency](#sorting-attributes-and-style-classes) | `false` |
183
183
  | `strictCustomFragments`<br>`--strict-custom-fragments` | [Reject `ignoreCustomFragments` patterns that risk catastrophic backtracking](#redos-protection) (rather than warning about them) | `false` |
184
- | `trimCustomFragments`<br>`--trim-custom-fragments` | Trim whitespace around custom fragments (`ignoreCustomFragments`)—use with `collapseWhitespace` | `false` |
184
+ | `trimCustomFragments`<br>`--trim-custom-fragments` | Trim whitespace around custom fragments (`ignoreCustomFragments`) where it does not affect the output (see [“Minifying server-side templates”](#minifying-server-side-templates))—use with `collapseWhitespace` | `false` |
185
185
  | `useShortDoctype`<br>`--use-short-doctype` | [Replaces the doctype with the short HTML doctype](https://perfectionkills.com/experimenting-with-html-minifier/#use_short_doctype) | `false` |
186
186
 
187
187
  ### API-only options
@@ -680,6 +680,14 @@ For partial HTML fragments (such as template includes, SSI fragments, or closing
680
680
 
681
681
  To validate complete HTML markup, use [the W3C validator](https://validator.w3.org/) or one of [several validator packages](https://meiert.com/blog/html-validator-packages/).
682
682
 
683
+ ### Minifying server-side templates
684
+
685
+ By default, `ignoreCustomFragments` preserves `<? … ?>` and `<% … %>` blocks, so PHP, ERB, JSP, and ASP templates can be minified as part of a build, with the markup, CSS, and JavaScript around those blocks being minified as usual. For includes and other partials, add `partialMarkup`.
686
+
687
+ Whitespace next to template blocks is collapsed but kept, as it may matter once the template renders. `trimCustomFragments` removes it where HTML Minifier Next would remove whitespace anyway, as between block-level elements: `<ul> <?php foreach ($items as $item): ?> <li>` loses it, `Hello <?= $name ?>!` keeps it. Tags keep the spacing of their source, and whitespace next to template blocks in attribute values, CSS, and JavaScript is always kept.
688
+
689
+ Where pages are rendered ahead of serving, as with static exports or full-page caches, you can also minify the rendered HTML instead, which makes this a non-issue.
690
+
683
691
  ### Regex options and flags
684
692
 
685
693
  `customAttrAssign` and `customAttrSurround` patterns are merged into one attribute pattern which carries no flags of its own. `i` and `s` are written into each pattern’s source instead, so they survive the merge. `u`, `v`, and `m` cannot be, and none of them fails loudly when dropped: `u` and `v` only narrow what syntax is legal, so a source valid under either stays valid without it and quietly matches something else—a dropped `u` leaves `\p{L}` matching the literal text `p{L}`—while a dropped `m` leaves `^` and `$` matching at the ends of the input rather than of each line.
@@ -244,7 +244,8 @@ export type MinifierOptions = {
244
244
  maxInputLength?: number;
245
245
  /**
246
246
  * Maximum line length for the output. When set the minifier will wrap
247
- * output to the given number of characters where possible.
247
+ * output to the given number of characters where possible, splitting
248
+ * lines only in tags—between attributes or before a tag’s `>`.
248
249
  *
249
250
  * Default: No limit
250
251
  */
@@ -308,8 +309,8 @@ export type MinifierOptions = {
308
309
  */
309
310
  minifySVG?: boolean | Object;
310
311
  /**
311
- * When wrapping lines, prevent inserting a newline directly before a
312
- * closing tag (useful to keep tags like `</a>` on the same line).
312
+ * When wrapping lines, never split a line in an end tag (e.g., keeps
313
+ * `</a>` from becoming `</a` and `>` on two lines).
313
314
  *
314
315
  * Default: `false`
315
316
  */
@@ -492,9 +493,10 @@ export type MinifierOptions = {
492
493
  */
493
494
  sortClassNames?: boolean | ((value: string) => string);
494
495
  /**
495
- * When true, whitespace around ignored custom fragments may be trimmed
496
- * more aggressively. This affects how preserved fragments interact with
497
- * surrounding whitespace collapse.
496
+ * When true, whitespace around ignored custom fragments is removed rather
497
+ * than collapsed where the minifier removes whitespace anyway (e.g., between
498
+ * block-level elements). Whitespace in tags, attribute values, CSS, and
499
+ * JavaScript is kept.
498
500
  *
499
501
  * Default: `false`
500
502
  */
@@ -1 +1 @@
1
- {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"htmlminifier.d.ts","sourceRoot":"","sources":["../../src/htmlminifier.js"],"names":[],"mappings":"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"}
@@ -11,7 +11,6 @@ export type HTMLParserHandler = {
11
11
  customAttrSurround?: RegExp[][] | undefined;
12
12
  customAttrAssign?: RegExp[] | undefined;
13
13
  };
14
- export declare const endTag: RegExp;
15
14
  export declare class HTMLParser {
16
15
  html: string;
17
16
  handler: HTMLParserHandler;
@@ -1 +1 @@
1
- {"version":3,"file":"htmlparser.d.ts","sourceRoot":"","sources":["../../src/htmlparser.js"],"names":[],"mappings":"AAeG,YAYG,iBAAiB,GAZV;IACR,KAAK,CAAC,WAAW;IACjB,GAAG,CAAC,WAAW;IACf,KAAK,CAAC,WAAW;IACjB,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,WAAW;IACnB,oBAAoB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3C,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,YAAY,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACnC,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,kBAAkB,CAAC,EAAE,MAAM,EAAE,EAAE,GAAG,SAAS,CAAC;IAC5C,gBAAgB,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAA;CACxC,CAAmB;AA0CvB,eAAO,MAAM,MAAM,QAA8C,CAAC;AAoLlE,qBAAa,UAAU;IAMd,IAAI;IACJ,OAAO;IANd;;;OAGG;IACH,YAAY,IAAI,EAHL,MAGK,EAAE,OAAO,EAFd,iBAEc,EAGxB;IAEK,KAAK,kBAwyBV;CACF"}
1
+ {"version":3,"file":"htmlparser.d.ts","sourceRoot":"","sources":["../../src/htmlparser.js"],"names":[],"mappings":"AAeG,YAYG,iBAAiB,GAZV;IACR,KAAK,CAAC,WAAW;IACjB,GAAG,CAAC,WAAW;IACf,KAAK,CAAC,WAAW;IACjB,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,WAAW;IACnB,oBAAoB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3C,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,YAAY,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACnC,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,kBAAkB,CAAC,EAAE,MAAM,EAAE,EAAE,GAAG,SAAS,CAAC;IAC5C,gBAAgB,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAA;CACxC,CAAmB;AA6NvB,qBAAa,UAAU;IAMd,IAAI;IACJ,OAAO;IANd;;;OAGG;IACH,YAAY,IAAI,EAHL,MAGK,EAAE,OAAO,EAFd,iBAEc,EAGxB;IAEK,KAAK,kBA2yBV;CACF"}
@@ -132,7 +132,7 @@
132
132
  }
133
133
  },
134
134
  "ignoreCustomFragments": {
135
- "description": "Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php … ?>`, `{{ … }}`)",
135
+ "description": "Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php … ?>`, `{{ … }}`, etc.)",
136
136
  "type": [
137
137
  "string",
138
138
  "array"
@@ -165,7 +165,7 @@
165
165
  "minimum": 0
166
166
  },
167
167
  "maxLineLength": {
168
- "description": "Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points",
168
+ "description": "Specify a maximum line length; output will be split by newlines in tags only (between attributes or before a tag’s `>`), where they can’t change the page, so lines may run longer",
169
169
  "type": "integer",
170
170
  "minimum": 0
171
171
  },
@@ -203,7 +203,7 @@
203
203
  ]
204
204
  },
205
205
  "noNewlinesBeforeTagClose": {
206
- "description": "Never add a newline before a tag that closes an element—use with `--max-line-length`",
206
+ "description": "Never split a line in a tag that closes an element—use with `--max-line-length`",
207
207
  "type": "boolean"
208
208
  },
209
209
  "partialMarkup": {
@@ -253,7 +253,7 @@
253
253
  "type": "boolean"
254
254
  },
255
255
  "removeEmptyElementsExcept": {
256
- "description": "Array of elements to preserve (e.g., `td`, `<span aria-hidden=\"true\">`)—use with `--remove-empty-elements`",
256
+ "description": "Array of elements to preserve (e.g., `td`, `<span aria-hidden=\"true\">`, etc.)—use with `--remove-empty-elements`",
257
257
  "type": [
258
258
  "string",
259
259
  "array"
@@ -290,7 +290,7 @@
290
290
  "type": "boolean"
291
291
  },
292
292
  "trimCustomFragments": {
293
- "description": "Trim whitespace around custom fragments (`--ignore-custom-fragments`)—use with `--collapse-whitespace`",
293
+ "description": "Trim whitespace around custom fragments (`--ignore-custom-fragments`) where it does not affect the output—use with `--collapse-whitespace`",
294
294
  "type": "boolean"
295
295
  },
296
296
  "strictCustomFragments": {
package/package.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "@commitlint/cli": "^21.2.2",
18
18
  "@eslint/js": "^10.0.1",
19
19
  "@swc/core": "^1.16.2",
20
- "@types/node": "^26.5.1",
20
+ "@types/node": "^26.6.1",
21
21
  "eslint": "^10.10.0",
22
22
  "globals": "^17.12.0",
23
23
  "typescript": "^7.0.2",
@@ -95,5 +95,5 @@
95
95
  },
96
96
  "type": "module",
97
97
  "types": "dist/types/htmlminifier.d.ts",
98
- "version": "8.5.0"
98
+ "version": "8.5.2"
99
99
  }
@@ -1,4 +1,4 @@
1
- import { HTMLParser, endTag } from './htmlparser.js';
1
+ import { HTMLParser } from './htmlparser.js';
2
2
  import TokenChain from './tokenchain.js';
3
3
  import { presets, getPreset, getPresetNames } from './presets.js';
4
4
 
@@ -297,7 +297,8 @@ import { toFragment, replaceCustomFragments } from './lib/fragments.js';
297
297
  *
298
298
  * @prop {number} [maxLineLength]
299
299
  * Maximum line length for the output. When set the minifier will wrap
300
- * output to the given number of characters where possible.
300
+ * output to the given number of characters where possible, splitting
301
+ * lines only in tags—between attributes or before a tag’s `>`.
301
302
  *
302
303
  * Default: No limit
303
304
  *
@@ -350,8 +351,8 @@ import { toFragment, replaceCustomFragments } from './lib/fragments.js';
350
351
  * Default: `false`
351
352
  *
352
353
  * @prop {boolean} [noNewlinesBeforeTagClose]
353
- * When wrapping lines, prevent inserting a newline directly before a
354
- * closing tag (useful to keep tags like `</a>` on the same line).
354
+ * When wrapping lines, never split a line in an end tag (e.g., keeps
355
+ * `</a>` from becoming `</a` and `>` on two lines).
355
356
  *
356
357
  * Default: `false`
357
358
  *
@@ -512,9 +513,10 @@ import { toFragment, replaceCustomFragments } from './lib/fragments.js';
512
513
  * Default: `false`
513
514
  *
514
515
  * @prop {boolean} [trimCustomFragments]
515
- * When true, whitespace around ignored custom fragments may be trimmed
516
- * more aggressively. This affects how preserved fragments interact with
517
- * surrounding whitespace collapse.
516
+ * When true, whitespace around ignored custom fragments is removed rather
517
+ * than collapsed where the minifier removes whitespace anyway (e.g., between
518
+ * block-level elements). Whitespace in tags, attribute values, CSS, and
519
+ * JavaScript is kept.
518
520
  *
519
521
  * Default: `false`
520
522
  *
@@ -623,6 +625,7 @@ const DEFAULT_JS_TYPES = new Set(['', 'text/javascript', 'application/javascript
623
625
  const RE_START_TAG = /^<[^/!]/;
624
626
  const RE_END_TAG = /^<\//;
625
627
  const RE_WHITESPACE_ONLY = /^\s+$/;
628
+ const RE_TABS = /\t/g;
626
629
  const RE_TRAILING_WHITESPACE = /\s$/;
627
630
 
628
631
  // Pre-compiled patterns for `htmlmin:ignore` block content analysis
@@ -1045,6 +1048,8 @@ async function minifyHTML(value, options, partialMarkup) {
1045
1048
 
1046
1049
  /** @type {string[]} */
1047
1050
  const buffer = [];
1051
+ // Parallel to `buffer`: whether an entry belongs to a start or end tag, the only place lines may break
1052
+ const bufferTags = options.maxLineLength ? /** @type {boolean[]} */ ([]) : null;
1048
1053
  /** @type {string} */
1049
1054
  let charsPrevTag = '';
1050
1055
  let currentChars = '';
@@ -1065,6 +1070,15 @@ async function minifyHTML(value, options, partialMarkup) {
1065
1070
  const ignoredMarkupChunks = [];
1066
1071
  /** @type {Array<RegExpExecArray | null>} */
1067
1072
  const ignoredCustomMarkupChunks = [];
1073
+ // Custom fragments whose surrounding whitespace is never trimmed: in tags, where it
1074
+ // separates attributes, and in CSS and JavaScript, where minifiers can’t judge it
1075
+ /** @type {Set<number>} */
1076
+ const customMarkupInTags = new Set();
1077
+ /** @type {Set<number>} */
1078
+ const customMarkupInCode = new Set();
1079
+ // Attributes joined with the custom fragments they run into, to be written as they are
1080
+ /** @type {WeakSet<HTMLAttribute>} */
1081
+ const opaqueAttrs = new WeakSet();
1068
1082
  /** @type {string | undefined} */
1069
1083
  let uidIgnore;
1070
1084
  /** @type {RegExp | undefined} */
@@ -1075,6 +1089,8 @@ async function minifyHTML(value, options, partialMarkup) {
1075
1089
  let uidPattern;
1076
1090
  /** @type {RegExp | undefined} */
1077
1091
  let uidAttrLeadingPattern;
1092
+ /** @type {RegExp | undefined} */
1093
+ let uidAttrExactPattern;
1078
1094
  // Create inline tags/text sets with custom elements
1079
1095
  const customElementsInput = options.inlineCustomElements ?? [];
1080
1096
  const customElementsArr = Array.isArray(customElementsInput) ? customElementsInput : Array.from(customElementsInput);
@@ -1149,12 +1165,14 @@ async function minifyHTML(value, options, partialMarkup) {
1149
1165
  uidAttr = uniqueId(value);
1150
1166
  uidPattern = new RegExp('(\\s*)' + uidAttr + '([0-9]+)' + uidAttr + '(\\s*)', 'g');
1151
1167
  uidAttrLeadingPattern = new RegExp('^\\s*' + uidAttr + '(\\d+)' + uidAttr);
1168
+ uidAttrExactPattern = new RegExp('^' + uidAttr + '(\\d+)' + uidAttr + '$');
1152
1169
 
1153
1170
  if (options.minifyCSS !== identity) {
1154
1171
  options.minifyCSS = (function (/** @type {ProcessedOptions['minifyCSS']} */ fn) {
1155
1172
  return function (/** @type {string} */ text, /** @type {string | undefined} */ type, /** @type {ProcessedOptions['cssContext']} */ context) {
1156
1173
  text = text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1157
1174
  const chunks = ignoredCustomMarkupChunks[+index];
1175
+ customMarkupInCode.add(+index);
1158
1176
  return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1159
1177
  });
1160
1178
 
@@ -1168,6 +1186,7 @@ async function minifyHTML(value, options, partialMarkup) {
1168
1186
  return function (/** @type {string} */ text, /** @type {boolean | undefined} */ inline, /** @type {boolean | undefined} */ isModule) {
1169
1187
  return fn(text.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ _match, /** @type {string} */ _prefix, /** @type {string} */ index) {
1170
1188
  const chunks = ignoredCustomMarkupChunks[+index];
1189
+ customMarkupInCode.add(+index);
1171
1190
  return (chunks?.[1] ?? '') + uidAttr + index + uidAttr + (chunks?.[2] ?? '');
1172
1191
  }), inline, isModule);
1173
1192
  };
@@ -1183,6 +1202,77 @@ async function minifyHTML(value, options, partialMarkup) {
1183
1202
  value = replaceCustomFragments(value, customFragments, replaceFragment);
1184
1203
  }
1185
1204
 
1205
+ // Record the custom fragments in (part of) a tag
1206
+ function markTagFragments(/** @type {string} */ str) {
1207
+ for (const uidMatch of str.matchAll(/** @type {RegExp} */ (uidPattern))) {
1208
+ customMarkupInTags.add(+(uidMatch[2] ?? 0));
1209
+ }
1210
+ }
1211
+
1212
+ // Source text of an attribute, as far as it matters
1213
+ function rawAttr(/** @type {HTMLAttribute} */ attr) {
1214
+ if (opaqueAttrs.has(attr)) {
1215
+ return attr.name;
1216
+ }
1217
+ let raw = attr.name;
1218
+ // Skip values the parser filled in for bare boolean attributes
1219
+ if (attr.value !== undefined && !(attr.quote === '' && attr.value === attr.name)) {
1220
+ raw += (attr.customAssign ?? '=') + (attr.quote ?? '') + attr.value + (attr.quote ?? '');
1221
+ }
1222
+ return (attr.customOpen ?? '') + raw + (attr.customClose ?? '');
1223
+ }
1224
+
1225
+ /**
1226
+ * Join each attribute a custom fragment runs into without whitespace with that fragment,
1227
+ * so that no later step separates, drops, changes, or reorders them
1228
+ * @param {HTMLAttribute[]} attrs - Joined in place
1229
+ * @returns {boolean} Whether the first attribute runs into the tag name
1230
+ */
1231
+ function joinFragmentAttrs(attrs) {
1232
+ const pattern = /** @type {RegExp} */ (uidAttrExactPattern);
1233
+ let joinsTag = false;
1234
+ let writeIndex = 0;
1235
+ // Padding of the previous attribute if that is a fragment without value
1236
+ /** @type {RegExpExecArray | null | undefined} */
1237
+ let prevChunks = null;
1238
+ for (const attr of attrs) {
1239
+ const uidMatch = pattern.exec(attr.name);
1240
+ const chunks = uidMatch ? ignoredCustomMarkupChunks[+(uidMatch[1] ?? 0)] : null;
1241
+ const joins = prevChunks ? prevChunks[2] === '' : chunks?.[1] === '';
1242
+ prevChunks = attr.value === undefined ? chunks : null;
1243
+ if (!joins) {
1244
+ attrs[writeIndex++] = attr;
1245
+ continue;
1246
+ }
1247
+ const prev = writeIndex ? attrs[writeIndex - 1] : undefined;
1248
+ if (!prev) {
1249
+ joinsTag = true;
1250
+ }
1251
+ /** @type {HTMLAttribute} */
1252
+ const joined = { name: (prev ? rawAttr(prev) : '') + rawAttr(attr), customAssign: '=', customOpen: '', customClose: '', quote: '' };
1253
+ opaqueAttrs.add(joined);
1254
+ attrs[prev ? writeIndex - 1 : writeIndex++] = joined;
1255
+ }
1256
+ attrs.length = writeIndex;
1257
+ return joinsTag;
1258
+ }
1259
+
1260
+ /**
1261
+ * Restore the whitespace on one side of a custom fragment
1262
+ * @param {string} str - Whitespace the minifier left there and the fragment’s own padding
1263
+ * @param {boolean} trim
1264
+ * @param {boolean} leading
1265
+ */
1266
+ function restoreFragmentSide(str, trim, leading) {
1267
+ if (!str) {
1268
+ return str;
1269
+ }
1270
+ return collapseWhitespace(str, {
1271
+ preserveLineBreaks: options.preserveLineBreaks,
1272
+ conservativeCollapse: !trim
1273
+ }, leading, !leading);
1274
+ }
1275
+
1186
1276
  function canCollapseWhitespace(/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs) {
1187
1277
  return options.canCollapseWhitespace(tag, attrs, defaultCanCollapseWhitespace);
1188
1278
  }
@@ -1191,12 +1281,22 @@ async function minifyHTML(value, options, partialMarkup) {
1191
1281
  return options.canTrimWhitespace(tag, attrs, defaultCanTrimWhitespace);
1192
1282
  }
1193
1283
 
1284
+ function bufferPush(/** @type {string} */ entry, /** @type {boolean} */ isTag) {
1285
+ buffer.push(entry);
1286
+ bufferTags?.push(isTag);
1287
+ }
1288
+
1289
+ function bufferTruncate(/** @type {number} */ length) {
1290
+ buffer.length = Math.max(0, length);
1291
+ if (bufferTags) bufferTags.length = buffer.length;
1292
+ }
1293
+
1194
1294
  function removeStartTag() {
1195
1295
  let index = buffer.length - 1;
1196
1296
  while (index > 0 && !RE_START_TAG.test(buffer[index] ?? '')) {
1197
1297
  index--;
1198
1298
  }
1199
- buffer.length = Math.max(0, index);
1299
+ bufferTruncate(index);
1200
1300
  }
1201
1301
 
1202
1302
  // Whether the element being parsed holds text rather than markup. The namespace decides
@@ -1252,13 +1352,14 @@ async function minifyHTML(value, options, partialMarkup) {
1252
1352
  const noTrim = stackNoTrimWhitespace.length > 0;
1253
1353
  if (index < buffer.length - 1 && (keepsWhitespace() || noTrim) && RE_END_TAG.test(buffer[index] ?? '')) {
1254
1354
  buffer.splice(index, 1);
1355
+ bufferTags?.splice(index, 1);
1255
1356
  // Only collapsed whitespace can merge
1256
1357
  if (options.collapseWhitespace && !noTrim) {
1257
1358
  mergeWhitespaceRuns(index);
1258
1359
  }
1259
1360
  return;
1260
1361
  }
1261
- buffer.length = Math.max(0, index);
1362
+ bufferTruncate(index);
1262
1363
  }
1263
1364
 
1264
1365
  // Look for trailing whitespaces, bypass any inline tags
@@ -1458,7 +1559,7 @@ async function minifyHTML(value, options, partialMarkup) {
1458
1559
  if (text) {
1459
1560
  hasChars = true;
1460
1561
  }
1461
- buffer.push(text);
1562
+ bufferPush(text, false);
1462
1563
  }
1463
1564
 
1464
1565
  // Comment finalization (sync): Optional tag handling, `htmlmin:ignore` whitespace collapsing, buffer push
@@ -1569,7 +1670,7 @@ async function minifyHTML(value, options, partialMarkup) {
1569
1670
  }
1570
1671
  }
1571
1672
 
1572
- buffer.push(comment);
1673
+ bufferPush(comment, false);
1573
1674
  }
1574
1675
 
1575
1676
  // Inline scripts are minified one at a time as the parse reaches them, which serializes
@@ -1752,7 +1853,11 @@ async function minifyHTML(value, options, partialMarkup) {
1752
1853
  const needsXMLSlash = unary && Boolean(options.insideSVG) && Boolean(options.minifySVG);
1753
1854
  const hasUnarySlash = needsXMLSlash || (unarySlash && (useNameParentForTag ? optionsParent : options).keepClosingSlash);
1754
1855
 
1755
- buffer.push(openTag);
1856
+ bufferPush(openTag, true);
1857
+
1858
+ const fragmentJoinsTag = uidAttrExactPattern ? joinFragmentAttrs(attrs) : false;
1859
+ // Whatever runs into the tag name has to stay first
1860
+ const tagFragment = fragmentJoinsTag ? attrs.shift() : undefined;
1756
1861
 
1757
1862
  // Remove duplicate attributes (per HTML spec, first occurrence wins)
1758
1863
  // Duplicate attributes result in invalid HTML
@@ -1763,11 +1868,17 @@ async function minifyHTML(value, options, partialMarkup) {
1763
1868
  // By the time tags are processed, `createSortFns` has replaced any truthy non-function value
1764
1869
  /** @type {(tag: string, attrs: HTMLAttribute[]) => void} */ (options.sortAttributes)(tag, attrs);
1765
1870
  }
1871
+ if (tagFragment) {
1872
+ attrs.unshift(tagFragment);
1873
+ }
1766
1874
 
1767
1875
  const attrResults = new Array(attrs.length);
1768
1876
  let anyThenable = false;
1769
1877
  for (let i = 0; i < attrs.length; i++) {
1770
- const result = normalizeAttr(/** @type {HTMLAttribute} */ (attrs[i]), attrs, tag, options, minifyHTML);
1878
+ const attr = /** @type {HTMLAttribute} */ (attrs[i]);
1879
+ const result = opaqueAttrs.has(attr)
1880
+ ? { name: attr.name, value: undefined, attr }
1881
+ : normalizeAttr(attr, attrs, tag, options, minifyHTML);
1771
1882
  if (!anyThenable && isThenable(result)) {
1772
1883
  anyThenable = true;
1773
1884
  }
@@ -1785,11 +1896,22 @@ async function minifyHTML(value, options, partialMarkup) {
1785
1896
  }
1786
1897
  }
1787
1898
  if (lastKeptIndex !== -1) {
1788
- buffer.push(' ');
1899
+ if (!fragmentJoinsTag) {
1900
+ bufferPush(' ', true);
1901
+ }
1789
1902
  for (let i = 0; i <= lastKeptIndex; i++) {
1790
1903
  const normalizedAttr = normalizedAttrs[i];
1791
1904
  if (normalizedAttr) {
1792
- buffer.push(buildAttr(normalizedAttr, hasUnarySlash, options, i === lastKeptIndex, uidAttr));
1905
+ if (uidAttr && normalizedAttr.name.includes(uidAttr)) {
1906
+ markTagFragments(normalizedAttr.name);
1907
+ }
1908
+ const builtAttr = buildAttr(normalizedAttr, hasUnarySlash, options, i === lastKeptIndex, uidAttr);
1909
+ // One entry with the tag name, so that no line break can come between them
1910
+ if (i === 0 && fragmentJoinsTag) {
1911
+ buffer[buffer.length - 1] += builtAttr;
1912
+ } else {
1913
+ bufferPush(builtAttr, true);
1914
+ }
1793
1915
  }
1794
1916
  }
1795
1917
  } else if (optional && optionalStartTags.has(tag)) {
@@ -1797,7 +1919,7 @@ async function minifyHTML(value, options, partialMarkup) {
1797
1919
  optionalStartTag = tag;
1798
1920
  }
1799
1921
 
1800
- buffer.push(buffer.pop() + (hasUnarySlash ? '/' : '') + '>');
1922
+ buffer[buffer.length - 1] += (hasUnarySlash ? '/' : '') + '>';
1801
1923
 
1802
1924
  if (autoGenerated && !options.includeAutoGeneratedTags) {
1803
1925
  removeStartTag();
@@ -1815,7 +1937,7 @@ async function minifyHTML(value, options, partialMarkup) {
1815
1937
  currentTag = '';
1816
1938
  }
1817
1939
  },
1818
- end: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ autoGenerated) {
1940
+ end: function (/** @type {string} */ tag, /** @type {HTMLAttribute[]} */ attrs, /** @type {boolean} */ autoGenerated, /** @type {string | undefined} */ rest) {
1819
1941
  // As in `start`: `lowerTag` stays '' when no foreign content is around
1820
1942
  let lowerTag = '';
1821
1943
  // Named as the start tag was: an `svg` or `math` element in its own context,
@@ -1833,6 +1955,18 @@ async function minifyHTML(value, options, partialMarkup) {
1833
1955
  }
1834
1956
  }
1835
1957
  tag = name(tag);
1958
+ let endTagText = '</' + tag + '>';
1959
+ // Custom fragments after the name are kept, next to it if they were in the source
1960
+ if (rest && uidAttr && rest.includes(uidAttr)) {
1961
+ markTagFragments(rest);
1962
+ const uidMatch = /** @type {RegExp} */ (uidAttrLeadingPattern).exec(rest);
1963
+ const joinsTag = uidMatch !== null && rest.startsWith('\t' + uidAttr) &&
1964
+ ignoredCustomMarkupChunks[+(uidMatch[1] ?? 0)]?.[1] === '';
1965
+ // Anything else an end tag holds is dropped, as without fragments
1966
+ const marker = uidAttr;
1967
+ const fragments = Array.from(rest.matchAll(/** @type {RegExp} */ (uidPattern)), uid => marker + (uid[2] ?? '') + marker);
1968
+ endTagText = '</' + tag + (joinsTag ? '' : ' ') + fragments.join(' ') + '>';
1969
+ }
1836
1970
 
1837
1971
  // Check if current tag is in a whitespace stack
1838
1972
  if (options.collapseWhitespace) {
@@ -1894,7 +2028,7 @@ async function minifyHTML(value, options, partialMarkup) {
1894
2028
  if (autoGenerated && !options.includeAutoGeneratedTags) {
1895
2029
  optionalEndTagEmitted = false;
1896
2030
  } else {
1897
- buffer.push('</' + tag + '>');
2031
+ bufferPush(endTagText, true);
1898
2032
  }
1899
2033
  charsPrevTag = '/' + tag;
1900
2034
  if (!inlineElements.has(tag)) {
@@ -1907,7 +2041,7 @@ async function minifyHTML(value, options, partialMarkup) {
1907
2041
  if (autoGenerated && !options.includeAutoGeneratedTags) {
1908
2042
  optionalEndTagEmitted = false;
1909
2043
  } else {
1910
- buffer.push('</' + tag + '>');
2044
+ bufferPush(endTagText, true);
1911
2045
  }
1912
2046
  charsPrevTag = '/' + tag;
1913
2047
  if (!inlineElements.has(tag)) {
@@ -1983,10 +2117,10 @@ async function minifyHTML(value, options, partialMarkup) {
1983
2117
  commentFinalize(text);
1984
2118
  },
1985
2119
  doctype: function (/** @type {string} */ doctype) {
1986
- buffer.push(options.useShortDoctype
2120
+ bufferPush(options.useShortDoctype
1987
2121
  ? '<!doctype' +
1988
2122
  (options.removeTagWhitespace ? '' : ' ') + 'html>'
1989
- : collapseWhitespaceAll(doctype));
2123
+ : collapseWhitespaceAll(doctype), false);
1990
2124
  }
1991
2125
  });
1992
2126
 
@@ -2003,7 +2137,10 @@ async function minifyHTML(value, options, partialMarkup) {
2003
2137
  );
2004
2138
  for (let i = svgBlocks.length - 1; i >= 0; i--) {
2005
2139
  const block = svgBlocks[i];
2006
- if (block) buffer.splice(block.start, block.end - block.start, optimized[i] ?? '');
2140
+ if (block) {
2141
+ buffer.splice(block.start, block.end - block.start, optimized[i] ?? '');
2142
+ bufferTags?.splice(block.start, block.end - block.start, false);
2143
+ }
2007
2144
  }
2008
2145
  }
2009
2146
 
@@ -2022,23 +2159,28 @@ async function minifyHTML(value, options, partialMarkup) {
2022
2159
  squashTrailingWhitespace('br');
2023
2160
  }
2024
2161
 
2025
- return joinResultSegments(buffer, options, uidPattern
2162
+ return joinResultSegments(buffer, bufferTags ?? [], [uidAttr, uidIgnore].filter(marker => marker !== undefined), options, uidPattern
2026
2163
  ? function (/** @type {string} */ str) {
2027
- return str.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ prefix, /** @type {string} */ index, /** @type {string} */ suffix) {
2028
- let chunk = ignoredCustomMarkupChunks[+index]?.[0] ?? match;
2029
- if (options.collapseWhitespace) {
2030
- if (prefix !== '\t') {
2031
- chunk = prefix + chunk;
2032
- }
2033
- if (suffix !== '\t') {
2034
- chunk += suffix;
2035
- }
2036
- return collapseWhitespace(chunk, {
2037
- preserveLineBreaks: options.preserveLineBreaks,
2038
- conservativeCollapse: !options.trimCustomFragments
2039
- }, /^[ \n\r\t\f]/.test(chunk), /[ \n\r\t\f]$/.test(chunk));
2164
+ return str.replace(/** @type {RegExp} */ (uidPattern), function (/** @type {string} */ match, /** @type {string} */ prefix, /** @type {string} */ index, /** @type {string} */ suffix, /** @type {number} */ offset, /** @type {string} */ string) {
2165
+ const chunks = ignoredCustomMarkupChunks[+index];
2166
+ if (!chunks) {
2167
+ return match;
2168
+ }
2169
+ if (!options.collapseWhitespace) {
2170
+ return chunks[0];
2040
2171
  }
2041
- return chunk;
2172
+ const leading = chunks[1] ?? '';
2173
+ const trailing = chunks[2] ?? '';
2174
+ const fragment = chunks[0].slice(leading.length, chunks[0].length - trailing.length);
2175
+ const inTag = customMarkupInTags.has(+index);
2176
+ const keep = inTag || customMarkupInCode.has(+index) || !options.trimCustomFragments;
2177
+ // The padding tabs are whitespace to the minifier, so where one is gone, the
2178
+ // minifier removed the whitespace, which makes it safe to trim
2179
+ const trimLeading = !keep && prefix === '';
2180
+ const trimTrailing = inTag ? string.charCodeAt(offset + match.length) === 62 /* > */ : !keep && suffix === '';
2181
+ return restoreFragmentSide(prefix.replace(RE_TABS, '') + leading, trimLeading, true) +
2182
+ fragment +
2183
+ restoreFragmentSide(suffix.replace(RE_TABS, '') + trailing, trimTrailing, false);
2042
2184
  });
2043
2185
  }
2044
2186
  : identity, uidIgnore
@@ -2052,46 +2194,74 @@ async function minifyHTML(value, options, partialMarkup) {
2052
2194
 
2053
2195
  /**
2054
2196
  * @param {string[]} results
2197
+ * @param {boolean[]} resultTags Whether each result belongs to a start or end tag
2198
+ * @param {string[]} markers Custom fragment and `htmlmin:ignore` placeholder markers
2055
2199
  * @param {ProcessedOptions} options
2056
2200
  * @param {Function} restoreCustom
2057
2201
  * @param {Function} restoreIgnore
2058
2202
  */
2059
- function joinResultSegments(results, options, restoreCustom, restoreIgnore) {
2203
+ function joinResultSegments(results, resultTags, markers, options, restoreCustom, restoreIgnore) {
2060
2204
  let str;
2061
2205
  const maxLineLength = options.maxLineLength;
2062
- const noNewlinesBeforeTagClose = options.noNewlinesBeforeTagClose;
2063
2206
 
2064
2207
  if (maxLineLength) {
2065
- let line = ''; const lines = [];
2066
- // Index-based scan—`Array.shift()` is O(n) per call, which would make this
2067
- // loop quadratic on documents with many segments
2068
- let index = 0;
2069
- while (index < results.length) {
2070
- const len = line.length;
2071
- const cur = results[index] ?? '';
2072
- const end = cur.indexOf('\n');
2073
- const isClosingTag = Boolean(cur.match(endTag));
2074
- const shouldKeepSameLine = noNewlinesBeforeTagClose && isClosingTag;
2075
-
2076
- if (end < 0) {
2077
- line += restoreIgnore(restoreCustom(cur));
2078
- index++;
2079
- } else {
2080
- line += restoreIgnore(restoreCustom(cur.slice(0, end)));
2081
- results[index] = cur.slice(end + 1);
2208
+ // Whitespace in tags is the only whitespace that can’t change the output, so lines break
2209
+ // there alone—in place of the space between attributes, or right before a tag’s `>`
2210
+ const noNewlinesBeforeTagClose = options.noNewlinesBeforeTagClose;
2211
+ /** @type {string[]} */
2212
+ const lines = [];
2213
+ let lineLength = 0;
2214
+ // Output since the last break opportunity, and what that opportunity is when not taken;
2215
+ // fragments are restored by chunk, as whitespace around them decides how they come back
2216
+ let chunk = '';
2217
+ /** @type {string | null} */
2218
+ let separator = null;
2219
+ const flush = (/** @type {string | null} */ nextSeparator) => {
2220
+ chunk = restoreIgnore(restoreCustom(chunk));
2221
+ const newline = chunk.indexOf('\n');
2222
+ const head = newline < 0 ? chunk.length : newline;
2223
+ // A line already too long gains nothing from moving a `>` that ends a line of its own
2224
+ const isUseless = separator === '' && head === 1 && lineLength > maxLineLength;
2225
+ if (separator !== null) {
2226
+ if (lineLength > 0 && lineLength + separator.length + head > maxLineLength && !isUseless) {
2227
+ lines.push('\n');
2228
+ lineLength = 0;
2229
+ } else if (separator) {
2230
+ lines.push(separator);
2231
+ lineLength += separator.length;
2232
+ }
2082
2233
  }
2083
- if (len > 0 && line.length > maxLineLength && !shouldKeepSameLine) {
2084
- lines.push(line.slice(0, len));
2085
- line = line.slice(len);
2086
- } else if (end >= 0) {
2087
- lines.push(line);
2088
- line = '';
2234
+ lines.push(chunk);
2235
+ lineLength = newline < 0 ? lineLength + chunk.length : chunk.length - chunk.lastIndexOf('\n') - 1;
2236
+ chunk = '';
2237
+ separator = nextSeparator;
2238
+ };
2239
+ for (let index = 0; index < results.length; index++) {
2240
+ const result = results[index] ?? '';
2241
+ if (!resultTags[index]) {
2242
+ chunk += result;
2243
+ } else if (markers.length && markers.some(marker => result.includes(marker))) {
2244
+ // Whitespace next to a fragment stays as is, as it may belong to it
2245
+ if (!chunk && separator !== null) {
2246
+ chunk = separator;
2247
+ separator = null;
2248
+ }
2249
+ chunk += result;
2250
+ } else if (result.charCodeAt(result.length - 1) === 32 /* space */) {
2251
+ chunk += result.slice(0, -1);
2252
+ flush(' ');
2253
+ } else if (result.charCodeAt(result.length - 1) === 62 /* > */ &&
2254
+ result.charCodeAt(result.length - 2) !== 47 /* / */ &&
2255
+ !(noNewlinesBeforeTagClose && result.startsWith('</'))) {
2256
+ chunk += result.slice(0, -1);
2257
+ flush('');
2258
+ chunk = '>';
2259
+ } else {
2260
+ chunk += result;
2089
2261
  }
2090
2262
  }
2091
- if (line) {
2092
- lines.push(line);
2093
- }
2094
- str = lines.join('\n');
2263
+ flush(null);
2264
+ str = lines.join('');
2095
2265
  } else {
2096
2266
  str = restoreIgnore(restoreCustom(results.join('')));
2097
2267
  }
package/src/htmlparser.js CHANGED
@@ -67,7 +67,6 @@ const qnameCapture = (function () {
67
67
  })();
68
68
  const startTagOpen = new RegExp('^<' + qnameCapture);
69
69
  const endTagOpen = new RegExp('^</' + qnameCapture);
70
- export const endTag = new RegExp('^</' + qnameCapture + '[^>]*>');
71
70
 
72
71
  let IS_REGEX_CAPTURING_BROKEN = false;
73
72
  'x'.replace(/x(.)?/g, function (/** @type {string} */ m, /** @type {string | undefined} */ g) {
@@ -1033,11 +1032,14 @@ export class HTMLParser {
1033
1032
  stackIndex = 0;
1034
1033
  }
1035
1034
 
1035
+ // Whatever follows the name, which browsers drop but a custom fragment may stand in for
1036
+ const rest = tag.length > tagName.length + 3 ? tag.slice(tagName.length + 2, -1) : '';
1037
+
1036
1038
  if (stackIndex >= 0) {
1037
1039
  // Close all the open elements, up the stack
1038
1040
  for (let i = stack.length - 1; i >= stackIndex; i--) {
1039
1041
  if (handler.end) {
1040
- handler.end(stack[i]?.tag, stack[i]?.attrs, i > stackIndex || !tag);
1042
+ handler.end(stack[i]?.tag, stack[i]?.attrs, i > stackIndex || !tag, i === stackIndex ? rest : '');
1041
1043
  }
1042
1044
  }
1043
1045
 
@@ -1048,7 +1050,7 @@ export class HTMLParser {
1048
1050
  } else if (handler.partialMarkup && tagName) {
1049
1051
  // In partial markup mode, preserve stray end tags
1050
1052
  if (handler.end) {
1051
- handler.end(tagName, [], false);
1053
+ handler.end(tagName, [], false, rest);
1052
1054
  }
1053
1055
  } else if (lowerTagName === 'br') {
1054
1056
  if (handler.start) {
@@ -81,7 +81,7 @@ const optionDefinitions = {
81
81
  type: 'regexpArray'
82
82
  },
83
83
  ignoreCustomFragments: {
84
- description: 'Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php \u2026 ?>`, `{{ \u2026 }}`)',
84
+ description: 'Array of regexes that allow to ignore certain fragments, when matched (e.g., `<?php \u2026 ?>`, `{{ \u2026 }}`, etc.)',
85
85
  type: 'regexpArray'
86
86
  },
87
87
  includeAutoGeneratedTags: {
@@ -101,7 +101,7 @@ const optionDefinitions = {
101
101
  type: 'int'
102
102
  },
103
103
  maxLineLength: {
104
- description: 'Specify a maximum line length; compressed output will be split by newlines at valid HTML split-points',
104
+ description: 'Specify a maximum line length; output will be split by newlines in tags only (between attributes or before a tag’s `>`), where they can’t change the page, so lines may run longer',
105
105
  type: 'int'
106
106
  },
107
107
  mergeScripts: {
@@ -125,7 +125,7 @@ const optionDefinitions = {
125
125
  type: 'json'
126
126
  },
127
127
  noNewlinesBeforeTagClose: {
128
- description: 'Never add a newline before a tag that closes an element—use with `--max-line-length`',
128
+ description: 'Never split a line in a tag that closes an element—use with `--max-line-length`',
129
129
  type: 'boolean'
130
130
  },
131
131
  partialMarkup: {
@@ -169,7 +169,7 @@ const optionDefinitions = {
169
169
  type: 'boolean'
170
170
  },
171
171
  removeEmptyElementsExcept: {
172
- description: 'Array of elements to preserve (e.g., `td`, `<span aria-hidden="true">`)—use with `--remove-empty-elements`',
172
+ description: 'Array of elements to preserve (e.g., `td`, `<span aria-hidden="true">`, etc.)—use with `--remove-empty-elements`',
173
173
  type: 'jsonArray'
174
174
  },
175
175
  removeOptionalTags: {
@@ -197,7 +197,7 @@ const optionDefinitions = {
197
197
  type: 'boolean'
198
198
  },
199
199
  trimCustomFragments: {
200
- description: 'Trim whitespace around custom fragments (`--ignore-custom-fragments`)—use with `--collapse-whitespace`',
200
+ description: 'Trim whitespace around custom fragments (`--ignore-custom-fragments`) where it does not affect the output—use with `--collapse-whitespace`',
201
201
  type: 'boolean'
202
202
  },
203
203
  strictCustomFragments: {