pulse-js-framework 1.7.33 → 1.7.37

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.
@@ -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.37",
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,8 @@
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",
181
184
  "build:netlify": "node scripts/build-netlify.js",
182
185
  "version": "node scripts/sync-version.js",
183
186
  "docs": "node cli/index.js dev docs"
@@ -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,
package/types/a11y.d.ts CHANGED
@@ -91,6 +91,24 @@ export function restoreFocus(): void;
91
91
  */
92
92
  export function clearFocusStack(): void;
93
93
 
94
+ /**
95
+ * Handle escape key press within a container
96
+ * @returns Cleanup function to remove listener
97
+ */
98
+ export function onEscapeKey(
99
+ container: HTMLElement,
100
+ onEscape: () => void,
101
+ options?: { stopPropagation?: boolean }
102
+ ): () => void;
103
+
104
+ /**
105
+ * Track keyboard vs mouse focus for :focus-visible styling
106
+ */
107
+ export function createFocusVisibleTracker(): {
108
+ isKeyboardUser: Pulse<boolean>;
109
+ cleanup: () => void;
110
+ };
111
+
94
112
  // =============================================================================
95
113
  // SKIP LINKS
96
114
  // =============================================================================
@@ -138,10 +156,28 @@ export function prefersColorScheme(): 'light' | 'dark' | 'no-preference';
138
156
  */
139
157
  export function prefersHighContrast(): boolean;
140
158
 
159
+ /**
160
+ * Check if user prefers reduced transparency
161
+ */
162
+ export function prefersReducedTransparency(): boolean;
163
+
164
+ /**
165
+ * Check if forced colors mode is active (Windows High Contrast Mode)
166
+ */
167
+ export function forcedColorsMode(): 'active' | 'none';
168
+
169
+ /**
170
+ * Check user's preferred contrast level
171
+ */
172
+ export function prefersContrast(): 'no-preference' | 'more' | 'less' | 'custom';
173
+
141
174
  export interface UserPreferences {
142
175
  reducedMotion: Pulse<boolean>;
143
176
  colorScheme: Pulse<'light' | 'dark' | 'no-preference'>;
144
177
  highContrast: Pulse<boolean>;
178
+ reducedTransparency?: Pulse<boolean>;
179
+ forcedColors?: Pulse<'active' | 'none'>;
180
+ contrast?: Pulse<'no-preference' | 'more' | 'less' | 'custom'>;
145
181
  }
146
182
 
147
183
  /**
@@ -209,6 +245,96 @@ export function createTabs(
209
245
  options?: TabsOptions
210
246
  ): TabsControl;
211
247
 
248
+ // =============================================================================
249
+ // ARIA WIDGETS
250
+ // =============================================================================
251
+
252
+ export interface ModalOptions {
253
+ labelledBy?: string;
254
+ describedBy?: string;
255
+ closeOnBackdropClick?: boolean;
256
+ onClose?: () => void;
257
+ }
258
+
259
+ export interface ModalControl {
260
+ open: () => void;
261
+ close: () => void;
262
+ isOpen: Pulse<boolean>;
263
+ }
264
+
265
+ /**
266
+ * Create an ARIA-compliant modal dialog
267
+ */
268
+ export function createModal(
269
+ dialog: HTMLElement,
270
+ options?: ModalOptions
271
+ ): ModalControl;
272
+
273
+ export interface TooltipOptions {
274
+ showDelay?: number;
275
+ hideDelay?: number;
276
+ }
277
+
278
+ export interface TooltipControl {
279
+ isVisible: Pulse<boolean>;
280
+ cleanup: () => void;
281
+ }
282
+
283
+ /**
284
+ * Create an ARIA-compliant tooltip
285
+ */
286
+ export function createTooltip(
287
+ trigger: HTMLElement,
288
+ tooltip: HTMLElement,
289
+ options?: TooltipOptions
290
+ ): TooltipControl;
291
+
292
+ export interface AccordionOptions {
293
+ triggerSelector?: string;
294
+ panelSelector?: string;
295
+ allowMultiple?: boolean;
296
+ defaultOpen?: number;
297
+ }
298
+
299
+ export interface AccordionControl {
300
+ open: (index: number) => void;
301
+ close: (index: number) => void;
302
+ toggle: (index: number) => void;
303
+ closeAll: () => void;
304
+ openIndices: Pulse<number[]>;
305
+ cleanup: () => void;
306
+ }
307
+
308
+ /**
309
+ * Create an ARIA-compliant accordion
310
+ */
311
+ export function createAccordion(
312
+ container: HTMLElement,
313
+ options?: AccordionOptions
314
+ ): AccordionControl;
315
+
316
+ export interface MenuOptions {
317
+ itemSelector?: string;
318
+ closeOnSelect?: boolean;
319
+ onSelect?: (element: HTMLElement, index: number) => void;
320
+ }
321
+
322
+ export interface MenuControl {
323
+ open: () => void;
324
+ close: () => void;
325
+ toggle: () => void;
326
+ cleanup: () => void;
327
+ }
328
+
329
+ /**
330
+ * Create an ARIA-compliant dropdown menu
331
+ */
332
+ export function createMenu(
333
+ button: HTMLElement,
334
+ menu: HTMLElement,
335
+ options?: MenuOptions
336
+ ): MenuControl;
337
+
212
338
  // =============================================================================
