postcss-normalize-string 9.0.2 → 9.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-normalize-string",
3
- "version": "9.0.2",
3
+ "version": "9.0.4",
4
4
  "description": "Normalize wrapping quotes for CSS string literals.",
5
5
  "exports": {
6
6
  ".": {
@@ -33,7 +33,7 @@
33
33
  "url": "git+https://github.com/cssnano/cssnano.git"
34
34
  },
35
35
  "dependencies": {
36
- "postcss-value-parser": "^4.2.0"
36
+ "@csstools/css-tokenizer": "^4.0.2"
37
37
  },
38
38
  "bugs": {
39
39
  "url": "https://github.com/cssnano/cssnano/issues"
package/src/index.js CHANGED
@@ -1,233 +1,233 @@
1
- import valueParser from 'postcss-value-parser';
2
-
3
- /*
4
- * Constants (parser usage)
5
- */
1
+ import { tokenize, TokenType } from '@csstools/css-tokenizer';
6
2
 
7
3
  const SINGLE_QUOTE = "'".charCodeAt(0);
8
4
  const DOUBLE_QUOTE = '"'.charCodeAt(0);
9
5
  const BACKSLASH = '\\'.charCodeAt(0);
10
6
  const NEWLINE = '\n'.charCodeAt(0);
11
- const SPACE = ' '.charCodeAt(0);
12
7
  const FEED = '\f'.charCodeAt(0);
13
- const TAB = '\t'.charCodeAt(0);
14
8
  const CR = '\r'.charCodeAt(0);
15
9
 
16
- const WORD_END = /[ \n\t\r\f'"\\]/g;
17
-
18
- /*
19
- * Constants (node type strings)
10
+ /**
11
+ * Verify whether a string token has matching delimiters and balanced trailing escapes.
12
+ *
13
+ * @param {string} value
14
+ * @return {boolean}
20
15
  */
16
+ function isClosedString(value) {
17
+ if (value.length < 2 || (value[0] !== "'" && value[0] !== '"')) {
18
+ return false;
19
+ }
21
20
 
22
- const C_STRING = 'string';
23
- const C_ESCAPED_SINGLE_QUOTE = 'escapedSingleQuote';
24
- const C_ESCAPED_DOUBLE_QUOTE = 'escapedDoubleQuote';
25
- const C_SINGLE_QUOTE = 'singleQuote';
26
- const C_DOUBLE_QUOTE = 'doubleQuote';
27
- const C_NEWLINE = 'newline';
28
- const C_SINGLE = 'single';
29
-
30
- /*
31
- * Literals
32
- */
21
+ let backslashes = 0;
22
+ for (
23
+ let index = value.length - 2;
24
+ index >= 0 && value[index] === '\\';
25
+ index--
26
+ ) {
27
+ backslashes++;
28
+ }
33
29
 
34
- const L_SINGLE_QUOTE = `'`;
35
- const L_DOUBLE_QUOTE = `"`;
36
- const L_NEWLINE = `\\\n`;
30
+ return value.at(-1) === value[0] && backslashes % 2 === 0;
31
+ }
37
32
 
38
- /*
39
- * Parser nodes
33
+ /**
34
+ * Return the length of an escaped line continuation, or 0 if not a newline escape.
35
+ *
36
+ * @param {string} inner
37
+ * @param {number} pos
38
+ * @param {number} len
39
+ * @return {number}
40
40
  */
41
+ function getEscapedNewlineLength(inner, pos, len) {
42
+ const next = inner.charCodeAt(pos + 1);
43
+ if (next === NEWLINE || next === FEED) {
44
+ return 2;
45
+ }
46
+ if (next === CR) {
47
+ return pos + 2 < len && inner.charCodeAt(pos + 2) === NEWLINE ? 3 : 2;
48
+ }
49
+ return 0;
50
+ }
41
51
 
42
- const T_ESCAPED_SINGLE_QUOTE = { type: C_ESCAPED_SINGLE_QUOTE, value: `\\'` };
43
- const T_ESCAPED_DOUBLE_QUOTE = { type: C_ESCAPED_DOUBLE_QUOTE, value: `\\"` };
44
- const T_SINGLE_QUOTE = { type: C_SINGLE_QUOTE, value: L_SINGLE_QUOTE };
45
- const T_DOUBLE_QUOTE = { type: C_DOUBLE_QUOTE, value: L_DOUBLE_QUOTE };
46
- const T_NEWLINE = { type: C_NEWLINE, value: L_NEWLINE };
47
-
48
- /** @typedef {typeof T_ESCAPED_SINGLE_QUOTE | typeof T_ESCAPED_DOUBLE_QUOTE | typeof T_SINGLE_QUOTE | typeof T_NEWLINE} StringAstNode */
49
52
  /**
50
- * @typedef {{nodes: StringAstNode[],
51
- * types: {escapedSingleQuote: number, escapedDoubleQuote: number, singleQuote: number, doubleQuote: number},
52
- * quotes: boolean}} StringAst
53
+ * Scan string contents to count quotes and detect escaped newlines or redundant escapes.
54
+ *
55
+ * @param {string} inner
56
+ * @param {string} currentQuote
57
+ * @return {{ singleCount: number, doubleCount: number, hasEscapedNewline: boolean, hasRedundantEscape: boolean }}
53
58
  */
59
+ function scanQuotes(inner, currentQuote) {
60
+ const len = inner.length;
61
+ let singleCount = 0;
62
+ let doubleCount = 0;
63
+ let hasEscapedNewline = false;
64
+ let hasRedundantEscape = false;
65
+ let pos = 0;
66
+
67
+ while (pos < len) {
68
+ const code = inner.charCodeAt(pos);
69
+ if (code === BACKSLASH) {
70
+ if (pos + 1 < len) {
71
+ const newlineLen = getEscapedNewlineLength(inner, pos, len);
72
+ if (newlineLen > 0) {
73
+ hasEscapedNewline = true;
74
+ pos += newlineLen;
75
+ continue;
76
+ }
77
+ const next = inner.charCodeAt(pos + 1);
78
+ if (next === SINGLE_QUOTE) {
79
+ singleCount++;
80
+ if (currentQuote === '"') {
81
+ hasRedundantEscape = true;
82
+ }
83
+ pos += 2;
84
+ continue;
85
+ }
86
+ if (next === DOUBLE_QUOTE) {
87
+ doubleCount++;
88
+ if (currentQuote === "'") {
89
+ hasRedundantEscape = true;
90
+ }
91
+ pos += 2;
92
+ continue;
93
+ }
94
+ pos += 2;
95
+ continue;
96
+ }
97
+ pos++;
98
+ continue;
99
+ }
100
+ if (code === SINGLE_QUOTE) {
101
+ singleCount++;
102
+ } else if (code === DOUBLE_QUOTE) {
103
+ doubleCount++;
104
+ }
105
+ pos++;
106
+ }
107
+
108
+ return { singleCount, doubleCount, hasEscapedNewline, hasRedundantEscape };
109
+ }
54
110
 
55
111
  /**
56
- * @param {StringAst} ast
112
+ * Determine the optimal wrapping quote delimiter.
113
+ *
114
+ * @param {number} singleCount
115
+ * @param {number} doubleCount
116
+ * @param {'single' | 'double'} preferredQuote
117
+ * @param {string} currentQuote
57
118
  * @return {string}
58
119
  */
59
- function stringify(ast) {
60
- let str = '';
61
- for (const { value } of ast.nodes) {
62
- // Collapse multiple line strings automatically
63
- if (value !== L_NEWLINE) {
64
- str += value;
65
- }
120
+ function selectTargetQuote(
121
+ singleCount,
122
+ doubleCount,
123
+ preferredQuote,
124
+ currentQuote
125
+ ) {
126
+ if (singleCount > doubleCount) {
127
+ return '"';
128
+ }
129
+ if (doubleCount > singleCount) {
130
+ return "'";
66
131
  }
67
- return str;
132
+ if (singleCount === 0 && doubleCount === 0) {
133
+ return preferredQuote === 'single' ? "'" : '"';
134
+ }
135
+ return currentQuote;
68
136
  }
69
137
 
70
138
  /**
71
- * @param {string} str
72
- * @return {StringAst}
139
+ * Reconstruct string with the target quote, collapsing newlines and updating escapes.
140
+ *
141
+ * @param {string} inner
142
+ * @param {string} targetQuote
143
+ * @return {string}
73
144
  */
74
- function parse(str) {
75
- let code, next, value;
145
+ function reconstructString(inner, targetQuote) {
146
+ const len = inner.length;
147
+ const targetCode = targetQuote.charCodeAt(0);
148
+ const otherQuote = targetQuote === "'" ? '"' : "'";
149
+ const otherCode = otherQuote.charCodeAt(0);
150
+ const escapedTargetQuote = '\\' + targetQuote;
151
+ const chunks = [targetQuote];
152
+ let cursor = 0;
76
153
  let pos = 0;
77
- const len = str.length;
78
-
79
- /** @type StringAst */
80
- const ast = {
81
- nodes: [],
82
- types: {
83
- escapedSingleQuote: 0,
84
- escapedDoubleQuote: 0,
85
- singleQuote: 0,
86
- doubleQuote: 0,
87
- },
88
- quotes: false,
89
- };
90
154
 
91
155
  while (pos < len) {
92
- code = str.charCodeAt(pos);
93
-
94
- switch (code) {
95
- case SPACE:
96
- case TAB:
97
- case CR:
98
- case FEED:
99
- next = pos;
100
-
101
- do {
102
- next += 1;
103
- code = str.charCodeAt(next);
104
- } while (
105
- code === SPACE ||
106
- code === NEWLINE ||
107
- code === TAB ||
108
- code === CR ||
109
- code === FEED
110
- );
111
-
112
- ast.nodes.push({
113
- type: 'space',
114
- value: str.slice(pos, next),
115
- });
116
- pos = next - 1;
117
- break;
118
- case SINGLE_QUOTE:
119
- ast.nodes.push(T_SINGLE_QUOTE);
120
- ast.types[C_SINGLE_QUOTE]++;
121
- ast.quotes = true;
122
- break;
123
- case DOUBLE_QUOTE:
124
- ast.nodes.push(T_DOUBLE_QUOTE);
125
- ast.types[C_DOUBLE_QUOTE]++;
126
- ast.quotes = true;
127
- break;
128
- case BACKSLASH:
129
- next = pos + 1;
130
-
131
- if (str.charCodeAt(next) === SINGLE_QUOTE) {
132
- ast.nodes.push(T_ESCAPED_SINGLE_QUOTE);
133
- ast.types[C_ESCAPED_SINGLE_QUOTE]++;
134
- ast.quotes = true;
135
- pos = next;
136
- break;
137
- } else if (str.charCodeAt(next) === DOUBLE_QUOTE) {
138
- ast.nodes.push(T_ESCAPED_DOUBLE_QUOTE);
139
- ast.types[C_ESCAPED_DOUBLE_QUOTE]++;
140
- ast.quotes = true;
141
- pos = next;
142
- break;
143
- } else if (str.charCodeAt(next) === NEWLINE) {
144
- ast.nodes.push(T_NEWLINE);
145
- pos = next;
146
- break;
156
+ const code = inner.charCodeAt(pos);
157
+ if (code === BACKSLASH) {
158
+ if (pos + 1 < len) {
159
+ const newlineLen = getEscapedNewlineLength(inner, pos, len);
160
+ if (newlineLen > 0) {
161
+ if (pos > cursor) {
162
+ chunks.push(inner.slice(cursor, pos));
163
+ }
164
+ pos += newlineLen;
165
+ cursor = pos;
166
+ continue;
147
167
  }
148
- /*
149
- * We need to fall through here to handle the token as
150
- * a whole word. The missing 'break' is intentional.
151
- */
152
- // oxlint-disable-next-line no-fallthrough
153
- default:
154
- WORD_END.lastIndex = pos + 1;
155
- WORD_END.test(str);
156
-
157
- if (WORD_END.lastIndex === 0) {
158
- next = len - 1;
159
- } else {
160
- next = WORD_END.lastIndex - 2;
168
+ if (inner.charCodeAt(pos + 1) === otherCode) {
169
+ if (pos > cursor) {
170
+ chunks.push(inner.slice(cursor, pos));
171
+ }
172
+ chunks.push(otherQuote);
173
+ pos += 2;
174
+ cursor = pos;
175
+ continue;
161
176
  }
162
-
163
- value = str.slice(pos, next + 1);
164
-
165
- ast.nodes.push({
166
- type: C_STRING,
167
- value,
168
- });
169
-
170
- pos = next;
177
+ pos += 2;
178
+ continue;
179
+ }
180
+ pos++;
181
+ continue;
182
+ }
183
+ if (code === targetCode) {
184
+ if (pos > cursor) {
185
+ chunks.push(inner.slice(cursor, pos));
186
+ }
187
+ chunks.push(escapedTargetQuote);
188
+ pos++;
189
+ cursor = pos;
190
+ continue;
171
191
  }
172
192
  pos++;
173
193
  }
174
194
 
175
- return ast;
195
+ if (cursor < len) {
196
+ chunks.push(inner.slice(cursor));
197
+ }
198
+ chunks.push(targetQuote);
199
+ return chunks.join('');
176
200
  }
177
201
 
178
202
  /**
179
- * @param {valueParser.StringNode} node
180
- * @param {StringAst} ast
181
- * @return {void}
203
+ * Normalizes quotes and line continuations in a single closed CSS string token.
204
+ *
205
+ * @param {string} raw
206
+ * @param {'single' | 'double'} preferredQuote
207
+ * @return {string}
182
208
  */
183
- function changeWrappingQuotes(node, ast) {
184
- const { types } = ast;
185
-
186
- if (types[C_SINGLE_QUOTE] || types[C_DOUBLE_QUOTE]) {
187
- return;
188
- }
189
-
190
- if (
191
- node.quote === L_SINGLE_QUOTE &&
192
- types[C_ESCAPED_SINGLE_QUOTE] > 0 &&
193
- !types[C_ESCAPED_DOUBLE_QUOTE]
194
- ) {
195
- node.quote = L_DOUBLE_QUOTE;
196
- }
209
+ function normalizeString(raw, preferredQuote) {
210
+ const currentQuote = raw[0];
211
+ const inner = raw.slice(1, -1);
212
+ const { singleCount, doubleCount, hasEscapedNewline, hasRedundantEscape } =
213
+ scanQuotes(inner, currentQuote);
214
+
215
+ const targetQuote = selectTargetQuote(
216
+ singleCount,
217
+ doubleCount,
218
+ preferredQuote,
219
+ currentQuote
220
+ );
197
221
 
198
222
  if (
199
- node.quote === L_DOUBLE_QUOTE &&
200
- types[C_ESCAPED_DOUBLE_QUOTE] > 0 &&
201
- !types[C_ESCAPED_SINGLE_QUOTE]
223
+ targetQuote === currentQuote &&
224
+ !hasEscapedNewline &&
225
+ !hasRedundantEscape
202
226
  ) {
203
- node.quote = L_SINGLE_QUOTE;
227
+ return raw;
204
228
  }
205
229
 
206
- ast.nodes = changeChildQuotes(ast.nodes, node.quote);
207
- }
208
- /**
209
- * @param {StringAstNode[]} childNodes
210
- * @param {string} parentQuote
211
- * @return {StringAstNode[]}
212
- */
213
- function changeChildQuotes(childNodes, parentQuote) {
214
- const updatedChildren = [];
215
- for (const child of childNodes) {
216
- if (
217
- child.type === C_ESCAPED_DOUBLE_QUOTE &&
218
- parentQuote === L_SINGLE_QUOTE
219
- ) {
220
- updatedChildren.push(T_DOUBLE_QUOTE);
221
- } else if (
222
- child.type === C_ESCAPED_SINGLE_QUOTE &&
223
- parentQuote === L_DOUBLE_QUOTE
224
- ) {
225
- updatedChildren.push(T_SINGLE_QUOTE);
226
- } else {
227
- updatedChildren.push(child);
228
- }
229
- }
230
- return updatedChildren;
230
+ return reconstructString(inner, targetQuote);
231
231
  }
232
232
 
233
233
  /**
@@ -236,29 +236,27 @@ function changeChildQuotes(childNodes, parentQuote) {
236
236
  * @return {string}
237
237
  */
238
238
  function normalize(value, preferredQuote) {
239
- if (!value || !value.length) {
239
+ if (!value || (!value.includes("'") && !value.includes('"'))) {
240
240
  return value;
241
241
  }
242
242
 
243
- return valueParser(value)
244
- .walk((child) => {
245
- if (child.type !== C_STRING) {
246
- return;
247
- }
248
-
249
- const ast = parse(child.value);
250
-
251
- if (ast.quotes) {
252
- changeWrappingQuotes(child, ast);
253
- } else if (preferredQuote === C_SINGLE) {
254
- child.quote = L_SINGLE_QUOTE;
255
- } else {
256
- child.quote = L_DOUBLE_QUOTE;
257
- }
258
-
259
- child.value = stringify(ast);
260
- })
261
- .toString();
243
+ const chunks = [];
244
+ let cursor = 0;
245
+ let changed = false;
246
+ for (const [type, raw, start, end] of tokenize({ css: value })) {
247
+ if (type !== TokenType.String) continue;
248
+ if (!isClosedString(raw)) continue;
249
+ const normalized = normalizeString(raw, preferredQuote);
250
+ if (normalized !== raw) {
251
+ changed = true;
252
+ }
253
+ chunks.push(value.slice(cursor, start));
254
+ chunks.push(normalized);
255
+ cursor = end + 1;
256
+ }
257
+ if (!changed) return value;
258
+ chunks.push(value.slice(cursor));
259
+ return chunks.join('');
262
260
  }
263
261
 
264
262
  /**
@@ -277,6 +275,12 @@ function minify(original, cache, preferredQuote) {
277
275
  return newValue;
278
276
  }
279
277
 
278
+ /** @param {import('postcss').Declaration} decl @param {string} value */
279
+ function assignValue(decl, value) {
280
+ decl.value = value;
281
+ if (decl.raws.value?.raw) decl.raws.value = { raw: value, value };
282
+ }
283
+
280
284
  /** @typedef {{preferredQuote?: 'double' | 'single'}} Options */
281
285
  /**
282
286
  * @param {Options} opts
@@ -306,7 +310,13 @@ function pluginCreator(opts) {
306
310
  node.selector = minify(node.selector, cache, preferredQuote);
307
311
  break;
308
312
  case 'decl':
309
- node.value = minify(node.value, cache, preferredQuote);
313
+ {
314
+ const value =
315
+ node.raws.value?.value === node.value
316
+ ? (node.raws.value.raw ?? node.value)
317
+ : node.value;
318
+ assignValue(node, minify(value, cache, preferredQuote));
319
+ }
310
320
  break;
311
321
  case 'atrule':
312
322
  node.params = minify(node.params, cache, preferredQuote);
package/types/index.d.ts CHANGED
@@ -1,30 +1,3 @@
1
- declare const T_ESCAPED_SINGLE_QUOTE: {
2
- type: string;
3
- value: string;
4
- };
5
- declare const T_ESCAPED_DOUBLE_QUOTE: {
6
- type: string;
7
- value: string;
8
- };
9
- declare const T_SINGLE_QUOTE: {
10
- type: string;
11
- value: string;
12
- };
13
- declare const T_NEWLINE: {
14
- type: string;
15
- value: string;
16
- };
17
- export type StringAstNode = typeof T_ESCAPED_SINGLE_QUOTE | typeof T_ESCAPED_DOUBLE_QUOTE | typeof T_SINGLE_QUOTE | typeof T_NEWLINE;
18
- export type StringAst = {
19
- nodes: StringAstNode[];
20
- types: {
21
- escapedSingleQuote: number;
22
- escapedDoubleQuote: number;
23
- singleQuote: number;
24
- doubleQuote: number;
25
- };
26
- quotes: boolean;
27
- };
28
1
  export type Options = {
29
2
  preferredQuote?: 'double' | 'single';
30
3
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AAyCA,QAAA,MAAM,sBAAsB;;;CAAiD,CAAC;AAC9E,QAAA,MAAM,sBAAsB;;;CAAiD,CAAC;AAC9E,QAAA,MAAM,cAAc;;;CAAkD,CAAC;AAEvE,QAAA,MAAM,SAAS;;;CAAwC,CAAC;AAEpD,YAAoH,aAAa,GAAvH,OAAO,sBAAsB,GAAG,OAAO,sBAAsB,GAAG,OAAO,cAAc,GAAG,OAAO,SAAS,CAAe;AAElI,YAE6B,SAAS,GAF5B;IAAC,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,KAAK,EAAE;QAAC,kBAAkB,EAAE,MAAM,CAAC;QAAC,kBAAkB,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAC,CAAC;IAC1G,MAAM,EAAE,OAAO,CAAA;CAAC,CAAW;AAoOrC,YAAkD,OAAO,GAA/C;IAAC,cAAc,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAA;CAAC,CAAS;AAA7D,gEAAgE;AAChE;;;GAGG;AACH,iBAAS,aAAa,CAAC,IAAI,EAHhB,OAGgB,GAFf,OAAO,SAAS,EAAE,MAAM,CAmCnC;kBAjCQ,aAAa;iBAkCX,IAAI;;AAEf,QAAA,MAAM,aAAa,sBAAgB,CAAC;AAEpC,OAAO,EAAE,aAAa,IAAI,OAAO,EAAE,aAAa,IAAI,gBAAgB,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.js"],"names":[],"mappings":"AA2RI,YAAkD,OAAO,GAA/C;IAAC,cAAc,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAA;CAAC,CAAS;AAA7D,gEAAgE;AAChE;;;GAGG;AACH,iBAAS,aAAa,CAAC,IAAI,EAHhB,OAGgB,GAFf,OAAO,SAAS,EAAE,MAAM,CAyCnC;kBAvCQ,aAAa;iBAwCX,IAAI;;AAEf,QAAA,MAAM,aAAa,sBAAgB,CAAC;AAEpC,OAAO,EAAE,aAAa,IAAI,OAAO,EAAE,aAAa,IAAI,gBAAgB,EAAE,CAAC"}