tailwind-canonical 0.8.0 → 0.10.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.
@@ -5,24 +5,199 @@ export function toClassStringOpts(config) {
5
5
  functionNames: config.functionNames,
6
6
  attributeNames: config.attributeNames,
7
7
  customTextTokens: config.customTextTokens,
8
+ tailwindVersion: config.tailwindVersion,
8
9
  };
9
10
  }
10
11
  const DEFAULT_ATTR_NAMES = ['className'];
11
- export const SINGLE_CLASS_REGEX = /[^\s"'`{}]+/g;
12
+ export const SINGLE_CLASS_REGEX = /\S+/g;
12
13
  function escapeRegex(s) {
13
14
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
15
  }
15
16
  function buildAttrRegex(names) {
16
17
  const alts = names.map(escapeRegex).join('|');
17
- return new RegExp(`(^|[^\\w:$-])(${alts})\\s*=\\s*(?:({)(?:"([^"]+)"|'([^']+)'|\`([^\`]+)\`)(})|(?:"([^"]+)"|'([^']+)'|\`([^\`]+)\`))`, 'g');
18
+ return new RegExp(`(^|[^\\w:$-])(${alts})\\s*=\\s*`, 'g');
19
+ }
20
+ function attributeStrings(content, names, functionNames = []) {
21
+ const result = [];
22
+ for (const match of content.matchAll(buildAttrRegex(names))) {
23
+ const attributeStart = (match.index ?? 0) + match[1].length;
24
+ const start = (match.index ?? 0) + match[0].length;
25
+ if (content[start] === '{') {
26
+ const expression = readCallTokens(content, start + 1, '}');
27
+ if (expression.complete) {
28
+ result.push(...classExpression(expression.tokens, functionNames).map((string) => ({ ...string, attributeStart })));
29
+ }
30
+ continue;
31
+ }
32
+ if (!['"', "'", '`'].includes(content[start]))
33
+ continue;
34
+ const quoted = readQuotedString(content, start);
35
+ if (!quoted)
36
+ continue;
37
+ if (match[2].startsWith(':') || match[2].startsWith('v-bind:')) {
38
+ // Vue bindings contain JavaScript, not a class list. Entities require an
39
+ // HTML decoder/source map, so leave those expressions intact for now.
40
+ if (/&(?:#\d+|#x[\da-f]+|[a-z]+);/i.test(quoted.value))
41
+ continue;
42
+ const expression = readCallTokens(content, quoted.start, content[start]);
43
+ if (expression.complete && expression.next === quoted.next) {
44
+ result.push(...classExpression(expression.tokens, functionNames).map((string) => ({ ...string, attributeStart })));
45
+ }
46
+ }
47
+ else
48
+ result.push({ ...quoted, attributeStart, braceExpressions: true });
49
+ }
50
+ return result;
51
+ }
52
+ // A template expression is JavaScript, never a class list. Keep its adjacent
53
+ // token fragments as well: `text-[12px]${suffix}` is not a static class.
54
+ function interpolationEnd(content, start, openingLength = 2) {
55
+ let depth = 1;
56
+ let i = start + openingLength;
57
+ while (i < content.length) {
58
+ const ch = content[i];
59
+ if (ch === '"' || ch === "'" || ch === '`') {
60
+ const quoted = readQuotedString(content, i);
61
+ if (!quoted)
62
+ return content.length;
63
+ i = quoted.next;
64
+ }
65
+ else if (content.startsWith('//', i)) {
66
+ const end = content.indexOf('\n', i + 2);
67
+ i = end < 0 ? content.length : end;
68
+ }
69
+ else if (content.startsWith('/*', i)) {
70
+ const end = content.indexOf('*/', i + 2);
71
+ i = end < 0 ? content.length : end + 2;
72
+ }
73
+ else if (ch === '/') {
74
+ const preceding = previousToken(content, i);
75
+ if (regexMayStart(preceding)) {
76
+ const end = readRegex(content, i);
77
+ if (!end)
78
+ return content.length;
79
+ i = end;
80
+ }
81
+ else
82
+ i++;
83
+ }
84
+ else {
85
+ if (ch === '{')
86
+ depth++;
87
+ else if (ch === '}' && --depth === 0)
88
+ return i + 1;
89
+ i++;
90
+ }
91
+ }
92
+ return content.length;
93
+ }
94
+ function staticClassRanges(value, braceExpressions = false) {
95
+ const ranges = [];
96
+ let start = 0;
97
+ let i = 0;
98
+ let bracketDepth = 0;
99
+ while (i < value.length) {
100
+ let escapes = 0;
101
+ for (let j = i - 1; j >= 0 && value[j] === '\\'; j--)
102
+ escapes++;
103
+ const openingLength = value.startsWith('${', i)
104
+ ? 2
105
+ : braceExpressions && bracketDepth === 0 && value[i] === '{'
106
+ ? 1
107
+ : 0;
108
+ if (!openingLength || escapes % 2 === 1) {
109
+ if (escapes % 2 === 0) {
110
+ if (value[i] === '[')
111
+ bracketDepth++;
112
+ else if (value[i] === ']')
113
+ bracketDepth = Math.max(0, bracketDepth - 1);
114
+ }
115
+ i++;
116
+ continue;
117
+ }
118
+ let before = i;
119
+ while (before > start && !/\s/.test(value[before - 1]))
120
+ before--;
121
+ if (before > start)
122
+ ranges.push({ value: value.slice(start, before), start, end: before });
123
+ i = interpolationEnd(value, i, openingLength);
124
+ while (i < value.length && !/\s/.test(value[i]))
125
+ i++;
126
+ start = i;
127
+ }
128
+ if (start < value.length)
129
+ ranges.push({ value: value.slice(start), start, end: value.length });
130
+ return ranges;
131
+ }
132
+ function transformStaticClasses(value, transform, isSuppressedAt, offset = 0, braceExpressions = false) {
133
+ const suppressed = [];
134
+ if (isSuppressedAt) {
135
+ let lineStart = 0;
136
+ while (lineStart < value.length) {
137
+ const newline = value.indexOf('\n', lineStart);
138
+ const end = newline < 0 ? value.length : newline + 1;
139
+ if (isSuppressedAt(offset + lineStart))
140
+ suppressed.push({ start: lineStart, end });
141
+ lineStart = end;
142
+ }
143
+ }
144
+ const ranges = staticClassRanges(value, braceExpressions).flatMap((range) => {
145
+ const safe = [];
146
+ let start = range.start;
147
+ for (const line of suppressed) {
148
+ if (line.end <= start || line.start >= range.end)
149
+ continue;
150
+ if (line.start > start)
151
+ safe.push({
152
+ value: value.slice(start, line.start),
153
+ start,
154
+ end: line.start,
155
+ });
156
+ start = Math.max(start, line.end);
157
+ }
158
+ if (start < range.end)
159
+ safe.push({
160
+ value: value.slice(start, range.end),
161
+ start,
162
+ end: range.end,
163
+ });
164
+ return safe;
165
+ });
166
+ const preserveEdges = value.includes('${') ||
167
+ (braceExpressions && value.includes('{')) ||
168
+ suppressed.length > 0;
169
+ let out = '';
170
+ let pos = 0;
171
+ for (const range of ranges) {
172
+ const segment = range.value;
173
+ if (!segment.trim()) {
174
+ out += value.slice(pos, range.end);
175
+ pos = range.end;
176
+ continue;
177
+ }
178
+ const leading = preserveEdges ? (segment.match(/^\s*/)?.[0] ?? '') : '';
179
+ const trailing = preserveEdges ? (segment.match(/\s*$/)?.[0] ?? '') : '';
180
+ const inner = segment.slice(leading.length, segment.length - trailing.length);
181
+ out +=
182
+ value.slice(pos, range.start) +
183
+ leading +
184
+ (inner ? transform(inner) : '') +
185
+ trailing;
186
+ pos = range.end;
187
+ }
188
+ return out + value.slice(pos);
18
189
  }
19
190
  function readQuotedString(content, quoteStart) {
20
191
  const quote = content[quoteStart];
21
192
  let quoteEnd = quoteStart + 1;
22
193
  while (quoteEnd < content.length && content[quoteEnd] !== quote) {
23
194
  if (content[quoteEnd] === '\\')
195
+ quoteEnd += 2;
196
+ else if (quote === '`' && content.startsWith('${', quoteEnd)) {
197
+ quoteEnd = interpolationEnd(content, quoteEnd);
198
+ }
199
+ else
24
200
  quoteEnd++;
25
- quoteEnd++;
26
201
  }
27
202
  if (quoteEnd >= content.length)
28
203
  return null;
@@ -33,59 +208,276 @@ function readQuotedString(content, quoteStart) {
33
208
  next: quoteEnd + 1,
34
209
  };
35
210
  }
36
- function scanCallStrings(content, start) {
37
- const strings = [];
38
- let i = start;
39
- let depth = 1;
40
- while (i < content.length && depth > 0) {
211
+ function readRegex(content, start) {
212
+ let bracket = false;
213
+ for (let i = start + 1; i < content.length && content[i] !== '\n'; i++) {
41
214
  const ch = content[i];
42
- if (ch === '(' || ch === '[' || ch === '{') {
43
- depth++;
215
+ if (ch === '\\')
216
+ i++;
217
+ else if (ch === '[')
218
+ bracket = true;
219
+ else if (ch === ']')
220
+ bracket = false;
221
+ else if (ch === '/' && !bracket) {
44
222
  i++;
223
+ while (/[a-z]/i.test(content[i] ?? '') && i < content.length)
224
+ i++;
225
+ return i;
45
226
  }
46
- else if (ch === ')' || ch === ']' || ch === '}') {
47
- depth--;
227
+ }
228
+ return null;
229
+ }
230
+ const REGEX_PREFIX_TOKENS = new Set([
231
+ ',',
232
+ ':',
233
+ '?',
234
+ '=',
235
+ '!',
236
+ '(',
237
+ '[',
238
+ '{',
239
+ ';',
240
+ '&',
241
+ '|',
242
+ '=>',
243
+ '&&',
244
+ '||',
245
+ '??',
246
+ '+',
247
+ '-',
248
+ '*',
249
+ '/',
250
+ '%',
251
+ '>',
252
+ '<',
253
+ '^',
254
+ '~',
255
+ '==',
256
+ '===',
257
+ '!=',
258
+ '!==',
259
+ '<=',
260
+ '>=',
261
+ 'return',
262
+ 'throw',
263
+ 'case',
264
+ 'typeof',
265
+ 'void',
266
+ 'delete',
267
+ 'yield',
268
+ 'await',
269
+ 'in',
270
+ 'instanceof',
271
+ 'else',
272
+ 'do',
273
+ 'new',
274
+ 'of',
275
+ // A slash after a statement block may open a regex. Treat a valid-looking
276
+ // literal conservatively even when an object/division expression is ambiguous.
277
+ '}',
278
+ ]);
279
+ // Read only the token immediately before an offset. Searching an entire source
280
+ // prefix with an end-anchored regexp once per helper call is quadratic.
281
+ function previousToken(content, offset) {
282
+ let end = offset;
283
+ while (end > 0 && /\s/.test(content[end - 1]))
284
+ end--;
285
+ if (end === 0)
286
+ return undefined;
287
+ let start = end;
288
+ while (start > 0 && /[\w$]/.test(content[start - 1]))
289
+ start--;
290
+ if (start < end)
291
+ return content.slice(start, end);
292
+ const pair = content.slice(Math.max(0, end - 2), end);
293
+ if (['=>', '&&', '||', '??'].includes(pair))
294
+ return pair;
295
+ return content[end - 1];
296
+ }
297
+ function regexMayStart(previous) {
298
+ return previous === undefined || REGEX_PREFIX_TOKENS.has(previous);
299
+ }
300
+ // Delimiter groups are enough to identify static class positions. This does
301
+ // not evaluate expressions or interpret arbitrary JavaScript as a class list.
302
+ function readCallTokens(content, start, closing = ')') {
303
+ const tokens = [];
304
+ let i = start;
305
+ while (i < content.length) {
306
+ const ch = content[i];
307
+ if (/\s/.test(ch)) {
48
308
  i++;
309
+ continue;
310
+ }
311
+ if (content.startsWith('//', i)) {
312
+ const end = content.indexOf('\n', i + 2);
313
+ i = end < 0 ? content.length : end;
314
+ continue;
315
+ }
316
+ if (content.startsWith('/*', i)) {
317
+ const end = content.indexOf('*/', i + 2);
318
+ if (end < 0)
319
+ return { tokens: [], next: content.length, complete: false };
320
+ i = end + 2;
321
+ continue;
49
322
  }
50
- else if (ch === '"' || ch === "'" || ch === '`') {
323
+ if (ch === closing)
324
+ return { tokens, next: i + 1, complete: true };
325
+ if (ch === ')' || ch === ']' || ch === '}')
326
+ return { tokens: [], next: i + 1, complete: false };
327
+ if (ch === '"' || ch === "'" || ch === '`') {
51
328
  const quoted = readQuotedString(content, i);
52
- if (!quoted) {
53
- return {
54
- strings: [],
55
- consumed: content.length - start,
56
- complete: false,
57
- };
58
- }
59
- strings.push(quoted);
329
+ if (!quoted)
330
+ return { tokens: [], next: content.length, complete: false };
331
+ tokens.push({ text: content.slice(i, quoted.next), string: quoted });
60
332
  i = quoted.next;
333
+ continue;
61
334
  }
62
- else {
63
- i++;
335
+ if (ch === '(' || ch === '[' || ch === '{') {
336
+ const end = { '(': ')', '[': ']', '{': '}' }[ch];
337
+ const group = readCallTokens(content, i + 1, end);
338
+ if (!group.complete)
339
+ return { tokens: [], next: group.next, complete: false };
340
+ tokens.push({ text: ch, children: group.tokens });
341
+ i = group.next;
342
+ continue;
343
+ }
344
+ if (ch === '/' && regexMayStart(tokens.at(-1)?.text)) {
345
+ const end = readRegex(content, i);
346
+ if (!end)
347
+ return { tokens: [], next: content.length, complete: false };
348
+ tokens.push({ text: content.slice(i, end) });
349
+ i = end;
350
+ continue;
64
351
  }
352
+ const word = content.slice(i).match(/^[\w$]+/);
353
+ const operator = content
354
+ .slice(i)
355
+ .match(/^(?:===|!==|=>|&&|\|\||\?\?|==|!=|<=|>=|\?\.|\+\+|--|\*\*|\.\.\.)/);
356
+ const text = word?.[0] ?? operator?.[0] ?? ch;
357
+ tokens.push({ text });
358
+ i += text.length;
65
359
  }
66
- return { strings, consumed: i - start, complete: depth === 0 };
360
+ return { tokens: [], next: content.length, complete: false };
67
361
  }
68
- function transformCallContent(content, start, transform, isSuppressedAt) {
69
- const scanned = scanCallStrings(content, start);
70
- const original = content.slice(start, start + scanned.consumed);
71
- if (!scanned.complete) {
72
- return { processed: original, consumed: scanned.consumed, count: 0 };
362
+ function splitTokens(tokens, separator) {
363
+ const result = [[]];
364
+ for (const token of tokens) {
365
+ if (token.text === separator)
366
+ result.push([]);
367
+ else
368
+ result[result.length - 1].push(token);
73
369
  }
74
- const parts = [];
75
- let pos = start;
76
- let count = 0;
77
- for (const string of scanned.strings) {
78
- parts.push(content.slice(pos, string.start));
79
- const out = isSuppressedAt?.(string.start - 1)
80
- ? string.value
81
- : transform(string.value);
82
- if (out !== string.value)
83
- count++;
84
- parts.push(out);
85
- pos = string.end;
370
+ return result;
371
+ }
372
+ function objectProperties(token) {
373
+ if (token?.text !== '{' || !token.children)
374
+ return [];
375
+ return splitTokens(token.children, ',').flatMap((property) => property.length >= 3 &&
376
+ property[1].text === ':' &&
377
+ (property[0].string || /^[\w$]+$/.test(property[0].text))
378
+ ? [{ key: property[0], value: property.slice(2) }]
379
+ : []);
380
+ }
381
+ function propertyName(key) {
382
+ return key.string?.value ?? key.text;
383
+ }
384
+ function classExpression(tokens, functionNames, objects = true) {
385
+ if (tokens.length === 1) {
386
+ const token = tokens[0];
387
+ if (token.string)
388
+ return [token.string];
389
+ if (token.text === '[' && token.children)
390
+ return splitTokens(token.children, ',').flatMap((part) => classExpression(part, functionNames, objects));
391
+ if (token.text === '{' && objects) {
392
+ // Rewriting keys of a multi-property object can create a duplicate key
393
+ // and discard a predicate. Unknown/spread properties can collide too.
394
+ const properties = splitTokens(token.children ?? [], ',').filter((property) => property.length > 0);
395
+ if (properties.length !== 1)
396
+ return [];
397
+ return objectProperties(token).flatMap(({ key }) => key.string ? [key.string] : []);
398
+ }
399
+ // A grouping pair does not establish class context, but preserves one.
400
+ if (token.text === '(' && token.children)
401
+ return classExpression(token.children, functionNames, objects);
402
+ return [];
403
+ }
404
+ const question = tokens.findIndex(({ text }) => text === '?');
405
+ if (question >= 0) {
406
+ let nested = 0;
407
+ for (let i = question + 1; i < tokens.length; i++) {
408
+ if (tokens[i].text === '?')
409
+ nested++;
410
+ else if (tokens[i].text === ':' && nested-- === 0)
411
+ return [
412
+ ...classExpression(tokens.slice(question + 1, i), functionNames, objects),
413
+ ...classExpression(tokens.slice(i + 1), functionNames, objects),
414
+ ];
415
+ }
416
+ return [];
417
+ }
418
+ const logical = tokens.findIndex(({ text }) => text === '&&' || text === '||' || text === '??');
419
+ if (logical >= 0)
420
+ return [
421
+ ...(tokens[logical].text === '&&'
422
+ ? []
423
+ : classExpression(tokens.slice(0, logical), functionNames, objects)),
424
+ ...classExpression(tokens.slice(logical + 1), functionNames, objects),
425
+ ];
426
+ if (tokens.length === 2 &&
427
+ functionNames.includes(tokens[0].text) &&
428
+ tokens[1].text === '(' &&
429
+ tokens[1].children)
430
+ return classCall(tokens[0].text, tokens[1].children, functionNames);
431
+ return [];
432
+ }
433
+ function cvaOptions(token, functionNames) {
434
+ const result = [];
435
+ for (const { key, value } of objectProperties(token)) {
436
+ if (value.length !== 1)
437
+ continue;
438
+ if (propertyName(key) === 'variants') {
439
+ for (const variant of objectProperties(value[0])) {
440
+ if (variant.value.length !== 1)
441
+ continue;
442
+ for (const choice of objectProperties(variant.value[0]))
443
+ result.push(...classExpression(choice.value, functionNames, false));
444
+ }
445
+ }
446
+ else if (propertyName(key) === 'compoundVariants' &&
447
+ value[0].text === '[' &&
448
+ value[0].children) {
449
+ for (const entry of splitTokens(value[0].children, ',')) {
450
+ if (entry.length !== 1)
451
+ continue;
452
+ for (const compound of objectProperties(entry[0])) {
453
+ if (propertyName(compound.key) === 'class' ||
454
+ propertyName(compound.key) === 'className')
455
+ result.push(...classExpression(compound.value, functionNames, false));
456
+ }
457
+ }
458
+ }
86
459
  }
87
- parts.push(content.slice(pos, start + scanned.consumed));
88
- return { processed: parts.join(''), consumed: scanned.consumed, count };
460
+ return result;
461
+ }
462
+ function classCall(name, tokens, functionNames) {
463
+ const args = splitTokens(tokens, ',');
464
+ if (name === 'cva')
465
+ return [
466
+ ...classExpression(args[0] ?? [], functionNames, false),
467
+ ...(args[1]?.length === 1 ? cvaOptions(args[1][0], functionNames) : []),
468
+ ];
469
+ return args.flatMap((arg) => classExpression(arg, functionNames));
470
+ }
471
+ function scanCallStrings(content, start, functionNames = []) {
472
+ const scanned = readCallTokens(content, start);
473
+ const name = previousToken(content, start - 1) ?? '';
474
+ return {
475
+ strings: scanned.complete
476
+ ? classCall(name, scanned.tokens, functionNames)
477
+ : [],
478
+ consumed: scanned.next - start,
479
+ complete: scanned.complete,
480
+ };
89
481
  }
90
482
  function offsetSuppressor(content, isSuppressed) {
91
483
  if (!isSuppressed)
@@ -93,65 +485,199 @@ function offsetSuppressor(content, isSuppressed) {
93
485
  const positionAt = createPositionLookup(content);
94
486
  return (offset) => isSuppressed(positionAt(offset).line);
95
487
  }
96
- export function replaceClassStrings(content, transform, opts = {}) {
97
- const attrNames = opts.attributeNames?.length
98
- ? opts.attributeNames
99
- : DEFAULT_ATTR_NAMES;
100
- let result = content;
101
- let count = 0;
102
- const isSuppressed = opts.isSuppressed;
103
- const isAttributeSuppressed = offsetSuppressor(content, isSuppressed);
104
- result = result.replace(buildAttrRegex(attrNames), (_full, before, attr, open, bracedDq, bracedSq, bracedBt, close, dq, sq, bt, offset) => {
105
- const raw = bracedDq ?? bracedSq ?? bracedBt ?? dq ?? sq ?? bt ?? '';
106
- const q = bracedDq !== undefined || dq !== undefined
107
- ? '"'
108
- : bracedSq !== undefined || sq !== undefined
109
- ? "'"
110
- : '`';
111
- const attrOffset = offset + before.length;
112
- const out = isAttributeSuppressed?.(attrOffset) ? raw : transform(raw);
113
- if (out !== raw)
114
- count++;
115
- return `${before}${attr}=${open ?? ''}${q}${out}${q}${close ?? ''}`;
116
- });
117
- const funcNames = opts.functionNames;
118
- if (!funcNames?.length)
119
- return { result, count };
120
- const funcRe = new RegExp(`\\b(?:${funcNames.map(escapeRegex).join('|')})\\s*\\(`, 'g');
121
- const buf = [];
122
- let pos = 0;
123
- const isCallSuppressed = offsetSuppressor(result, isSuppressed);
124
- for (const m of result.matchAll(funcRe)) {
125
- const callStart = (m.index ?? 0) + m[0].length;
126
- if (callStart < pos)
488
+ // Ignore source comments, quoted prose and regex literals before looking for
489
+ // attribute/function entry points. Ambiguous syntax is deliberately skipped.
490
+ function protectedSourceOffsets(content) {
491
+ const ranges = [];
492
+ const tags = [];
493
+ const pendingTags = [];
494
+ let expressionEnd = 0;
495
+ const controlParens = [];
496
+ let lastControlParenEnd = -1;
497
+ let i = 0;
498
+ while (i < content.length) {
499
+ const start = i;
500
+ const ch = content[i];
501
+ const tag = pendingTags.at(0);
502
+ if (tag?.end === i) {
503
+ if (tag.closing)
504
+ tags.pop();
505
+ else if (!tag.selfClosing)
506
+ tags.push(tag.name);
507
+ pendingTags.length = 0;
508
+ i++;
127
509
  continue;
128
- buf.push(result.slice(pos, callStart));
129
- const { processed, consumed, count: c, } = transformCallContent(result, callStart, transform, isCallSuppressed);
130
- buf.push(processed);
131
- pos = callStart + consumed;
132
- count += c;
133
- funcRe.lastIndex = pos;
510
+ }
511
+ if (!tag && ch === '<' && !content.startsWith('<!--', i)) {
512
+ const match = content
513
+ .slice(i)
514
+ .match(/^<(\/?)(?:([A-Za-z][\w:.-]*)(?=[\s/>])|(?=>))/);
515
+ if (match) {
516
+ let end = i + match[0].length;
517
+ while (end < content.length && content[end] !== '>') {
518
+ if (content[end] === '"' ||
519
+ content[end] === "'" ||
520
+ content[end] === '`') {
521
+ end = readQuotedString(content, end)?.next ?? content.length;
522
+ }
523
+ else if (content[end] === '{') {
524
+ const expression = readCallTokens(content, end + 1, '}');
525
+ end = expression.next;
526
+ }
527
+ else
528
+ end++;
529
+ }
530
+ const closing = match[1] === '/';
531
+ const selfClosing = content[end - 1] === '/' ||
532
+ /^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/.test(match[2] ?? '');
533
+ const name = match[2] ?? '';
534
+ if (end < content.length &&
535
+ (tags.length > 0 ||
536
+ selfClosing ||
537
+ content.includes(`</${name}>`, end)))
538
+ pendingTags.push({ end, name, closing, selfClosing });
539
+ }
540
+ }
541
+ const rawTag = tags.at(-1)?.toLowerCase();
542
+ if (!tag && rawTag === 'style' && !content.startsWith('</style', i)) {
543
+ const end = content.indexOf('</style', i);
544
+ i = end < 0 ? content.length : end;
545
+ ranges.push({ start, end: i });
546
+ continue;
547
+ }
548
+ if (!tag &&
549
+ tags.length > 0 &&
550
+ rawTag !== 'script' &&
551
+ i >= expressionEnd &&
552
+ ch !== '<') {
553
+ if (ch === '{')
554
+ expressionEnd = readCallTokens(content, i + 1, '}').next;
555
+ else {
556
+ while (i < content.length && content[i] !== '<' && content[i] !== '{')
557
+ i++;
558
+ ranges.push({ start, end: i });
559
+ continue;
560
+ }
561
+ }
562
+ if (ch === '"' || ch === "'" || ch === '`') {
563
+ i = readQuotedString(content, i)?.next ?? content.length;
564
+ }
565
+ else if (content.startsWith('//', i)) {
566
+ const end = content.indexOf('\n', i + 2);
567
+ i = end < 0 ? content.length : end;
568
+ }
569
+ else if (content.startsWith('/*', i) || content.startsWith('<!--', i)) {
570
+ const html = content.startsWith('<!--', i);
571
+ const ending = html ? '-->' : '*/';
572
+ const end = content.indexOf(ending, i + (html ? 4 : 2));
573
+ i = end < 0 ? content.length : end + ending.length;
574
+ }
575
+ else if (ch === '/' && content[i - 1] !== '<' && content[i + 1] !== '>') {
576
+ const preceding = previousToken(content, i);
577
+ let before = i - 1;
578
+ while (before >= 0 && /\s/.test(content[before]))
579
+ before--;
580
+ const afterControlCondition = preceding === ')' && before === lastControlParenEnd;
581
+ const end = regexMayStart(preceding) || afterControlCondition
582
+ ? readRegex(content, i)
583
+ : null;
584
+ if (!end) {
585
+ i++;
586
+ continue;
587
+ }
588
+ i = end;
589
+ }
590
+ else {
591
+ if (ch === '(') {
592
+ controlParens.push(['if', 'while', 'for', 'with', 'switch', 'catch'].includes(previousToken(content, i) ?? ''));
593
+ }
594
+ else if (ch === ')' && controlParens.pop())
595
+ lastControlParenEnd = i;
596
+ i++;
597
+ continue;
598
+ }
599
+ ranges.push({ start, end: i });
134
600
  }
135
- buf.push(result.slice(pos));
136
- return { result: buf.join(''), count };
601
+ return (offset) => {
602
+ let low = 0;
603
+ let high = ranges.length;
604
+ while (low < high) {
605
+ const mid = (low + high) >>> 1;
606
+ if (ranges[mid].end <= offset)
607
+ low = mid + 1;
608
+ else
609
+ high = mid;
610
+ }
611
+ return low < ranges.length && ranges[low].start <= offset;
612
+ };
137
613
  }
138
- export function extractClassStrings(content, opts = {}) {
614
+ /** Collect disjoint ranges against the original source before any rewrite.
615
+ * Source offsets and suppression line numbers must not depend on an earlier
616
+ * attribute transform changing the number of lines in the file.
617
+ */
618
+ function collectClassStrings(content, opts) {
139
619
  const attrNames = opts.attributeNames?.length
140
620
  ? opts.attributeNames
141
621
  : DEFAULT_ATTR_NAMES;
142
- const results = [];
143
- for (const m of content.matchAll(buildAttrRegex(attrNames))) {
144
- const raw = m[4] ?? m[5] ?? m[6] ?? m[8] ?? m[9] ?? m[10] ?? '';
145
- const start = (m.index ?? 0) + m[0].lastIndexOf(raw);
146
- results.push({ value: raw, start, end: start + raw.length });
147
- }
622
+ const isProtected = protectedSourceOffsets(content);
623
+ const strings = attributeStrings(content, attrNames, opts.functionNames).filter((string) => !isProtected(string.attributeStart));
148
624
  const funcNames = opts.functionNames;
149
- if (!funcNames?.length)
150
- return results;
151
- const funcRe = new RegExp(`\\b(?:${funcNames.map(escapeRegex).join('|')})\\s*\\(`, 'g');
152
- for (const m of content.matchAll(funcRe)) {
153
- const start = (m.index ?? 0) + m[0].length;
154
- results.push(...scanCallStrings(content, start).strings);
625
+ if (funcNames?.length) {
626
+ const funcRe = new RegExp(`\\b(?:${funcNames.map(escapeRegex).join('|')})\\s*\\(`, 'g');
627
+ let consumedUntil = 0;
628
+ for (const match of content.matchAll(funcRe)) {
629
+ const offset = match.index ?? 0;
630
+ // A configured identifier must not select a suffix of a member call or
631
+ // a larger JavaScript identifier (where `$` is also an identifier char).
632
+ if (previousToken(content, offset) === '.' ||
633
+ /[\w$]/.test(content[offset - 1] ?? ''))
634
+ continue;
635
+ if (isProtected(offset) || offset < consumedUntil)
636
+ continue;
637
+ const start = (match.index ?? 0) + match[0].length;
638
+ const scanned = scanCallStrings(content, start, funcNames);
639
+ strings.push(...scanned.strings);
640
+ consumedUntil = start + scanned.consumed;
641
+ }
642
+ }
643
+ const seen = new Set();
644
+ return strings
645
+ .filter((string) => {
646
+ const key = `${string.start}:${string.end}`;
647
+ if (seen.has(key))
648
+ return false;
649
+ seen.add(key);
650
+ return true;
651
+ })
652
+ .sort((left, right) => left.start - right.start);
653
+ }
654
+ export function replaceClassStrings(content, transform, opts = {}) {
655
+ const isSuppressedAt = offsetSuppressor(content, opts.isSuppressed);
656
+ const parts = [];
657
+ let cursor = 0;
658
+ let count = 0;
659
+ for (const string of collectClassStrings(content, opts)) {
660
+ const out = transformStaticClasses(string.value, transform, isSuppressedAt, string.start, string.braceExpressions);
661
+ if (out !== string.value)
662
+ count++;
663
+ parts.push(content.slice(cursor, string.start), out);
664
+ cursor = string.end;
155
665
  }
156
- return results;
666
+ parts.push(content.slice(cursor));
667
+ return { result: parts.join(''), count };
668
+ }
669
+ export function extractClassStrings(content, opts = {}) {
670
+ return staticRanges(collectClassStrings(content, opts));
671
+ }
672
+ function staticRanges(strings) {
673
+ const seen = new Set();
674
+ return strings.flatMap((string) => staticClassRanges(string.value, string.braceExpressions).flatMap((range) => {
675
+ const start = string.start + range.start;
676
+ const end = string.start + range.end;
677
+ const key = `${start}:${end}`;
678
+ if (seen.has(key))
679
+ return [];
680
+ seen.add(key);
681
+ return [{ value: range.value, start, end }];
682
+ }));
157
683
  }