213
339
  // KEYBOARD NAVIGATION
214
340
  // =============================================================================
@@ -260,6 +386,61 @@ export function logA11yIssues(issues: A11yIssue[]): void;
260
386
  */
261
387
  export function highlightA11yIssues(issues: A11yIssue[]): () => void;
262
388
 
389
+ // =============================================================================
390
+ // COLOR CONTRAST
391
+ // =============================================================================
392
+
393
+ /**
394
+ * Calculate WCAG contrast ratio between two colors
395
+ */
396
+ export function getContrastRatio(foreground: string, background: string): number;
397
+
398
+ /**
399
+ * Check if a contrast ratio meets WCAG requirements
400
+ */
401
+ export function meetsContrastRequirement(
402
+ ratio: number,
403
+ level?: 'AA' | 'AAA',
404
+ textSize?: 'normal' | 'large'
405
+ ): boolean;
406
+
407
+ /**
408
+ * Get the effective background color of an element (handles transparency)
409
+ */
410
+ export function getEffectiveBackgroundColor(element: HTMLElement): string;
411
+
412
+ /**
413
+ * Check contrast of a specific element
414
+ */
415
+ export function checkElementContrast(
416
+ element: HTMLElement,
417
+ level?: 'AA' | 'AAA'
418
+ ): {
419
+ ratio: number;
420
+ passes: boolean;
421
+ foreground: string;
422
+ background: string;
423
+ };
424
+
425
+ // =============================================================================
426
+ // ANNOUNCEMENT QUEUE
427
+ // =============================================================================
428
+
429
+ export interface AnnouncementQueueOptions {
430
+ minDelay?: number;
431
+ }
432
+
433
+ export interface AnnouncementQueue {
434
+ add: (message: string, options?: { priority?: 'polite' | 'assertive' }) => void;
435
+ clear: () => void;
436
+ queueLength: Pulse<number>;
437
+ }
438
+
439
+ /**
440
+ * Create a queue for managing multiple announcements
441
+ */
442
+ export function createAnnouncementQueue(options?: AnnouncementQueueOptions): AnnouncementQueue;
443
+
263
444
  // =============================================================================
264
445
  // UTILITIES
265
446
  // =============================================================================
@@ -285,6 +466,11 @@ export function makeInert(element: HTMLElement): () => void;
285
466
  */
286
467
  export function srOnly(text: string): HTMLSpanElement;
287
468
 
469
+ /**
470
+ * Compute the accessible name of an element (aria-label, text content, etc.)
471
+ */
472
+ export function getAccessibleName(element: HTMLElement): string;
473
+
288
474
  // =============================================================================
289
475
  // DEFAULT EXPORT
290
476
  // =============================================================================