pulse-js-framework 1.7.33 → 1.7.38

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.
@@ -80,6 +80,9 @@ function globMatch(base, pattern, extensions) {
80
80
  match(dir, partIndex + 1);
81
81
  try {
82
82
  for (const entry of readdirSync(dir)) {
83
+ // Skip hidden files and node_modules
84
+ if (entry.startsWith('.') || entry === 'node_modules') continue;
85
+
83
86
  const full = join(dir, entry);
84
87
  try {
85
88
  if (statSync(full).isDirectory()) {
@@ -153,6 +156,9 @@ function matchFilesInDirRecursive(dir, pattern, extensions, results) {
153
156
  function walk(currentDir) {
154
157
  try {
155
158
  for (const entry of readdirSync(currentDir)) {
159
+ // Skip hidden files and node_modules
160
+ if (entry.startsWith('.') || entry === 'node_modules') continue;
161
+
156
162
  const full = join(currentDir, entry);
157
163
  try {
158
164
  const stat = statSync(full);
@@ -100,7 +100,9 @@ export class Parser {
100
100
  * Peek at token at offset
101
101
  */
102
102
  peek(offset = 1) {
103
- return this.tokens[this.pos + offset];
103
+ const index = this.pos + offset;
104
+ if (index < 0 || index >= this.tokens.length) return undefined;
105
+ return this.tokens[index];
104
106
  }
105
107
 
106
108
  /**
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  /** Generate a unique scope ID for CSS scoping */
8
- export const generateScopeId = () => 'p' + Math.random().toString(36).substring(2, 8);
8
+ export const generateScopeId = () => 'p' + Math.random().toString(36).substring(2, 12);
9
9
 
10
10
  /** Token types that should not have space after them */
11
11
  export const NO_SPACE_AFTER = new Set([
@@ -182,14 +182,54 @@ export function transformExpression(transformer, node) {
182
182
  * @returns {string} Transformed expression string
183
183
  */
184
184
  export function transformExpressionString(transformer, exprStr) {
185
- // Simple transformation: wrap state and prop vars with .get()
185
+ // Transform state and prop vars in expression strings (interpolations, attribute bindings)
186
186
  // Both are now reactive (useProp returns computed for uniform interface)
187
187
  let result = exprStr;
188
188
 
189
- // Transform state vars
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)
190
192
  for (const stateVar of transformer.stateVars) {
193
+ // Compound assignment: stateVar += expr -> stateVar.update(_v => _v + expr)
191
194
  result = result.replace(
192
- new RegExp(`\\b${stateVar}\\b`, 'g'),
195
+ new RegExp(`\\b${stateVar}\\s*(\\+=|-=|\\*=|\\/=|&&=|\\|\\|=|\\?\\?=)\\s*`, 'g'),
196
+ (_match, op) => {
197
+ const baseOp = op.slice(0, -1); // Remove trailing '='
198
+ return `${stateVar}.update(_v => _v ${baseOp} `;
199
+ }
200
+ );
201
+ // Close the .update() call - find the end of the expression after the replacement
202
+ // This is handled by the fact that the expression continues after the replacement text
203
+ // and the closing paren is added by wrapping logic below.
204
+
205
+ // Simple assignment: stateVar = expr -> stateVar.set(expr)
206
+ // Use negative lookbehind to skip compound assignments (already handled)
207
+ // Use negative lookahead to skip == and ===
208
+ result = result.replace(
209
+ new RegExp(`\\b${stateVar}\\s*=(?!=)`, 'g'),
210
+ `${stateVar}.set(`
211
+ );
212
+ }
213
+
214
+ // If we inserted .set( or .update(, we need to close the parenthesis
215
+ // Find unclosed .set( and .update( calls and close them at end of expression
216
+ if (result.includes('.set(') || result.includes('.update(_v =>')) {
217
+ // For .update(_v => _v op expr), close with )
218
+ result = result.replace(
219
+ /\.update\(_v => _v [^\)]*$/,
220
+ (m) => m + ')'
221
+ );
222
+ // For .set(expr), close with )
223
+ result = result.replace(
224
+ /\.set\(([^)]*$)/,
225
+ (_m, expr) => `.set(${expr})`
226
+ );
227
+ }
228
+
229
+ // Transform state var reads (not already transformed to .get/.set/.update)
230
+ for (const stateVar of transformer.stateVars) {
231
+ result = result.replace(
232
+ new RegExp(`\\b${stateVar}\\b(?!\\.(?:get|set|update))`, 'g'),
193
233
  `${stateVar}.get()`
194
234
  );
195
235
  }
@@ -146,7 +146,7 @@ export default function pulsePlugin(options = {}) {
146
146
  compressed: sassOptions.compressed || false
147
147
  });
148
148
 
149
- if (preprocessed.wasSass) {
149
+ if (preprocessed.preprocessor !== 'none') {
150
150
  css = preprocessed.css;
151
151
  }
152
152
  } catch (sassError) {
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.7.33",
3
+ "version": "1.7.38",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
+ "sideEffects": false,
6
7
  "main": "index.js",
7
8
  "types": "types/index.d.ts",
8
9
  "bin": {
@@ -178,6 +179,11 @@
178
179
  "test:esbuild-plugin": "node test/esbuild-plugin.test.js",
179
180
  "test:parcel-plugin": "node test/parcel-plugin.test.js",
180
181
  "test:swc-plugin": "node test/swc-plugin.test.js",
182
+ "test:dom-binding": "node test/dom-binding.test.js",
183
+ "test:interceptor-manager": "node test/interceptor-manager.test.js",
184
+ "test:vite-plugin": "node --test test/vite-plugin.test.js",
185
+ "test:memory-cleanup": "node --test test/memory-cleanup.test.js",
186
+ "test:dev-server": "node --test test/dev-server.test.js",
181
187
  "build:netlify": "node scripts/build-netlify.js",
182
188
  "version": "node scripts/sync-version.js",
183
189
  "docs": "node cli/index.js dev docs"
package/runtime/a11y.js CHANGED
@@ -523,44 +523,39 @@ export function createPreferences() {
523
523
  const forcedColors = pulse(forcedColorsMode());
524
524
  const contrast = pulse(prefersContrast());
525
525
 
526
- if (typeof window !== 'undefined') {
527
- // Listen for preference changes
528
- window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
529
- reducedMotion.set(e.matches);
530
- });
531
-
532
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
533
- colorScheme.set(e.matches ? 'dark' : 'light');
534
- });
535
-
536
- window.matchMedia('(prefers-contrast: more)').addEventListener('change', (e) => {
537
- highContrast.set(e.matches);
538
- });
526
+ const listeners = [];
539
527
 
540
- window.matchMedia('(prefers-reduced-transparency: reduce)').addEventListener('change', (e) => {
541
- reducedTransparency.set(e.matches);
542
- });
543
-
544
- window.matchMedia('(forced-colors: active)').addEventListener('change', (e) => {
545
- forcedColors.set(e.matches ? 'active' : 'none');
546
- });
547
-
548
- // More granular contrast detection
549
- window.matchMedia('(prefers-contrast: more)').addEventListener('change', () => {
550
- contrast.set(prefersContrast());
551
- });
552
- window.matchMedia('(prefers-contrast: less)').addEventListener('change', () => {
553
- contrast.set(prefersContrast());
554
- });
528
+ if (typeof window !== 'undefined') {
529
+ const track = (query, handler) => {
530
+ const mql = window.matchMedia(query);
531
+ mql.addEventListener('change', handler);
532
+ listeners.push({ mql, handler });
533
+ };
534
+
535
+ track('(prefers-reduced-motion: reduce)', (e) => reducedMotion.set(e.matches));
536
+ track('(prefers-color-scheme: dark)', (e) => colorScheme.set(e.matches ? 'dark' : 'light'));
537
+ track('(prefers-contrast: more)', (e) => highContrast.set(e.matches));
538
+ track('(prefers-reduced-transparency: reduce)', (e) => reducedTransparency.set(e.matches));
539
+ track('(forced-colors: active)', (e) => forcedColors.set(e.matches ? 'active' : 'none'));
540
+ track('(prefers-contrast: more)', () => contrast.set(prefersContrast()));
541
+ track('(prefers-contrast: less)', () => contrast.set(prefersContrast()));
555
542
  }
556
543
 
544
+ const cleanup = () => {
545
+ for (const { mql, handler } of listeners) {
546
+ mql.removeEventListener('change', handler);
547
+ }
548
+ listeners.length = 0;
549
+ };
550
+
557
551
  return {
558
552
  reducedMotion,
559
553
  colorScheme,
560
554
  highContrast,
561
555
  reducedTransparency,
562
556
  forcedColors,
563
- contrast
557
+ contrast,
558
+ cleanup
564
559
  };
565
560
  }
566
561
 
@@ -1570,27 +1565,43 @@ export function createAnnouncementQueue(options = {}) {
1570
1565
 
1571
1566
  const queue = [];
1572
1567
  let isProcessing = false;
1568
+ let currentTimerId = null;
1569
+ let aborted = false;
1573
1570
  const queueLength = pulse(0);
1574
1571
 
1575
1572
  const processQueue = async () => {
1576
- if (isProcessing || queue.length === 0) return;
1573
+ if (isProcessing || queue.length === 0 || aborted) return;
1577
1574
 
1578
1575
  isProcessing = true;
1579
1576
 
1580
- while (queue.length > 0) {
1577
+ while (queue.length > 0 && !aborted) {
1581
1578
  const { message, priority, clearAfter } = queue.shift();
1582
1579
  queueLength.set(queue.length);
1583
1580
 
1584
1581
  announce(message, { priority, clearAfter });
1585
1582
 
1586
1583
  // Wait for announcement to be read
1587
- await new Promise(resolve => setTimeout(resolve,
1588
- Math.max(minDelay, clearAfter || 1000)));
1584
+ await new Promise(resolve => {
1585
+ currentTimerId = setTimeout(resolve,
1586
+ Math.max(minDelay, clearAfter || 1000));
1587
+ });
1588
+ currentTimerId = null;
1589
1589
  }
1590
1590
 
1591
1591
  isProcessing = false;
1592
1592
  };
1593
1593
 
1594
+ const dispose = () => {
1595
+ aborted = true;
1596
+ if (currentTimerId !== null) {
1597
+ clearTimeout(currentTimerId);
1598
+ currentTimerId = null;
1599
+ }
1600
+ queue.length = 0;
1601
+ queueLength.set(0);
1602
+ isProcessing = false;
1603
+ };
1604
+
1594
1605
  return {
1595
1606
  queueLength,
1596
1607
  /**
@@ -1599,6 +1610,7 @@ export function createAnnouncementQueue(options = {}) {
1599
1610
  * @param {object} options - Announcement options (priority, clearAfter)
1600
1611
  */
1601
1612
  add: (message, opts = {}) => {
1613
+ if (aborted) return;
1602
1614
  queue.push({ message, ...opts });
1603
1615
  queueLength.set(queue.length);
1604
1616
  processQueue();
@@ -1614,7 +1626,11 @@ export function createAnnouncementQueue(options = {}) {
1614
1626
  * Check if queue is being processed
1615
1627
  * @returns {boolean}
1616
1628
  */
1617
- isProcessing: () => isProcessing
1629
+ isProcessing: () => isProcessing,
1630
+ /**
1631
+ * Dispose the queue, cancelling any pending timers
1632
+ */
1633
+ dispose
1618
1634
  };
1619
1635
  }
1620
1636
 
package/runtime/async.js CHANGED
@@ -462,6 +462,11 @@ export function useAsync(asyncFn, options = {}) {
462
462
  }
463
463
  }
464
464
 
465
+ const dispose = () => {
466
+ versionController.cleanup();
467
+ };
468
+ onCleanup(dispose);
469
+
465
470
  // Execute immediately if requested
466
471
  if (immediate) {
467
472
  execute();
@@ -474,7 +479,8 @@ export function useAsync(asyncFn, options = {}) {
474
479
  status,
475
480
  execute,
476
481
  reset,
477
- abort
482
+ abort,
483
+ dispose
478
484
  };
479
485
  }
480
486
 
@@ -727,6 +733,13 @@ export function useResource(key, fetcher, options = {}) {
727
733
  fetch();
728
734
  }
729
735
 
736
+ const dispose = () => {
737
+ if (intervalId) {
738
+ clearInterval(intervalId);
739
+ intervalId = null;
740
+ }
741
+ };
742
+
730
743
  return {
731
744
  data,
732
745
  error,
@@ -737,7 +750,8 @@ export function useResource(key, fetcher, options = {}) {
737
750
  fetch,
738
751
  refresh,
739
752
  mutate,
740
- invalidate
753
+ invalidate,
754
+ dispose
741
755
  };
742
756
  }
743
757
 
package/runtime/form.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * and touched state tracking.
7
7
  */
8
8
 
9
- import { pulse, effect, computed, batch } from './pulse.js';
9
+ import { pulse, effect, computed, batch, onCleanup } from './pulse.js';
10
10
 
11
11
  /**
12
12
  * @typedef {Object} FieldState
@@ -698,6 +698,19 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
698
698
  });
699
699
  }
700
700
 
701
+ const dispose = () => {
702
+ for (const name of fieldNames) {
703
+ const timers = debounceTimers[name];
704
+ if (timers) {
705
+ for (const timerId of timers.values()) {
706
+ clearTimeout(timerId);
707
+ }
708
+ timers.clear();
709
+ }
710
+ }
711
+ };
712
+ onCleanup(dispose);
713
+
701
714
  return {
702
715
  fields,
703
716
  isValid,
@@ -715,7 +728,8 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
715
728
  reset,
716
729
  handleSubmit,
717
730
  setErrors,
718
- clearErrors
731
+ clearErrors,
732
+ dispose
719
733
  };
720
734
  }
721
735
 
@@ -905,6 +919,14 @@ export function useField(initialValue, rules = [], options = {}) {
905
919
  });
906
920
  };
907
921
 
922
+ const dispose = () => {
923
+ for (const timerId of debounceTimers.values()) {
924
+ clearTimeout(timerId);
925
+ }
926
+ debounceTimers.clear();
927
+ };
928
+ onCleanup(dispose);
929
+
908
930
  return {
909
931
  value,
910
932
  error,
@@ -918,7 +940,8 @@ export function useField(initialValue, rules = [], options = {}) {
918
940
  onBlur,
919
941
  reset,
920
942
  setError: (msg) => error.set(msg),
921
- clearError: () => error.set(null)
943
+ clearError: () => error.set(null),
944
+ dispose
922
945
  };
923
946
  }
924
947
 
@@ -1028,6 +1051,13 @@ export function useFieldArray(initialValues = [], itemRules = []) {
1028
1051
  return asyncResults.every(r => r === true);
1029
1052
  };
1030
1053
 
1054
+ const dispose = () => {
1055
+ for (const field of fieldsArray.get()) {
1056
+ field.dispose?.();
1057
+ }
1058
+ };
1059
+ onCleanup(dispose);
1060
+
1031
1061
  return {
1032
1062
  fields: fieldsArray,
1033
1063
  values,
@@ -1042,7 +1072,8 @@ export function useFieldArray(initialValues = [], itemRules = []) {
1042
1072
  replace,
1043
1073
  reset,
1044
1074
  validateAll,
1045
- validateAllSync
1075
+ validateAllSync,
1076
+ dispose
1046
1077
  };
1047
1078
  }
1048
1079
 
package/runtime/router.js CHANGED
@@ -878,16 +878,18 @@ export function createRouter(options = {}) {
878
878
  const a = el('a', content);
879
879
  a.href = href;
880
880
 
881
- a.addEventListener('click', (e) => {
881
+ const handleClick = (e) => {
882
882
  // Allow ctrl/cmd+click for new tab
883
883
  if (e.ctrlKey || e.metaKey) return;
884
884
 
885
885
  e.preventDefault();
886
886
  navigate(path, options);
887
- });
887
+ };
888
+
889
+ a.addEventListener('click', handleClick);
888
890
 
889
891
  // Add active class when route matches
890
- effect(() => {
892
+ const disposeEffect = effect(() => {
891
893
  const current = currentPath.get();
892
894
  if (current === path || (options.exact === false && current.startsWith(path))) {
893
895
  a.classList.add(options.activeClass || 'active');
@@ -896,6 +898,11 @@ export function createRouter(options = {}) {
896
898
  }
897
899
  });
898
900
 
901
+ a.cleanup = () => {
902
+ a.removeEventListener('click', handleClick);
903
+ disposeEffect();
904
+ };
905
+
899
906
  return a;
900
907
  }
901
908
 
@@ -321,6 +321,27 @@ export function sanitizeHtml(html, options = {}) {
321
321
  attrValue = sanitized;
322
322
  }
323
323
 
324
+ // Sanitize style attribute to prevent CSS injection
325
+ if (attrName === 'style') {
326
+ const parts = attrValue.split(';').filter(Boolean);
327
+ const safeParts = [];
328
+ for (const part of parts) {
329
+ const colonIndex = part.indexOf(':');
330
+ if (colonIndex === -1) continue;
331
+ const cssProp = part.slice(0, colonIndex).trim();
332
+ const cssVal = part.slice(colonIndex + 1).trim();
333
+ if (/url\s*\(/i.test(cssVal) || /expression\s*\(/i.test(cssVal) ||
334
+ /javascript:/i.test(cssVal) || /behavior\s*:/i.test(cssVal) ||
335
+ /-moz-binding/i.test(cssVal)) {
336
+ log.warn(`Blocked dangerous CSS in style attribute: ${cssProp}`);
337
+ continue;
338
+ }
339
+ safeParts.push(`${cssProp}: ${cssVal}`);
340
+ }
341
+ attrValue = safeParts.join('; ');
342
+ if (!attrValue) continue;
343
+ }
344
+
324
345
  result += ` ${attrName}="${escapeHtml(attrValue)}"`;
325
346
  }
326
347
 
package/runtime/ssr.js CHANGED
@@ -408,12 +408,16 @@ export function deserializeState(data) {
408
408
  * // Default implementation just stores in global
409
409
  * restoreState(window.__PULSE_STATE__);
410
410
  */
411
+ // Module-scoped state store (avoids globalThis collision between multiple SSR instances)
412
+ let _ssrState = null;
413
+
411
414
  export function restoreState(state) {
412
415
  const deserialized = typeof state === 'string'
413
416
  ? deserializeState(state)
414
417
  : state;
415
418
 
416
- // Store in global for access by components
419
+ // Store in module scope and global for backward compatibility
420
+ _ssrState = deserialized;
417
421
  if (typeof globalThis !== 'undefined') {
418
422
  globalThis.__PULSE_SSR_STATE__ = deserialized;
419
423
  }
@@ -430,10 +434,20 @@ export function restoreState(state) {
430
434
  * const userData = getSSRState('user');
431
435
  */
432
436
  export function getSSRState(key) {
433
- const state = globalThis?.__PULSE_SSR_STATE__ || {};
437
+ const state = _ssrState || globalThis?.__PULSE_SSR_STATE__ || {};
434
438
  return key ? state[key] : state;
435
439
  }
436
440
 
441
+ /**
442
+ * Clear the SSR state. Use in tests or when cleaning up SSR context.
443
+ */
444
+ export function clearSSRState() {
445
+ _ssrState = null;
446
+ if (typeof globalThis !== 'undefined') {
447
+ delete globalThis.__PULSE_SSR_STATE__;
448
+ }
449
+ }
450
+
437
451
  // ============================================================================
438
452
  // Re-exports for convenience
439
453
  // ============================================================================
@@ -456,6 +470,7 @@ export default {
456
470
  deserializeState,
457
471
  restoreState,
458
472
  getSSRState,
473
+ clearSSRState,
459
474
 
460
475
  // Mode checks
461
476
  isSSR,