pulse-js-framework 1.11.3 → 1.11.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.
Files changed (48) hide show
  1. package/cli/analyze.js +21 -8
  2. package/cli/build.js +83 -56
  3. package/cli/dev.js +108 -94
  4. package/cli/docs-test.js +52 -33
  5. package/cli/index.js +81 -51
  6. package/cli/mobile.js +92 -40
  7. package/cli/release.js +64 -46
  8. package/cli/scaffold.js +14 -13
  9. package/compiler/lexer.js +55 -54
  10. package/compiler/parser/core.js +1 -0
  11. package/compiler/parser/state.js +6 -12
  12. package/compiler/parser/style.js +17 -20
  13. package/compiler/parser/view.js +1 -3
  14. package/compiler/preprocessor.js +124 -262
  15. package/compiler/sourcemap.js +10 -4
  16. package/compiler/transformer/expressions.js +122 -106
  17. package/compiler/transformer/index.js +2 -4
  18. package/compiler/transformer/style.js +74 -7
  19. package/compiler/transformer/view.js +86 -36
  20. package/loader/esbuild-plugin-server-components.js +209 -0
  21. package/loader/esbuild-plugin.js +41 -93
  22. package/loader/parcel-plugin.js +37 -97
  23. package/loader/rollup-plugin-server-components.js +30 -169
  24. package/loader/rollup-plugin.js +27 -78
  25. package/loader/shared.js +362 -0
  26. package/loader/swc-plugin.js +65 -82
  27. package/loader/vite-plugin-server-components.js +30 -171
  28. package/loader/vite-plugin.js +25 -10
  29. package/loader/webpack-loader-server-components.js +21 -134
  30. package/loader/webpack-loader.js +25 -80
  31. package/package.json +52 -12
  32. package/runtime/dom-selector.js +2 -1
  33. package/runtime/form.js +4 -3
  34. package/runtime/http.js +6 -1
  35. package/runtime/logger.js +44 -24
  36. package/runtime/router/utils.js +14 -7
  37. package/runtime/security.js +13 -1
  38. package/runtime/server-components/actions-server.js +23 -19
  39. package/runtime/server-components/error-sanitizer.js +18 -18
  40. package/runtime/server-components/security.js +41 -24
  41. package/runtime/ssr-preload.js +5 -3
  42. package/runtime/testing.js +759 -0
  43. package/runtime/utils.js +3 -2
  44. package/server/utils.js +15 -9
  45. package/sw/index.js +2 -0
  46. package/types/loaders.d.ts +1043 -0
  47. package/compiler/parser/_extract.js +0 -393
  48. package/loader/README.md +0 -509
@@ -14,6 +14,15 @@ import {
14
14
  STATEMENT_END_TYPES
15
15
  } from './constants.js';
16
16
 
