pulse-js-framework 1.7.32 → 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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Pulse Webpack Loader
3
+ *
4
+ * Enables .pulse file support in Webpack projects
5
+ * Extracts CSS and compiles to JavaScript with source maps
6
+ *
7
+ * CSS Preprocessor Support:
8
+ * - If `sass`, `less`, or `stylus` is installed in the user's project,
9
+ * preprocessor syntax in style blocks is automatically compiled
10
+ * - No configuration needed - just install the preprocessor package
11
+ *
12
+ * Usage in webpack.config.js:
13
+ * ```js
14
+ * module.exports = {
15
+ * module: {
16
+ * rules: [
17
+ * {
18
+ * test: /\.pulse$/,
19
+ * use: [
20
+ * 'style-loader', // or mini-css-extract-plugin
21
+ * 'css-loader',
22
+ * 'pulse-js-framework/loader/webpack-loader'
23
+ * ]
24
+ * }
25
+ * ]
26
+ * }
27
+ * };
28
+ * ```
29
+ */
30
+
31
+ import { compile } from '../compiler/index.js';
32
+ import {
33
+ preprocessStylesSync,
34
+ isSassAvailable,
35
+ isLessAvailable,
36
+ isStylusAvailable,
37
+ getSassVersion,
38
+ getLessVersion,
39
+ getStylusVersion,
40
+ detectPreprocessor
41
+ } from '../compiler/preprocessor.js';
42
+ import { dirname } from 'path';
43
+
44
+ // Cache for preprocessor availability checks
45
+ let preprocessorCache = null;
46
+
47
+ /**
48
+ * Check available preprocessors once
49
+ */
50
+ function checkPreprocessors() {
51
+ if (preprocessorCache) return preprocessorCache;
52
+
53
+ preprocessorCache = {
54
+ sass: isSassAvailable(),
55
+ less: isLessAvailable(),
56
+ stylus: isStylusAvailable()
57
+ };
58
+
59
+ return preprocessorCache;
60
+ }
61
+
62
+ /**
63
+ * Webpack loader for .pulse files
64
+ * @this {import('webpack').LoaderContext}
65
+ */
66
+ export default function pulseLoader(source) {
67
+ const callback = this.async();
68
+ const options = this.getOptions() || {};
69
+
70
+ const {
71
+ sourceMap = true,
72
+ sass: sassOptions = {},
73
+ less: lessOptions = {},
74
+ stylus: stylusOptions = {}
75
+ } = options;
76
+
77
+ // Mark as cacheable
78
+ this.cacheable?.();
79
+
80
+ try {
81
+ // Compile .pulse to JavaScript
82
+ const result = compile(source, {
83
+ runtime: 'pulse-js-framework/runtime',
84
+ sourceMap,
85
+ filename: this.resourcePath
86
+ });
87
+
88
+ if (!result.success) {
89
+ const errors = result.errors.map(e =>
90
+ `${e.message}${e.line ? ` at line ${e.line}` : ''}`
91
+ ).join('\n');
92
+
93
+ callback(new Error(`Pulse compilation failed:\n${errors}`));
94
+ return;
95
+ }
96
+
97
+ let outputCode = result.code;
98
+ let outputMap = result.map;
99
+
100
+ // Extract CSS from compiled output
101
+ const stylesMatch = outputCode.match(/const styles = `([\s\S]*?)`;/);
102
+
103
+ if (stylesMatch) {
104
+ let css = stylesMatch[1];
105
+
106
+ // Check available preprocessors
107
+ const available = checkPreprocessors();
108
+ const preprocessor = detectPreprocessor(css);
109
+
110
+ // Preprocess if preprocessor detected and available
111
+ if (preprocessor !== 'none' && available[preprocessor]) {
112
+ try {
113
+ const preprocessorOptions = {
114
+ sass: sassOptions,
115
+ less: lessOptions,
116
+ stylus: stylusOptions
117
+ }[preprocessor];
118
+
119
+ const preprocessed = preprocessStylesSync(css, {
120
+ filename: this.resourcePath,
121
+ loadPaths: [dirname(this.resourcePath), ...(preprocessorOptions.loadPaths || [])],
122
+ compressed: preprocessorOptions.compressed || false,
123
+ preprocessor // Force detected preprocessor
124
+ });
125
+
126
+ css = preprocessed.css;
127
+
128
+ // Log preprocessor usage in verbose mode
129
+ if (preprocessorOptions.verbose) {
130
+ console.log(`[Pulse] Compiled ${preprocessor.toUpperCase()} in ${this.resourcePath}`);
131
+ }
132
+ } catch (preprocessorError) {
133
+ // Emit warning but continue with original CSS
134
+ this.emitWarning(
135
+ new Error(`${preprocessor.toUpperCase()} compilation warning: ${preprocessorError.message}`)
136
+ );
137
+ }
138
+ }
139
+
140
+ // Emit CSS as separate file or inline
141
+ if (options.extractCss !== false) {
142
+ // Default: emit CSS for css-loader to process
143
+ // This allows Webpack's CSS pipeline to handle it
144
+ this.emitFile?.(
145
+ this.resourcePath.replace(/\.pulse$/, '.pulse.css'),
146
+ css
147
+ );
148
+
149
+ // Replace inline CSS injection with import statement
150
+ // css-loader will process the CSS and style-loader will inject it
151
+ outputCode = outputCode.replace(
152
+ /\/\/ Styles\nconst styles = `[\s\S]*?`;\n\/\/ Inject styles\nconst styleEl = document\.createElement\("style"\);\nstyleEl\.textContent = styles;\ndocument\.head\.appendChild\(styleEl\);/,
153
+ `// Styles extracted - handled by css-loader\nimport "./${this.resourcePath.split('/').pop().replace(/\.pulse$/, '.pulse.css')}";`
154
+ );
155
+ }
156
+ // else: keep inline CSS injection (useful for development)
157
+ }
158
+
159
+ // Add HMR support if Webpack HMR is enabled
160
+ if (this.hot && options.hmr !== false) {
161
+ outputCode += `\n${generateHMRCode(this.resourcePath)}`;
162
+ }
163
+
164
+ // Only pass source map if enabled in options
165
+ callback(null, outputCode, sourceMap ? outputMap : null);
166
+ } catch (error) {
167
+ callback(new Error(`Pulse loader error: ${error.message}`));
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Generate HMR (Hot Module Replacement) code for Webpack
173
+ */
174
+ function generateHMRCode(resourcePath) {
175
+ return `
176
+ // Webpack HMR
177
+ if (module.hot) {
178
+ module.hot.accept();
179
+
180
+ // Cleanup on module replacement
181
+ module.hot.dispose((data) => {
182
+ // Store state for preservation
183
+ if (typeof __PULSE_HMR_STATE__ !== 'undefined') {
184
+ data.pulseState = __PULSE_HMR_STATE__;
185
+ }
186
+ });
187
+
188
+ // Restore state after replacement
189
+ if (module.hot.data && module.hot.data.pulseState) {
190
+ if (typeof __PULSE_HMR_RESTORE__ !== 'undefined') {
191
+ __PULSE_HMR_RESTORE__(module.hot.data.pulseState);
192
+ }
193
+ }
194
+ }
195
+ `;
196
+ }
197
+
198
+ /**
199
+ * Pitch loader - runs before other loaders
200
+ * Used to log preprocessor availability
201
+ */
202
+ export function pitch() {
203
+ const available = checkPreprocessors();
204
+ const options = this.getOptions() || {};
205
+
206
+ // Log preprocessor availability once on first run
207
+ if (!pulseLoader._logged && options.verbose !== false) {
208
+ const preprocessors = [];
209
+ if (available.sass) {
210
+ preprocessors.push(`SASS ${getSassVersion() || 'unknown'}`);
211
+ }
212
+ if (available.less) {
213
+ preprocessors.push(`LESS ${getLessVersion() || 'unknown'}`);
214
+ }
215
+ if (available.stylus) {
216
+ preprocessors.push(`Stylus ${getStylusVersion() || 'unknown'}`);
217
+ }
218
+
219
+ if (preprocessors.length > 0) {
220
+ console.log(`[Pulse Webpack] Preprocessor support: ${preprocessors.join(', ')}`);
221
+ }
222
+
223
+ pulseLoader._logged = true;
224
+ }
225
+ }
226
+
227
+ // Export for CommonJS compatibility
228
+ export const raw = false; // Return code as string, not Buffer
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.7.32",
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": {
@@ -94,6 +95,11 @@
94
95
  "types": "./types/index.d.ts",
95
96
  "default": "./loader/vite-plugin.js"
96
97
  },
98
+ "./webpack": "./loader/webpack-loader.js",
99
+ "./rollup": "./loader/rollup-plugin.js",
100
+ "./esbuild": "./loader/esbuild-plugin.js",
101
+ "./parcel": "./loader/parcel-plugin.js",
102
+ "./swc": "./loader/swc-plugin.js",
97
103
  "./mobile": "./mobile/bridge/pulse-native.js",
98
104
  "./package.json": "./package.json"
99
105
  },
