pulse-js-framework 1.4.8 → 1.4.10

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.
package/README.md CHANGED
@@ -136,6 +136,7 @@ style {
136
136
  - CSS scoping (styles are automatically scoped to component)
137
137
  - Native `router {}` and `store {}` blocks
138
138
  - Detailed error messages with line/column info
139
+ - Source map generation (v1.4.9) for debugging original `.pulse` code
139
140
 
140
141
  ### Router & Store DSL (v1.4.0)
141
142
 
@@ -266,7 +267,53 @@ router.start();
266
267
  - Per-route guards (`beforeEnter`)
267
268
  - Global guards (`beforeEach`, `beforeResolve`, `afterEach`)
268
269
  - Scroll restoration
269
- - Lazy-loaded routes (async handlers)
270
+ - Lazy-loaded routes with `lazy()` and `preload()`
271
+ - Middleware pipeline (Koa-style)
272
+
273
+ #### Lazy Loading & Middleware (v1.4.9)
274
+
275
+ ```javascript
276
+ import { createRouter, lazy, preload } from 'pulse-js-framework/runtime/router';
277
+
278
+ // Lazy load components
279
+ const routes = {
280
+ '/': HomePage,
281
+ '/dashboard': lazy(() => import('./Dashboard.js')),
282
+ '/settings': lazy(() => import('./Settings.js'), {
283
+ loading: () => el('div.spinner', 'Loading...'),
284
+ error: (err) => el('div.error', `Failed: ${err.message}`),
285
+ timeout: 5000
286
+ })
287
+ };
288
+
289
+ // Preload on hover
290
+ link.addEventListener('mouseenter', () => preload(routes['/dashboard']));
291
+
292
+ // Middleware
293
+ const router = createRouter({
294
+ routes,
295
+ middleware: [
296
+ // Logger middleware
297
+ async (ctx, next) => {
298
+ console.log('Navigating to:', ctx.to.path);
299
+ await next();
300
+ },
301
+ // Auth middleware
302
+ async (ctx, next) => {
303
+ if (ctx.to.meta.requiresAuth && !isLoggedIn()) {
304
+ return ctx.redirect('/login');
305
+ }
306
+ await next();
307
+ }
308
+ ]
309
+ });
310
+
311
+ // Add middleware dynamically
312
+ const unsubscribe = router.use(async (ctx, next) => {
313
+ ctx.meta.startTime = Date.now();
314
+ await next();
315
+ });
316
+ ```
270
317
 
271
318
  ### Store
272
319
 
package/cli/lint.js CHANGED
@@ -14,6 +14,9 @@ export const LintRules = {
14
14
  'undefined-reference': { severity: 'error', fixable: false },
15
15
  'duplicate-declaration': { severity: 'error', fixable: false },
16
16
 
17
+ // Security rules (warnings)
18
+ 'xss-vulnerability': { severity: 'warning', fixable: false },
19
+
17
20
  // Usage rules (warnings)
18
21
  'unused-import': { severity: 'warning', fixable: true },
19
22
  'unused-state': { severity: 'warning', fixable: false },
@@ -127,6 +130,9 @@ export class SemanticAnalyzer {
127
130
  // Phase 4: Style checks
128
131
  this.checkStyle();
129
132
 
133
+ // Phase 5: Security checks (XSS detection)
134
+ this.checkSecurity();
135
+
130
136
  return this.diagnostics;
131
137
  }
132
138
 
@@ -519,6 +525,117 @@ export class SemanticAnalyzer {
519
525
  }
520
526
  }
521
527
 
528
+ /**
529
+ * Check for security vulnerabilities (XSS patterns)
530
+ */
531
+ checkSecurity() {
532
+ // Check actions for dangerous DOM manipulation
533
+ if (this.ast.actions && this.ast.actions.functions) {
534
+ for (const fn of this.ast.actions.functions) {
535
+ if (fn.body) {
536
+ this.checkForXSS(fn.body, fn.name, fn.line, fn.column);
537
+ }
538
+ }
539
+ }
540
+
541
+ // Check view for dangerous patterns in directives
542
+ if (this.ast.view) {
543
+ this.checkViewForXSS(this.ast.view);
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Check code string for XSS vulnerabilities
549
+ */
550
+ checkForXSS(code, context, line, column) {
551
+ if (typeof code !== 'string') return;
552
+
553
+ // Dangerous DOM manipulation patterns
554
+ const xssPatterns = [
555
+ {
556
+ pattern: /\.innerHTML\s*=\s*(?!['"]\s*['"])/,
557
+ message: 'Assigning dynamic content to innerHTML can lead to XSS. Use textContent or sanitize input.'
558
+ },
559
+ {
560
+ pattern: /\.outerHTML\s*=\s*(?!['"]\s*['"])/,
561
+ message: 'Assigning dynamic content to outerHTML can lead to XSS. Consider safer alternatives.'
562
+ },
563
+ {
564
+ pattern: /document\.write\s*\(/,
565
+ message: 'document.write() can execute scripts and lead to XSS. Use DOM methods instead.'
566
+ },
567
+ {
568
+ pattern: /\.insertAdjacentHTML\s*\(/,
569
+ message: 'insertAdjacentHTML with unsanitized input can lead to XSS. Sanitize HTML or use DOM methods.'
570
+ },
571
+ {
572
+ pattern: /eval\s*\(/,
573
+ message: 'eval() executes arbitrary code and is a security risk. Avoid using eval().'
574
+ },
575
+ {
576
+ pattern: /new\s+Function\s*\(/,
577
+ message: 'new Function() can execute arbitrary code like eval(). Avoid dynamic function creation.'
578
+ },
579
+ {
580
+ pattern: /setTimeout\s*\(\s*['"`]/,
581
+ message: 'setTimeout with string argument executes code like eval(). Use a function instead.'
582
+ },
583
+ {
584
+ pattern: /setInterval\s*\(\s*['"`]/,
585
+ message: 'setInterval with string argument executes code like eval(). Use a function instead.'
586
+ }
587
+ ];
588
+
589
+ for (const { pattern, message } of xssPatterns) {
590
+ if (pattern.test(code)) {
591
+ this.addDiagnostic('warning', 'xss-vulnerability',
592
+ `Potential XSS in action '${context}': ${message}`,
593
+ line || 1, column || 1);
594
+ }
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Check view nodes for XSS vulnerabilities
600
+ */
601
+ checkViewForXSS(node) {
602
+ if (!node) return;
603
+
604
+ const children = node.children || [];
605
+ for (const child of children) {
606
+ if (!child) continue;
607
+
608
+ // Check for @html directive (if exists in DSL)
609
+ if (child.directives) {
610
+ for (const directive of child.directives) {
611
+ if (directive.name === 'html') {
612
+ this.addDiagnostic('warning', 'xss-vulnerability',
613
+ '@html directive renders raw HTML and can lead to XSS if used with user input. Ensure content is sanitized.',
614
+ directive.line || child.line || 1, directive.column || child.column || 1);
615
+ }
616
+ }
617
+ }
618
+
619
+ // Check directive expressions for dangerous patterns
620
+ if (child.directives) {
621
+ for (const directive of child.directives) {
622
+ const expr = directive.handler || directive.expression || '';
623
+ if (typeof expr === 'string') {
624
+ // Check for innerHTML in expressions
625
+ if (/innerHTML|outerHTML/.test(expr)) {
626
+ this.addDiagnostic('warning', 'xss-vulnerability',
627
+ `Using innerHTML/outerHTML in directive expression can lead to XSS. Use safer alternatives.`,
628
+ directive.line || child.line || 1, directive.column || child.column || 1);
629
+ }
630
+ }
631
+ }
632
+ }
633
+
634
+ // Recurse into children
635
+ this.checkViewForXSS(child);
636
+ }
637
+ }
638
+
522
639
  /**
523
640
  * Add a diagnostic message
524
641
  */
package/compiler/index.js CHANGED
@@ -5,26 +5,51 @@
5
5
  export * from './lexer.js';
6
6
  export * from './parser.js';
7
7
  export * from './transformer.js';
8
+ export * from './sourcemap.js';
8
9
 
9
10
  import { tokenize } from './lexer.js';
10
11
  import { parse } from './parser.js';
11
- import { transform } from './transformer.js';
12
+ import { transform, Transformer } from './transformer.js';
12
13
 
13
14
  /**
14
15
  * Compile Pulse source code to JavaScript
16
+ *
17
+ * @param {string} source - Pulse source code
18
+ * @param {Object} options - Compiler options
19
+ * @param {string} options.runtime - Runtime import path
20
+ * @param {boolean} options.minify - Minify output
21
+ * @param {boolean} options.scopeStyles - Scope CSS with unique prefixes
22
+ * @param {boolean} options.sourceMap - Generate source map
23
+ * @param {string} options.sourceFileName - Original file name (for source maps)
24
+ * @param {boolean} options.inlineSourceMap - Include source map as inline comment
25
+ * @returns {Object} Compilation result
15
26
  */
16
27
  export function compile(source, options = {}) {
17
28
  try {
18
29
  // Parse source to AST
19
30
  const ast = parse(source);
20
31
 
32
+ // Prepare transformer options
33
+ const transformerOptions = {
34
+ ...options,
35
+ sourceContent: options.sourceMap ? source : null
36
+ };
37
+
21
38
  // Transform AST to JavaScript
22
- const code = transform(ast, options);
39
+ const transformer = new Transformer(ast, transformerOptions);
40
+ const result = transformer.transformWithSourceMap();
41
+
42
+ // Add inline source map if requested
43
+ let code = result.code;
44
+ if (options.sourceMap && options.inlineSourceMap && result.sourceMapComment) {
45
+ code = code + '\n' + result.sourceMapComment;
46
+ }
23
47
 
24
48
  return {
25
49
  success: true,
26
50
  code,
27
51
  ast,
52
+ sourceMap: result.sourceMap,
28
53
  errors: []
29
54
  };
30
55
  } catch (error) {
@@ -32,6 +57,7 @@ export function compile(source, options = {}) {
32
57
  success: false,
33
58
  code: null,
34
59
  ast: null,
60
+ sourceMap: null,
35
61
  errors: [{
36
62
  message: error.message,
37
63
  line: error.line,
@@ -55,11 +81,15 @@ export function tokenizeOnly(source) {
55
81
  return tokenize(source);
56
82
  }
57
83
 
84
+ import { SourceMapGenerator, SourceMapConsumer } from './sourcemap.js';
85
+
58
86
  export default {
59
87
  compile,
60
88
  parseOnly,
61
89
  tokenizeOnly,
62
90
  tokenize,
63
91
  parse,
64
- transform
92
+ transform,
93
+ SourceMapGenerator,
94
+ SourceMapConsumer
65
95
  };
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Pulse Source Map Generator
3
+ *
4
+ * Generates V3 source maps for .pulse files compiled to JavaScript.
5
+ * Enables debugging of original .pulse code in browser devtools.
6
+ *
7
+ * @module pulse-js-framework/compiler/sourcemap
8
+ */
9
+
10
+ /**
11
+ * Base64 VLQ encoding for source map mappings
12
+ * VLQ (Variable Length Quantity) is used to encode position data compactly
13
+ */
14
+ const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
15
+
16
+ /**
17
+ * Encode a number as VLQ base64
18
+ * @param {number} value - Number to encode (can be negative)
19
+ * @returns {string} VLQ encoded string
20
+ */
21
+ export function encodeVLQ(value) {
22
+ let encoded = '';
23
+ // Convert to unsigned and add sign bit
24
+ let vlq = value < 0 ? ((-value) << 1) + 1 : (value << 1);
25
+
26
+ do {
27
+ let digit = vlq & 0x1F; // 5 bits
28
+ vlq >>>= 5;
29
+ if (vlq > 0) {
30
+ digit |= 0x20; // Set continuation bit
31
+ }
32
+ encoded += BASE64_CHARS[digit];
33
+ } while (vlq > 0);
34
+
35
+ return encoded;
36
+ }
37
+
38
+ /**
39
+ * Source Map Generator class
40
+ * Tracks mappings between generated and original source positions
41
+ */
42
+ export class SourceMapGenerator {
43
+ /**
44
+ * @param {Object} options
45
+ * @param {string} options.file - Generated file name
46
+ * @param {string} options.sourceRoot - Root URL for source files
47
+ */
48
+ constructor(options = {}) {
49
+ this.file = options.file || '';
50
+ this.sourceRoot = options.sourceRoot || '';
51
+ this.sources = [];
52
+ this.sourcesContent = [];
53
+ this.names = [];
54
+ this.mappings = [];
55
+
56
+ // Current state for relative encoding
57
+ this._lastGeneratedLine = 0;
58
+ this._lastGeneratedColumn = 0;
59
+ this._lastSourceIndex = 0;
60
+ this._lastSourceLine = 0;
61
+ this._lastSourceColumn = 0;
62
+ this._lastNameIndex = 0;
63
+ }
64
+
65
+ /**
66
+ * Add a source file
67
+ * @param {string} source - Source file path
68
+ * @param {string} content - Source file content (optional)
69
+ * @returns {number} Source index
70
+ */
71
+ addSource(source, content = null) {
72
+ let index = this.sources.indexOf(source);
73
+ if (index === -1) {
74
+ index = this.sources.length;
75
+ this.sources.push(source);
76
+ this.sourcesContent.push(content);
77
+ }
78
+ return index;
79
+ }
80
+
81
+ /**
82
+ * Add a name (identifier) to the names array
83
+ * @param {string} name - Identifier name
84
+ * @returns {number} Name index
85
+ */
86
+ addName(name) {
87
+ let index = this.names.indexOf(name);
88
+ if (index === -1) {
89
+ index = this.names.length;
90
+ this.names.push(name);
91
+ }
92
+ return index;
93
+ }
94
+
95
+ /**
96
+ * Add a mapping between generated and original positions
97
+ * @param {Object} mapping
98
+ * @param {Object} mapping.generated - Generated position {line, column}
99
+ * @param {Object} mapping.original - Original position {line, column} (optional)
100
+ * @param {string} mapping.source - Source file path (required if original provided)
101
+ * @param {string} mapping.name - Original identifier name (optional)
102
+ */
103
+ addMapping(mapping) {
104
+ const { generated, original, source, name } = mapping;
105
+
106
+ // Ensure mappings array has enough lines
107
+ while (this.mappings.length <= generated.line) {
108
+ this.mappings.push([]);
109
+ }
110
+
111
+ const segment = {
112
+ generatedColumn: generated.column,
113
+ sourceIndex: source ? this.addSource(source) : null,
114
+ originalLine: original?.line ?? null,
115
+ originalColumn: original?.column ?? null,
116
+ nameIndex: name ? this.addName(name) : null
117
+ };
118
+
119
+ this.mappings[generated.line].push(segment);
120
+ }
121
+
122
+ /**
123
+ * Encode all mappings as VLQ string
124
+ * @returns {string} Encoded mappings
125
+ */
126
+ _encodeMappings() {
127
+ let previousGeneratedColumn = 0;
128
+ let previousSourceIndex = 0;
129
+ let previousSourceLine = 0;
130
+ let previousSourceColumn = 0;
131
+ let previousNameIndex = 0;
132
+
133
+ const lines = [];
134
+
135
+ for (const line of this.mappings) {
136
+ if (line.length === 0) {
137
+ lines.push('');
138
+ continue;
139
+ }
140
+
141
+ // Sort segments by generated column
142
+ line.sort((a, b) => a.generatedColumn - b.generatedColumn);
143
+
144
+ const segments = [];
145
+ previousGeneratedColumn = 0; // Reset column for each line
146
+
147
+ for (const segment of line) {
148
+ let encoded = '';
149
+
150
+ // 1. Generated column (relative to previous segment in same line)
151
+ encoded += encodeVLQ(segment.generatedColumn - previousGeneratedColumn);
152
+ previousGeneratedColumn = segment.generatedColumn;
153
+
154
+ // If we have source info
155
+ if (segment.sourceIndex !== null) {
156
+ // 2. Source file index (relative)
157
+ encoded += encodeVLQ(segment.sourceIndex - previousSourceIndex);
158
+ previousSourceIndex = segment.sourceIndex;
159
+
160
+ // 3. Original line (relative, 0-based in source maps)
161
+ encoded += encodeVLQ(segment.originalLine - previousSourceLine);
162
+ previousSourceLine = segment.originalLine;
163
+
164
+ // 4. Original column (relative)
165
+ encoded += encodeVLQ(segment.originalColumn - previousSourceColumn);
166
+ previousSourceColumn = segment.originalColumn;
167
+
168
+ // 5. Optional: name index (relative)
169
+ if (segment.nameIndex !== null) {
170
+ encoded += encodeVLQ(segment.nameIndex - previousNameIndex);
171
+ previousNameIndex = segment.nameIndex;
172
+ }
173
+ }
174
+
175
+ segments.push(encoded);
176
+ }
177
+
178
+ lines.push(segments.join(','));
179
+ }
180
+
181
+ return lines.join(';');
182
+ }
183
+
184
+ /**
185
+ * Generate the source map object
186
+ * @returns {Object} Source map object (V3 format)
187
+ */
188
+ toJSON() {
189
+ return {
190
+ version: 3,
191
+ file: this.file,
192
+ sourceRoot: this.sourceRoot,
193
+ sources: this.sources,
194
+ sourcesContent: this.sourcesContent,
195
+ names: this.names,
196
+ mappings: this._encodeMappings()
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Generate source map as JSON string
202
+ * @returns {string} JSON string
203
+ */
204
+ toString() {
205
+ return JSON.stringify(this.toJSON());
206
+ }
207
+
208
+ /**
209
+ * Generate inline source map comment
210
+ * @returns {string} Comment with base64 encoded source map
211
+ */
212
+ toComment() {
213
+ const base64 = typeof btoa === 'function'
214
+ ? btoa(this.toString())
215
+ : Buffer.from(this.toString()).toString('base64');
216
+ return `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64}`;
217
+ }
218
+
219
+ /**
220
+ * Generate external source map URL comment
221
+ * @param {string} url - URL to source map file
222
+ * @returns {string} Comment with source map URL
223
+ */
224
+ static toURLComment(url) {
225
+ return `//# sourceMappingURL=${url}`;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Source Map Consumer - parse and query source maps
231
+ * Useful for error stack trace translation
232
+ */
233
+ export class SourceMapConsumer {
234
+ /**
235
+ * @param {Object|string} sourceMap - Source map object or JSON string
236
+ */
237
+ constructor(sourceMap) {
238
+ this.map = typeof sourceMap === 'string' ? JSON.parse(sourceMap) : sourceMap;
239
+ this._decodedMappings = null;
240
+ }
241
+
242
+ /**
243
+ * Decode VLQ string to numbers
244
+ * @param {string} str - VLQ encoded string
245
+ * @returns {number[]} Decoded numbers
246
+ */
247
+ static decodeVLQ(str) {
248
+ const values = [];
249
+ let value = 0;
250
+ let shift = 0;
251
+
252
+ for (const char of str) {
253
+ const digit = BASE64_CHARS.indexOf(char);
254
+ if (digit === -1) continue;
255
+
256
+ value += (digit & 0x1F) << shift;
257
+
258
+ if (digit & 0x20) {
259
+ shift += 5;
260
+ } else {
261
+ // Check sign bit
262
+ const negative = value & 1;
263
+ value >>= 1;
264
+ values.push(negative ? -value : value);
265
+ value = 0;
266
+ shift = 0;
267
+ }
268
+ }
269
+
270
+ return values;
271
+ }
272
+
273
+ /**
274
+ * Get original position for a generated position
275
+ * @param {Object} position - Generated position {line, column}
276
+ * @returns {Object|null} Original position or null
277
+ */
278
+ originalPositionFor(position) {
279
+ this._ensureDecoded();
280
+
281
+ const { line, column } = position;
282
+ const lineData = this._decodedMappings[line];
283
+
284
+ if (!lineData) return null;
285
+
286
+ // Find the closest mapping at or before this column
287
+ let closest = null;
288
+ for (const mapping of lineData) {
289
+ if (mapping.generatedColumn <= column) {
290
+ closest = mapping;
291
+ } else {
292
+ break;
293
+ }
294
+ }
295
+
296
+ if (!closest || closest.sourceIndex === null) return null;
297
+
298
+ return {
299
+ source: this.map.sources[closest.sourceIndex],
300
+ line: closest.originalLine,
301
+ column: closest.originalColumn,
302
+ name: closest.nameIndex !== null ? this.map.names[closest.nameIndex] : null
303
+ };
304
+ }
305
+
306
+ /**
307
+ * Decode mappings lazily
308
+ */
309
+ _ensureDecoded() {
310
+ if (this._decodedMappings) return;
311
+
312
+ this._decodedMappings = [];
313
+ const lines = this.map.mappings.split(';');
314
+
315
+ let sourceIndex = 0;
316
+ let sourceLine = 0;
317
+ let sourceColumn = 0;
318
+ let nameIndex = 0;
319
+
320
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
321
+ const line = lines[lineIndex];
322
+ const segments = line.split(',').filter(s => s);
323
+ const lineData = [];
324
+ let generatedColumn = 0;
325
+
326
+ for (const segment of segments) {
327
+ const values = SourceMapConsumer.decodeVLQ(segment);
328
+
329
+ generatedColumn += values[0];
330
+
331
+ const mapping = { generatedColumn };
332
+
333
+ if (values.length >= 4) {
334
+ sourceIndex += values[1];
335
+ sourceLine += values[2];
336
+ sourceColumn += values[3];
337
+
338
+ mapping.sourceIndex = sourceIndex;
339
+ mapping.originalLine = sourceLine;
340
+ mapping.originalColumn = sourceColumn;
341
+ }
342
+
343
+ if (values.length >= 5) {
344
+ nameIndex += values[4];
345
+ mapping.nameIndex = nameIndex;
346
+ }
347
+
348
+ lineData.push(mapping);
349
+ }
350
+
351
+ this._decodedMappings.push(lineData);
352
+ }
353
+ }
354
+ }
355
+
356
+ export default {
357
+ SourceMapGenerator,
358
+ SourceMapConsumer,
359
+ encodeVLQ
360
+ };