17
+ /**
18
+ * Escape special regex characters in a string
19
+ * @param {string} str - String to escape
20
+ * @returns {string} Escaped string safe for use in RegExp
21
+ */
22
+ function escapeRegExp(str) {
23
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
24
+ }
25
+
17
26
  /**
18
27
  * Transform AST expression to JS code
19
28
  * @param {Object} transformer - Transformer instance
@@ -186,18 +195,26 @@ export function transformExpressionString(transformer, exprStr) {
186
195
  // Both are now reactive (useProp returns computed for uniform interface)
187
196
  let result = exprStr;
188
197
 
189
- // First, handle assignments to state vars: stateVar = expr -> stateVar.set(expr)
190
- // This must happen before the generic .get() replacement to avoid generating
191
- // invalid code like stateVar.get() = expr (LHS of assignment is not a reference)
192
- for (const stateVar of transformer.stateVars) {
198
+ // Build combined patterns once for all variables (O(1) regex compilations instead of O(n))
199
+ const stateVarsArr = [...transformer.stateVars];
200
+ const propVarsArr = [...transformer.propVars];
201
+
202
+ if (stateVarsArr.length > 0) {
203
+ const stateAlt = stateVarsArr.map(escapeRegExp).join('|');
204
+
205
+ // First, handle assignments to state vars: stateVar = expr -> stateVar.set(expr)
206
+ // This must happen before the generic .get() replacement to avoid generating
207
+ // invalid code like stateVar.get() = expr (LHS of assignment is not a reference)
208
+
193
209
  // Compound assignment: stateVar += expr -> stateVar.update(_v => _v + expr)
194
- // Use bracket-balancing to find the end of the RHS expression
195
- const compoundPattern = new RegExp(`\\b${stateVar}\\s*(\\+=|-=|\\*=|\\/=|&&=|\\|\\|=|\\?\\?=)\\s*`, 'g');
210
+ // Single combined regex for all state vars
211
+ const compoundPattern = new RegExp(`\\b(${stateAlt})\\s*(\\+=|-=|\\*=|\\/=|&&=|\\|\\|=|\\?\\?=)\\s*`, 'g');
196
212
  let compoundMatch;
197
213
  const compoundReplacements = [];
198
214
 
199
215
  while ((compoundMatch = compoundPattern.exec(result)) !== null) {
200
- const op = compoundMatch[1];
216
+ const varName = compoundMatch[1];
217
+ const op = compoundMatch[2];
201
218
  const baseOp = op.slice(0, -1);
202
219
  const rhsStart = compoundMatch.index + compoundMatch[0].length;
203
220
 
@@ -220,7 +237,7 @@ export function transformExpressionString(transformer, exprStr) {
220
237
  compoundReplacements.push({
221
238
  start: compoundMatch.index,
222
239
  end: endIdx,
223
- replacement: `${stateVar}.update(_v => _v ${baseOp} ${rhs})`
240
+ replacement: `${varName}.update(_v => _v ${baseOp} ${rhs})`
224
241
  });
225
242
  }
226
243
  }
@@ -232,12 +249,13 @@ export function transformExpressionString(transformer, exprStr) {
232
249
  }
233
250
 
234
251
  // Simple assignment: stateVar = expr -> stateVar.set(expr)
235
- // Use bracket-balancing to find the end of the RHS expression
236
- const simplePattern = new RegExp(`\\b${stateVar}\\s*=(?!=)\\s*`, 'g');
252
+ // Single combined regex for all state vars
253
+ const simplePattern = new RegExp(`\\b(${stateAlt})\\s*=(?!=)\\s*`, 'g');
237
254
  let simpleMatch;
238
255
  const simpleReplacements = [];
239
256
 
240
257
  while ((simpleMatch = simplePattern.exec(result)) !== null) {
258
+ const varName = simpleMatch[1];
241
259
  const rhsStart = simpleMatch.index + simpleMatch[0].length;
242
260
 
243
261
  let depth = 0;
@@ -258,7 +276,7 @@ export function transformExpressionString(transformer, exprStr) {
258
276
  simpleReplacements.push({
259
277
  start: simpleMatch.index,
260
278
  end: endIdx,
261
- replacement: `${stateVar}.set(${rhs})`
279
+ replacement: `${varName}.set(${rhs})`
262
280
  });
263
281
  }
264
282
  }
@@ -268,31 +286,31 @@ export function transformExpressionString(transformer, exprStr) {
268
286
  const r = simpleReplacements[i];
269
287
  result = result.slice(0, r.start) + r.replacement + result.slice(r.end);
270
288
  }
271
- }
272
289
 
273
- // Transform state var reads (not already transformed to .get/.set/.update)
274
- for (const stateVar of transformer.stateVars) {
290
+ // Transform state var reads (not already transformed to .get/.set/.update)
291
+ // Single combined regex for all state vars
275
292
  result = result.replace(
276
- new RegExp(`\\b${stateVar}\\b(?!\\.(?:get|set|update))`, 'g'),
277
- `${stateVar}.get()`
293
+ new RegExp(`\\b(${stateAlt})\\b(?!\\.(?:get|set|update))`, 'g'),
294
+ '$1.get()'
278
295
  );
279
296
  }
280
297
 
281
298
  // Transform prop vars (now also reactive via useProp)
282
299
  // Add optional chaining when followed by property access for nullable props
283
300
  // Props commonly receive null values (e.g., notification: null)
284
- for (const propVar of transformer.propVars) {
301
+ if (propVarsArr.length > 0) {
302
+ const propAlt = propVarsArr.map(escapeRegExp).join('|');
285
303
  // Property access: propVar.x -> propVar.get()?.x
286
304
  // Guard against already-transformed: skip if followed by .get( or .set(
287
305
  result = result.replace(
288
- new RegExp(`\\b${propVar}\\b(?=\\.(?!get\\(|set\\())`, 'g'),
289
- `${propVar}.get()?`
306
+ new RegExp(`\\b(${propAlt})\\b(?=\\.(?!get\\(|set\\())`, 'g'),
307
+ '$1.get()?'
290
308
  );
291
309
  // Handle standalone prop var (not followed by property access)
292
310
  // Guard: skip if already followed by .get or .set
293
311
  result = result.replace(
294
- new RegExp(`\\b${propVar}\\b(?!\\.(?:get|set)\\()(?!\\.)`, 'g'),
295
- `${propVar}.get()`
312
+ new RegExp(`\\b(${propAlt})\\b(?!\\.(?:get|set)\\()(?!\\.)`, 'g'),
313
+ '$1.get()'
296
314
  );
297
315
  }
298
316
 
@@ -420,12 +438,30 @@ export function transformFunctionBody(transformer, tokens) {
420
438
  // Protect string literals from state var replacement
421
439
  const stringPlaceholders = [];
422
440
  const protectStrings = (str) => {
423
- // Match strings and template literals, handling escapes
424
- return str.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, (match) => {
425
- const index = stringPlaceholders.length;
426
- stringPlaceholders.push(match);
427
- return `__STRING_${index}__`;
428
- });
441
+ // Linear scan to find and replace string/template literals with placeholders.
442
+ // Avoids regex to prevent any ReDoS risk from adversarial input.
443
+ let result = '';
444
+ let i = 0;
445
+ while (i < str.length) {
446
+ const ch = str[i];
447
+ if (ch === '"' || ch === "'" || ch === '`') {
448
+ const quote = ch;
449
+ let j = i + 1;
450
+ while (j < str.length) {
451
+ if (str[j] === '\\') { j += 2; continue; }
452
+ if (str[j] === quote) { j++; break; }
453
+ j++;
454
+ }
455
+ const index = stringPlaceholders.length;
456
+ stringPlaceholders.push(str.slice(i, j));
457
+ result += `__STRING_${index}__`;
458
+ i = j;
459
+ } else {
460
+ result += ch;
461
+ i++;
462
+ }
463
+ }
464
+ return result;
429
465
  };
430
466
  const restoreStrings = (str) => {
431
467
  return str.replace(/__STRING_(\d+)__/g, (_, index) => stringPlaceholders[parseInt(index)]);
@@ -434,19 +470,27 @@ export function transformFunctionBody(transformer, tokens) {
434
470
  // Protect strings before transformations
435
471
  code = protectStrings(code);
436
472
 
437
- // Build patterns for state variable transformation
438
- const stateVarPattern = [...stateVars].join('|');
439
- const funcPattern = [...actionNames, ...BUILTIN_FUNCTIONS].join('|');
440
- const keywordsPattern = [...STATEMENT_KEYWORDS].join('|');
473
+ // Build patterns for state variable transformation (precompiled once)
474
+ const stateVarPattern = [...stateVars].map(escapeRegExp).join('|');
475
+ const funcPattern = [...actionNames, ...BUILTIN_FUNCTIONS].map(escapeRegExp).join('|');
476
+ const keywordsPattern = [...STATEMENT_KEYWORDS].map(escapeRegExp).join('|');
477
+
478
+ // Precompile boundary regex once (was O(n²) when created inside inner loop)
479
+ const keywordBoundary = new RegExp(`^\\s+(?:(?:${stateVarPattern})\\s*=(?!=)|(?:${keywordsPattern}|await|return)\\b|(?:${funcPattern})\\s*\\()`);
480
+
481
+ // Combined assignment pattern for all state vars (single regex instead of N)
482
+ const assignPattern = stateVars.size > 0
483
+ ? new RegExp(`\\b(${stateVarPattern})\\s*=(?!=)`, 'g')
484
+ : null;
441
485
 
442
486
  // Transform state var assignments: stateVar = value -> stateVar.set(value)
443
487
  // Match assignment and find end by tracking balanced brackets
444
- for (const stateVar of stateVars) {
445
- const pattern = new RegExp(`\\b${stateVar}\\s*=(?!=)`, 'g');
488
+ if (assignPattern) {
446
489
  let match;
447
490
  const replacements = [];
448
491
 
449
- while ((match = pattern.exec(code)) !== null) {
492
+ while ((match = assignPattern.exec(code)) !== null) {
493
+ const stateVar = match[1];
450
494
  const startIdx = match.index + match[0].length;
451
495
 
452
496
  // Skip whitespace
@@ -508,7 +552,6 @@ export function transformFunctionBody(transformer, tokens) {
508
552
  // Check for whitespace followed by keyword/identifier that starts a new statement
509
553
  if (/\s/.test(ch)) {
510
554
  const rest = code.slice(i);
511
- const keywordBoundary = new RegExp(`^\\s+(?:(?:${stateVarPattern})\\s*=(?!=)|(?:${keywordsPattern}|await|return)\\b|(?:${funcPattern})\\s*\\()`);
512
555
  if (keywordBoundary.test(rest)) {
513
556
  break;
514
557
  }
@@ -539,99 +582,72 @@ export function transformFunctionBody(transformer, tokens) {
539
582
  code = code.replace(/;+/g, ';');
540
583
  code = code.replace(/; ;/g, ';');
541
584
 
542
- // Handle post-increment/decrement on state vars: stateVar++ -> ((v) => (stateVar.set(v + 1), v))(stateVar.get())
543
- for (const stateVar of stateVars) {
585
+ // Handle post-increment/decrement on state vars
586
+ // Combined regex for all state vars (single pass instead of 4N passes)
587
+ if (stateVarPattern) {
544
588
  // Post-increment: stateVar++ (returns old value)
545
589
  code = code.replace(
546
- new RegExp(`\\b${stateVar}\\s*\\+\\+`, 'g'),
547
- `((v) => (${stateVar}.set(v + 1), v))(${stateVar}.get())`
590
+ new RegExp(`\\b(${stateVarPattern})\\s*\\+\\+`, 'g'),
591
+ (_, v) => `((v) => (${v}.set(v + 1), v))(${v}.get())`
548
592
  );
549
593
  // Post-decrement: stateVar-- (returns old value)
550
594
  code = code.replace(
551
- new RegExp(`\\b${stateVar}\\s*--`, 'g'),
552
- `((v) => (${stateVar}.set(v - 1), v))(${stateVar}.get())`
595
+ new RegExp(`\\b(${stateVarPattern})\\s*--`, 'g'),
596
+ (_, v) => `((v) => (${v}.set(v - 1), v))(${v}.get())`
553
597
  );
554
598
  // Pre-increment: ++stateVar (returns new value)
555
599
  code = code.replace(
556
- new RegExp(`\\+\\+\\s*${stateVar}\\b`, 'g'),
557
- `(${stateVar}.set(${stateVar}.get() + 1), ${stateVar}.get())`
600
+ new RegExp(`\\+\\+\\s*(${stateVarPattern})\\b`, 'g'),
601
+ (_, v) => `(${v}.set(${v}.get() + 1), ${v}.get())`
558
602
  );
559
603
  // Pre-decrement: --stateVar (returns new value)
560
604
  code = code.replace(
561
- new RegExp(`--\\s*${stateVar}\\b`, 'g'),
562
- `(${stateVar}.set(${stateVar}.get() - 1), ${stateVar}.get())`
605
+ new RegExp(`--\\s*(${stateVarPattern})\\b`, 'g'),
606
+ (_, v) => `(${v}.set(${v}.get() - 1), ${v}.get())`
563
607
  );
564
608
  }
565
609
 
610
+ // Helper: check if match at offset is an object key (shared by state/prop reads)
611
+ const isObjectKey = (str, match, offset) => {
612
+ const after = str.slice(offset + match.length, offset + match.length + 10);
613
+ if (!/^\s*:(?!:)/.test(after)) return false;
614
+ let depth = 0;
615
+ for (let i = offset - 1; i >= 0; i--) {
616
+ const ch = str[i];
617
+ if (ch === ')' || ch === ']') depth++;
618
+ else if (ch === '(' || ch === '[') depth--;
619
+ else if (ch === '}') depth++;
620
+ else if (ch === '{') {
621
+ if (depth === 0) return true;
622
+ depth--;
623
+ }
624
+ else if (ch === ',' && depth === 0) return true;
625
+ else if (ch === ';' && depth === 0) break;
626
+ }
627
+ return false;
628
+ };
629
+
566
630
  // Replace state var reads (not in assignments, not already with .get/.set)
567
- // Allow spread operators (...stateVar) but block member access (obj.stateVar)
568
- // Skip object literal keys (e.g., { users: value } - don't transform the key 'users')
569
- for (const stateVar of stateVars) {
631
+ // Combined regex for all state vars (single pass instead of N passes)
632
+ if (stateVarPattern) {
570
633
  code = code.replace(
571
- new RegExp(`(?:(?<=\\.\\.\\.)|(?<!\\.))\\b${stateVar}\\b(?!\\s*=(?!=)|\\s*\\(|\\s*\\.(?:get|set))`, 'g'),
572
- (match, offset) => {
573
- // Check if this is an object key by looking at context
574
- // Pattern: after { or , and before : (with arbitrary content between)
575
- const after = code.slice(offset + match.length, offset + match.length + 10);
576
-
577
- // If followed by : (not ::), check if it's an object key
578
- if (/^\s*:(?!:)/.test(after)) {
579
- // Look backwards for the nearest { or , that would indicate object context
580
- // We need to track bracket depth to handle nested structures
581
- let depth = 0;
582
- for (let i = offset - 1; i >= 0; i--) {
583
- const ch = code[i];
584
- if (ch === ')' || ch === ']') depth++;
585
- else if (ch === '(' || ch === '[') depth--;
586
- else if (ch === '}') depth++;
587
- else if (ch === '{') {
588
- if (depth === 0) {
589
- // Found opening brace at same depth - this is an object key
590
- return match;
591
- }
592
- depth--;
593
- }
594
- else if (ch === ',' && depth === 0) {
595
- // Found comma at same depth - this is an object key
596
- return match;
597
- }
598
- // Stop if we hit a semicolon at depth 0 (different statement)
599
- else if (ch === ';' && depth === 0) break;
600
- }
601
- }
602
-
603
- return `${stateVar}.get()`;
634
+ new RegExp(`(?:(?<=\\.\\.\\.)|(?<!\\.))\\b(${stateVarPattern})\\b(?!\\s*=(?!=)|\\s*\\(|\\s*\\.(?:get|set))`, 'g'),
635
+ (match, varName, offset) => {
636
+ if (isObjectKey(code, match, offset)) return match;
637
+ return `${varName}.get()`;
604
638
  }
605
639
  );
606
640
  }
607
641
 
608
642
  // Replace prop var reads (props are reactive via useProp, need .get() like state vars)
609
- // For props: allow function calls (prop callbacks need .get()() pattern)
610
- // Skip object keys, allow spreads, block member access
611
- for (const propVar of propVars) {
643
+ // Combined regex for all prop vars (single pass instead of N passes)
644
+ if (propVars.size > 0) {
645
+ const propVarPattern = [...propVars].map(escapeRegExp).join('|');
612
646
  code = code.replace(
613
- new RegExp(`(?:(?<=\\.\\.\\.)|(?<!\\.))\\b${propVar}\\b(?!\\s*=(?!=)|\\s*\\.(?:get|set))`, 'g'),
614
- (match, offset) => {
615
- // Check if this is an object key
616
- const after = code.slice(offset + match.length, offset + match.length + 10);
617
-
618
- if (/^\s*:(?!:)/.test(after)) {
619
- let depth = 0;
620
- for (let i = offset - 1; i >= 0; i--) {
621
- const ch = code[i];
622
- if (ch === ')' || ch === ']') depth++;
623
- else if (ch === '(' || ch === '[') depth--;
624
- else if (ch === '}') depth++;
625
- else if (ch === '{') {
626
- if (depth === 0) return match;
627
- depth--;
628
- }
629
- else if (ch === ',' && depth === 0) return match;
630
- else if (ch === ';' && depth === 0) break;
631
- }
632
- }
633
-
634
- return `${propVar}.get()`;
647
+ new RegExp(`(?:(?<=\\.\\.\\.)|(?<!\\.))\\b(${propVarPattern})\\b(?!\\s*=(?!=)|\\s*\\.(?:get|set))`, 'g'),
648
+ (match, varName, offset) => {
649
+ if (isObjectKey(code, match, offset)) return match;
650
+ return `${varName}.get()`;
635
651
  }
636
652
  );
637
653
  }
@@ -144,10 +144,8 @@ export class Transformer {
144
144
  // Check directives for a11y usage
145
145
  if (node.directives) {
146
146
  for (const directive of node.directives) {
147
- if (directive.type === 'A11yDirective') {
148
- if (directive.attrs && directive.attrs.srOnly) {
149
- this.usesA11y.srOnly = true;
150
- }
147
+ if (directive.type === 'SrOnlyDirective') {
148
+ this.usesA11y.srOnly = true;
151
149
  } else if (directive.type === 'FocusTrapDirective') {
152
150
  this.usesA11y.trapFocus = true;
153
151
  }
@@ -113,7 +113,7 @@ function isConditionalGroupAtRule(selector) {
113
113
  */