@@ -110,7 +116,7 @@
110
116
  "LICENSE"
111
117
  ],
112
118
  "scripts": {
113
- "test": "npm run test:compiler && npm run test:sourcemap && npm run test:css-parsing && npm run test:preprocessor && npm run test:pulse && npm run test:dom && npm run test:dom-element && npm run test:dom-list && npm run test:dom-conditional && npm run test:dom-lifecycle && npm run test:dom-selector && npm run test:dom-adapter && npm run test:dom-advanced && npm run test:enhanced-mock-adapter && npm run test:router && npm run test:store && npm run test:context && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:cli && npm run test:cli-ui && npm run test:cli-create && npm run test:lru-cache && npm run test:utils && npm run test:utils-coverage && npm run test:docs && npm run test:docs-nav && npm run test:async && npm run test:async-coverage && npm run test:form && npm run test:http && npm run test:devtools && npm run test:native && npm run test:a11y && npm run test:a11y-enhanced && npm run test:logger && npm run test:logger-prod && npm run test:errors && npm run test:security && npm run test:websocket && npm run test:graphql && npm run test:graphql-coverage && npm run test:doctor && npm run test:scaffold && npm run test:test-runner && npm run test:build && npm run test:integration && npm run test:context-stress && npm run test:form-edge-cases && npm run test:graphql-subscriptions && npm run test:http-edge-cases && npm run test:integration-advanced && npm run test:websocket-stress && npm run test:ssr && npm run test:ssr-hydrator",
119
+ "test": "node scripts/run-all-tests.js",
114
120
  "test:compiler": "node test/compiler.test.js",
115
121
  "test:sourcemap": "node test/sourcemap.test.js",
116
122
  "test:css-parsing": "node test/css-parsing.test.js",
@@ -168,6 +174,13 @@
168
174
  "test:websocket-stress": "node test/websocket-stress.test.js",
169
175
  "test:ssr": "node test/ssr.test.js",
170
176
  "test:ssr-hydrator": "node test/ssr-hydrator.test.js",
177
+ "test:webpack-loader": "node test/webpack-loader.test.js",
178
+ "test:rollup-plugin": "node test/rollup-plugin.test.js",
179
+ "test:esbuild-plugin": "node test/esbuild-plugin.test.js",
180
+ "test:parcel-plugin": "node test/parcel-plugin.test.js",
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",
171
184
  "build:netlify": "node scripts/build-netlify.js",
172
185
  "version": "node scripts/sync-version.js",
173
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
  // =============================================================================