114
114
  function isKeyframeStep(selector) {
115
115
  const trimmed = selector.trim();
116
- return trimmed === 'from' || trimmed === 'to' || /^\d+%$/.test(trimmed);
116
+ return trimmed === 'from' || trimmed === 'to' || /^\d+(\.\d+)?%$/.test(trimmed);
117
117
  }
118
118
 
119
119
  /**
@@ -288,6 +288,70 @@ export function flattenStyleRule(transformer, rule, parentSelector, output, atRu
288
288
  }
289
289
  }
290
290
 
291
+ /**
292
+ * Split a CSS selector into tokens, preserving combinators (>, +, ~) as separate tokens.
293
+ * Uses a linear scan instead of a regex split to avoid ReDoS on inputs with many spaces.
294
+ * @param {string} selector - A single CSS selector (no commas)
295
+ * @returns {string[]} Array of tokens (selectors and combinators)
296
+ */
297
+ function splitSelectorTokens(selector) {
298
+ const tokens = [];
299
+ let current = '';
300
+ let i = 0;
301
+
302
+ while (i < selector.length) {
303
+ const ch = selector[i];
304
+
305
+ // Check for combinator characters
306
+ if (ch === '>' || ch === '+' || ch === '~') {
307
+ // Push any accumulated selector token
308
+ if (current.trim()) {
309
+ tokens.push(current.trim());
310
+ current = '';
311
+ }
312
+ // Push the combinator (with surrounding spaces for formatting)
313
+ tokens.push(` ${ch} `);
314
+ i++;
315
+ // Skip trailing whitespace after combinator
316
+ while (i < selector.length && /\s/.test(selector[i])) i++;
317
+ continue;
318
+ }
319
+
320
+ // Check for whitespace (descendant combinator)
321
+ if (/\s/.test(ch)) {
322
+ // Consume all consecutive whitespace
323
+ while (i < selector.length && /\s/.test(selector[i])) i++;
324
+ // Check if whitespace is followed by a combinator
325
+ if (i < selector.length && (selector[i] === '>' || selector[i] === '+' || selector[i] === '~')) {
326
+ // Don't push a space token; the combinator loop iteration will handle it
327
+ if (current.trim()) {
328
+ tokens.push(current.trim());
329
+ current = '';
330
+ }
331
+ continue;
332
+ }
333
+ // Plain whitespace = descendant combinator (space between selectors)
334
+ if (current.trim()) {
335
+ tokens.push(current.trim());
336
+ current = '';
337
+ }
338
+ // Push an empty string that will become a space separator
339
+ tokens.push(' ');
340
+ continue;
341
+ }
342
+
343
+ current += ch;
344
+ i++;
345
+ }
346
+
347
+ // Push final token
348
+ if (current.trim()) {
349
+ tokens.push(current.trim());
350
+ }
351
+
352
+ return tokens.filter(t => t !== '');
353
+ }
354
+
291
355
  /**
292
356
  * Add scope to CSS selector
293
357
  * .container -> .container.p123abc
@@ -327,22 +391,25 @@ export function scopeStyleSelector(transformer, selector) {
327
391
  return selector.split(',').map(part => {
328
392
  part = part.trim();
329
393
 
330
- // Split by whitespace but preserve combinators
331
- // This regex splits on whitespace but keeps combinators as separate tokens
332
- const tokens = part.split(/(\s*[>+~]\s*|\s+)/).filter(t => t.trim());
394
+ // Split selector into tokens, preserving CSS combinators (>, +, ~).
395
+ // Uses a programmatic approach instead of a single regex to avoid ReDoS.
396
+ const tokens = splitSelectorTokens(part);
333
397
  const result = [];
334
398
 
335
399
  for (let i = 0; i < tokens.length; i++) {
336
400
  const token = tokens[i].trim();
337
401
 
338
- // Check if this is a combinator
402
+ // Check if this is a combinator (>, +, ~)
339
403
  if (combinators.has(token)) {
340
404
  result.push(` ${token} `);
341
405
  continue;
342
406
  }
343
407
 
344
- // Skip empty tokens
345
- if (!token) continue;
408
+ // Whitespace-only token = descendant combinator (space between selectors)
409
+ if (!token) {
410
+ result.push(' ');
411
+ continue;
412
+ }
346
413
 
347
414
  // Check if this segment is a global selector
348
415
  const segmentBase = token.split(/[.#\[]/)[0];