pulse-js-framework 1.7.25 → 1.7.29

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
@@ -48,6 +48,27 @@ npm install
48
48
  npm run dev
49
49
  ```
50
50
 
51
+ ### Or from a template
52
+
53
+ Create projects from built-in example apps:
54
+
55
+ ```bash
56
+ # E-commerce app (products, cart, checkout)
57
+ npx pulse-js-framework create my-shop --ecommerce
58
+
59
+ # Todo app (filtering, local storage)
60
+ npx pulse-js-framework create my-todos --todo
61
+
62
+ # Blog (posts, sidebar, navigation)
63
+ npx pulse-js-framework create my-blog --blog
64
+
65
+ # Chat app (messages, users, emoji picker)
66
+ npx pulse-js-framework create my-chat --chat
67
+
68
+ # Dashboard (data visualization)
69
+ npx pulse-js-framework create my-dashboard --dashboard
70
+ ```
71
+
51
72
  ### Or use directly
52
73
 
53
74
  ```javascript
@@ -139,6 +160,11 @@ See [Pulse DSL documentation](docs/pulse-dsl.md) for full syntax reference.
139
160
  # Project Creation
140
161
  pulse create <name> # Create new project
141
162
  pulse create <name> --typescript # Create TypeScript project
163
+ pulse create <name> --ecommerce # Create from E-Commerce template
164
+ pulse create <name> --todo # Create from Todo App template
165
+ pulse create <name> --blog # Create from Blog template
166
+ pulse create <name> --chat # Create from Chat template
167
+ pulse create <name> --dashboard # Create from Dashboard template
142
168
  pulse init --typescript # Initialize in current directory
143
169
 
144
170
  # Development
package/cli/help.js CHANGED
@@ -29,18 +29,29 @@ Creates a new Pulse project with a complete starter template including:
29
29
  - Project structure (src/, public/)
30
30
  - Vite configuration for development and building
31
31
  - Sample App.pulse component with counter example
32
- - Package.json with all necessary scripts`,
32
+ - Package.json with all necessary scripts
33
+
34
+ You can also create projects from built-in example templates using
35
+ the template flags (--ecommerce, --todo, --blog, --chat, --dashboard).`,
33
36
  arguments: [
34
37
  { name: '<name>', description: 'Name of the project directory to create' }
35
38
  ],
36
39
  options: [
37
40
  { flag: '--typescript, --ts', description: 'Create a TypeScript project with tsconfig.json' },
38
- { flag: '--minimal', description: 'Create minimal project structure without extras' }
41
+ { flag: '--minimal', description: 'Create minimal project structure without extras' },
42
+ { flag: '--ecommerce', description: 'Use E-Commerce template (products, cart, checkout)' },
43
+ { flag: '--todo', description: 'Use Todo App template (filtering, local storage)' },
44
+ { flag: '--blog', description: 'Use Blog template (posts, sidebar, navigation)' },
45
+ { flag: '--chat', description: 'Use Chat template (messages, users, emoji picker)' },
46
+ { flag: '--dashboard', description: 'Use Dashboard template (data visualization)' }
39
47
  ],
40
48
  examples: [
41
49
  { cmd: 'pulse create my-app', desc: 'Create a new JavaScript project' },
42
50
  { cmd: 'pulse create my-app --typescript', desc: 'Create a new TypeScript project' },
43
- { cmd: 'pulse create my-app --minimal', desc: 'Create a minimal project' }
51
+ { cmd: 'pulse create my-app --minimal', desc: 'Create a minimal project' },
52
+ { cmd: 'pulse create my-shop --ecommerce', desc: 'Create from E-Commerce template' },
53
+ { cmd: 'pulse create my-todos --todo', desc: 'Create from Todo App template' },
54
+ { cmd: 'pulse create my-blog --blog', desc: 'Create from Blog template' }
44
55
  ]
45
56
  },
46
57
 
package/cli/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { fileURLToPath } from 'url';
8
8
  import { dirname, join, resolve, relative } from 'path';
9
- import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, watch } from 'fs';
9
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, watch, cpSync, statSync } from 'fs';
10
10
  import { log } from './logger.js';
11
11
  import { findPulseFiles, parseArgs } from './utils/file-utils.js';
12
12
  import { runHelp } from './help.js';
@@ -18,6 +18,15 @@ const __dirname = dirname(__filename);
18
18
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
19
19
  const VERSION = pkg.version;
20
20
 
21
+ // Available example templates
22
+ const TEMPLATES = {
23
+ ecommerce: { name: 'E-Commerce', description: 'Shopping cart with products, checkout, and cart management' },
24
+ todo: { name: 'Todo App', description: 'Classic todo list with filtering and local storage' },
25
+ blog: { name: 'Blog', description: 'Blogging platform with posts, sidebar, and navigation' },
26
+ chat: { name: 'Chat', description: 'Real-time chat with messages, users, and emoji picker' },
27
+ dashboard: { name: 'Dashboard', description: 'Analytics dashboard with data visualization' }
28
+ };
29
+
21
30
  // Command handlers
22
31
  const commands = {
23
32
  help: showHelp,
@@ -191,6 +200,87 @@ function showVersion() {
191
200
  log.info(`Pulse Framework v${VERSION}`);
192
201
  }
193
202
 
203
+ /**
204
+ * Copy example template to project directory
205
+ * Transforms imports from /runtime/index.js to pulse-js-framework/runtime
206
+ * @param {string} templateName - Name of the template (ecommerce, todo, blog, chat, dashboard)
207
+ * @param {string} projectPath - Destination project path
208
+ * @param {string} projectName - Name of the project
209
+ */
210
+ function copyExampleTemplate(templateName, projectPath, projectName) {
211
+ const examplesDir = join(__dirname, '..', 'examples');
212
+ const templateDir = join(examplesDir, templateName);
213
+
214
+ if (!existsSync(templateDir)) {
215
+ throw new Error(`Template "${templateName}" not found at ${templateDir}`);
216
+ }
217
+
218
+ /**
219
+ * Recursively copy directory, transforming JS files
220
+ */
221
+ function copyDir(src, dest) {
222
+ if (!existsSync(dest)) {
223
+ mkdirSync(dest, { recursive: true });
224
+ }
225
+
226
+ const entries = readdirSync(src, { withFileTypes: true });
227
+
228
+ for (const entry of entries) {
229
+ const srcPath = join(src, entry.name);
230
+ const destPath = join(dest, entry.name);
231
+
232
+ // Skip node_modules and dist directories
233
+ if (entry.name === 'node_modules' || entry.name === 'dist') {
234
+ continue;
235
+ }
236
+
237
+ if (entry.isDirectory()) {
238
+ copyDir(srcPath, destPath);
239
+ } else {
240
+ // Read file content
241
+ let content = readFileSync(srcPath, 'utf-8');
242
+
243
+ // Transform imports in JS files
244
+ if (entry.name.endsWith('.js') || entry.name.endsWith('.ts')) {
245
+ // Transform /runtime/index.js imports to pulse-js-framework/runtime
246
+ content = content.replace(
247
+ /from\s+['"]\/runtime\/index\.js['"]/g,
248
+ "from 'pulse-js-framework/runtime'"
249
+ );
250
+ content = content.replace(
251
+ /from\s+['"]\/runtime['"]/g,
252
+ "from 'pulse-js-framework/runtime'"
253
+ );
254
+ // Transform other runtime submodule imports
255
+ content = content.replace(
256
+ /from\s+['"]\/runtime\/([^'"]+)['"]/g,
257
+ "from 'pulse-js-framework/runtime/$1'"
258
+ );
259
+ }
260
+
261
+ writeFileSync(destPath, content);
262
+ }
263
+ }
264
+ }
265
+
266
+ // Copy src directory
267
+ const srcDir = join(templateDir, 'src');
268
+ if (existsSync(srcDir)) {
269
+ copyDir(srcDir, join(projectPath, 'src'));
270
+ }
271
+
272
+ // Copy index.html if exists
273
+ const indexHtml = join(templateDir, 'index.html');
274
+ if (existsSync(indexHtml)) {
275
+ let content = readFileSync(indexHtml, 'utf-8');
276
+ // Update title to project name
277
+ content = content.replace(/<title>[^<]*<\/title>/, `<title>${projectName}</title>`);
278
+ writeFileSync(join(projectPath, 'index.html'), content);
279
+ }
280
+
281
+ return true;
282
+ }
283
+
194
284
  /**
195
285
  * Create a new project
196
286
  */
@@ -200,7 +290,16 @@ async function createProject(args) {
200
290
 
201
291
  if (!projectName) {
202
292
  log.error('Please provide a project name.');
203
- log.info('Usage: pulse create <project-name>');
293
+ log.info('Usage: pulse create <project-name> [options]');
294
+ log.info('');
295
+ log.info('Options:');
296
+ log.info(' --typescript, --ts Create TypeScript project');
297
+ log.info(' --minimal Create minimal project');
298
+ log.info('');
299
+ log.info('Templates (use existing example apps):');
300
+ for (const [key, info] of Object.entries(TEMPLATES)) {
301
+ log.info(` --${key.padEnd(12)} ${info.description}`);
302
+ }
204
303
  process.exit(1);
205
304
  }
206
305
 
@@ -214,6 +313,93 @@ async function createProject(args) {
214
313
  const useTypescript = options.typescript || options.ts || false;
215
314
  const minimal = options.minimal || false;
216
315
 
316
+ // Check for template options
317
+ let selectedTemplate = null;
318
+ for (const templateName of Object.keys(TEMPLATES)) {
319
+ if (options[templateName]) {
320
+ selectedTemplate = templateName;
321
+ break;
322
+ }
323
+ }
324
+
325
+ // If a template is selected, use the template-based creation
326
+ if (selectedTemplate) {
327
+ const templateInfo = TEMPLATES[selectedTemplate];
328
+ log.info(`Creating new Pulse project: ${projectName} (${templateInfo.name} template)`);
329
+
330
+ // Create project directory
331
+ mkdirSync(projectPath);
332
+ mkdirSync(join(projectPath, 'public'));
333
+
334
+ // Copy template files
335
+ try {
336
+ copyExampleTemplate(selectedTemplate, projectPath, projectName);
337
+ log.info(` ✓ Copied ${templateInfo.name} template files`);
338
+ } catch (err) {
339
+ log.error(`Failed to copy template: ${err.message}`);
340
+ process.exit(1);
341
+ }
342
+
343
+ // Create package.json for template project
344
+ const packageJson = {
345
+ name: projectName,
346
+ version: '0.1.0',
347
+ type: 'module',
348
+ scripts: {
349
+ dev: 'pulse dev',
350
+ build: 'pulse build',
351
+ preview: 'vite preview',
352
+ test: 'pulse test',
353
+ lint: 'pulse lint'
354
+ },
355
+ dependencies: {
356
+ 'pulse-js-framework': '^1.0.0'
357
+ },
358
+ devDependencies: {
359
+ vite: '^5.0.0'
360
+ }
361
+ };
362
+
363
+ writeFileSync(
364
+ join(projectPath, 'package.json'),
365
+ JSON.stringify(packageJson, null, 2)
366
+ );
367
+
368
+ // Create vite.config.js
369
+ const viteConfig = `import { defineConfig } from 'vite';
370
+ import pulse from 'pulse-js-framework/vite';
371
+
372
+ export default defineConfig({
373
+ plugins: [pulse()]
374
+ });
375
+ `;
376
+ writeFileSync(join(projectPath, 'vite.config.js'), viteConfig);
377
+
378
+ // Create .gitignore
379
+ const gitignore = `node_modules
380
+ dist
381
+ .DS_Store
382
+ *.local
383
+ `;
384
+ writeFileSync(join(projectPath, '.gitignore'), gitignore);
385
+
386
+ log.info(`
387
+ Project created successfully!
388
+
389
+ Next steps:
390
+ cd ${projectName}
391
+ npm install
392
+ npm run dev
393
+
394
+ Template: ${templateInfo.name}
395
+ ${templateInfo.description}
396
+
397
+ Happy coding with Pulse!
398
+ `);
399
+ return;
400
+ }
401
+
402
+ // Standard project creation (no template)
217
403
  log.info(`Creating new Pulse project: ${projectName}${useTypescript ? ' (TypeScript)' : ''}`);
218
404
 
219
405
  // Create project structure
package/compiler/lexer.js CHANGED
@@ -85,13 +85,24 @@ export const TokenType = {
85
85
  PLUSPLUS: 'PLUSPLUS', // ++
86
86
  MINUSMINUS: 'MINUSMINUS', // --
87
87
  QUESTION: 'QUESTION', // ?
88
+ NULLISH: 'NULLISH', // ??
89
+ OPTIONAL_CHAIN: 'OPTIONAL_CHAIN', // ?.
88
90
  ARROW: 'ARROW', // =>
89
91
  SPREAD: 'SPREAD', // ...
92
+ // Logical/Nullish Assignment Operators (ES2021)
93
+ OR_ASSIGN: 'OR_ASSIGN', // ||=
94
+ AND_ASSIGN: 'AND_ASSIGN', // &&=
95
+ NULLISH_ASSIGN: 'NULLISH_ASSIGN', // ??=
96
+ PLUS_ASSIGN: 'PLUS_ASSIGN', // +=
97
+ MINUS_ASSIGN: 'MINUS_ASSIGN', // -=
98
+ STAR_ASSIGN: 'STAR_ASSIGN', // *=
99
+ SLASH_ASSIGN: 'SLASH_ASSIGN', // /=
90
100
 
91
101
  // Literals
92
102
  STRING: 'STRING',
93
103
  TEMPLATE: 'TEMPLATE', // Template literal `...`
94
104
  NUMBER: 'NUMBER',
105
+ BIGINT: 'BIGINT', // BigInt literal 123n
95
106
  TRUE: 'TRUE',
96
107
  FALSE: 'FALSE',
97
108
  NULL: 'NULL',
@@ -365,22 +376,96 @@ export class Lexer {
365
376
 
366
377
  /**
367
378
  * Read a number literal
379
+ * Supports:
380
+ * - Integers: 42
381
+ * - Decimals: 3.14
382
+ * - Scientific notation: 1e10, 1.5e-3
383
+ * - Numeric separators (ES2021): 1_000_000, 0xFF_FF_FF
384
+ * - BigInt literals (ES2020): 123n
385
+ * - Hex: 0xFF, Binary: 0b101, Octal: 0o777
368
386
  */
369
387
  readNumber() {
370
388
  const startLine = this.line;
371
389
  const startColumn = this.column;
372
390
  let value = '';
391
+ let rawValue = '';
392
+ let isBigInt = false;
393
+
394
+ // Check for hex, binary, or octal prefixes
395
+ if (this.current() === '0') {
396
+ rawValue += this.advance();
397
+ value += '0';
398
+
399
+ if (this.current() === 'x' || this.current() === 'X') {
400
+ // Hexadecimal
401
+ rawValue += this.advance();
402
+ value += 'x';
403
+ while (!this.isEOF() && /[0-9a-fA-F_]/.test(this.current())) {
404
+ const char = this.advance();
405
+ rawValue += char;
406
+ if (char !== '_') value += char; // Skip separators in actual value
407
+ }
408
+ // Check for BigInt suffix
409
+ if (this.current() === 'n') {
410
+ rawValue += this.advance();
411
+ isBigInt = true;
412
+ }
413
+ if (isBigInt) {
414
+ return new Token(TokenType.BIGINT, value + 'n', startLine, startColumn, rawValue);
415
+ }
416
+ return new Token(TokenType.NUMBER, parseInt(value, 16), startLine, startColumn, rawValue);
417
+ } else if (this.current() === 'b' || this.current() === 'B') {
418
+ // Binary
419
+ rawValue += this.advance();
420
+ value += 'b';
421
+ while (!this.isEOF() && /[01_]/.test(this.current())) {
422
+ const char = this.advance();
423
+ rawValue += char;
424
+ if (char !== '_') value += char;
425
+ }
426
+ if (this.current() === 'n') {
427
+ rawValue += this.advance();
428
+ isBigInt = true;
429
+ }
430
+ if (isBigInt) {
431
+ return new Token(TokenType.BIGINT, value + 'n', startLine, startColumn, rawValue);
432
+ }
433
+ return new Token(TokenType.NUMBER, parseInt(value.slice(2), 2), startLine, startColumn, rawValue);
434
+ } else if (this.current() === 'o' || this.current() === 'O') {
435
+ // Octal
436
+ rawValue += this.advance();
437
+ value += 'o';
438
+ while (!this.isEOF() && /[0-7_]/.test(this.current())) {
439
+ const char = this.advance();
440
+ rawValue += char;
441
+ if (char !== '_') value += char;
442
+ }
443
+ if (this.current() === 'n') {
444
+ rawValue += this.advance();
445
+ isBigInt = true;
446
+ }
447
+ if (isBigInt) {
448
+ return new Token(TokenType.BIGINT, value + 'n', startLine, startColumn, rawValue);
449
+ }
450
+ return new Token(TokenType.NUMBER, parseInt(value.slice(2), 8), startLine, startColumn, rawValue);
451
+ }
452
+ }
373
453
 
374
- // Integer part
375
- while (!this.isEOF() && /[0-9]/.test(this.current())) {
376
- value += this.advance();
454
+ // Regular decimal number (or continuation of '0')
455
+ while (!this.isEOF() && /[0-9_]/.test(this.current())) {
456
+ const char = this.advance();
457
+ rawValue += char;
458
+ if (char !== '_') value += char;
377
459
  }
378
460
 
379
461
  // Decimal part
380
462
  if (this.current() === '.' && /[0-9]/.test(this.peek())) {
381
- value += this.advance(); // .
382
- while (!this.isEOF() && /[0-9]/.test(this.current())) {
383
- value += this.advance();
463
+ rawValue += this.advance();
464
+ value += '.';
465
+ while (!this.isEOF() && /[0-9_]/.test(this.current())) {
466
+ const char = this.advance();
467
+ rawValue += char;
468
+ if (char !== '_') value += char;
384
469
  }
385
470
  }
386
471
 
@@ -394,18 +479,32 @@ export class Lexer {
394
479
  ((nextChar === '+' || nextChar === '-') && /[0-9]/.test(nextNextChar));
395
480
 
396
481
  if (isScientific) {
397
- value += this.advance(); // consume 'e' or 'E'
482
+ rawValue += this.advance();
483
+ value += 'e';
398
484
  if (this.current() === '+' || this.current() === '-') {
399
- value += this.advance();
485
+ const sign = this.advance();
486
+ rawValue += sign;
487
+ value += sign;
400
488
  }
401
- while (!this.isEOF() && /[0-9]/.test(this.current())) {
402
- value += this.advance();
489
+ while (!this.isEOF() && /[0-9_]/.test(this.current())) {
490
+ const char = this.advance();
491
+ rawValue += char;
492
+ if (char !== '_') value += char;
403
493
  }
404
494
  }
405
495
  // If not scientific notation, leave 'e' for the next token (e.g., 'em' unit)
406
496
  }
407
497
 
408
- return new Token(TokenType.NUMBER, parseFloat(value), startLine, startColumn, value);
498
+ // Check for BigInt suffix 'n'
499
+ if (this.current() === 'n') {
500
+ rawValue += this.advance();
501
+ isBigInt = true;
502
+ }
503
+
504
+ if (isBigInt) {
505
+ return new Token(TokenType.BIGINT, value + 'n', startLine, startColumn, rawValue);
506
+ }
507
+ return new Token(TokenType.NUMBER, parseFloat(value), startLine, startColumn, rawValue);
409
508
  }
410
509
 
411
510
  /**
@@ -586,6 +685,9 @@ export class Lexer {
586
685
  if (this.current() === '+') {
587
686
  this.advance();
588
687
  this.tokens.push(new Token(TokenType.PLUSPLUS, '++', startLine, startColumn));
688
+ } else if (this.current() === '=') {
689
+ this.advance();
690
+ this.tokens.push(new Token(TokenType.PLUS_ASSIGN, '+=', startLine, startColumn));
589
691
  } else {
590
692
  this.tokens.push(new Token(TokenType.PLUS, '+', startLine, startColumn));
591
693
  }
@@ -595,17 +697,30 @@ export class Lexer {
595
697
  if (this.current() === '-') {
596
698
  this.advance();
597
699
  this.tokens.push(new Token(TokenType.MINUSMINUS, '--', startLine, startColumn));
700
+ } else if (this.current() === '=') {
701
+ this.advance();
702
+ this.tokens.push(new Token(TokenType.MINUS_ASSIGN, '-=', startLine, startColumn));
598
703
  } else {
599
704
  this.tokens.push(new Token(TokenType.MINUS, '-', startLine, startColumn));
600
705
  }
601
706
  continue;
602
707
  case '*':
603
708
  this.advance();
604
- this.tokens.push(new Token(TokenType.STAR, '*', startLine, startColumn));
709
+ if (this.current() === '=') {
710
+ this.advance();
711
+ this.tokens.push(new Token(TokenType.STAR_ASSIGN, '*=', startLine, startColumn));
712
+ } else {
713
+ this.tokens.push(new Token(TokenType.STAR, '*', startLine, startColumn));
714
+ }
605
715
  continue;
606
716
  case '/':
607
717
  this.advance();
608
- this.tokens.push(new Token(TokenType.SLASH, '/', startLine, startColumn));
718
+ if (this.current() === '=') {
719
+ this.advance();
720
+ this.tokens.push(new Token(TokenType.SLASH_ASSIGN, '/=', startLine, startColumn));
721
+ } else {
722
+ this.tokens.push(new Token(TokenType.SLASH, '/', startLine, startColumn));
723
+ }
609
724
  continue;
610
725
  case '=':
611
726
  this.advance();
@@ -626,7 +741,25 @@ export class Lexer {
626
741
  continue;
627
742
  case '?':
628
743
  this.advance();
629
- this.tokens.push(new Token(TokenType.QUESTION, '?', startLine, startColumn));
744
+ if (this.current() === '?') {
745
+ this.advance();
746
+ if (this.current() === '=') {
747
+ this.advance();
748
+ this.tokens.push(new Token(TokenType.NULLISH_ASSIGN, '??=', startLine, startColumn));
749
+ } else {
750
+ this.tokens.push(new Token(TokenType.NULLISH, '??', startLine, startColumn));
751
+ }
752
+ } else if (this.current() === '.') {
753
+ // Optional chaining ?. but only if not followed by a digit (to avoid ?.5)
754
+ if (!/[0-9]/.test(this.peek())) {
755
+ this.advance();
756
+ this.tokens.push(new Token(TokenType.OPTIONAL_CHAIN, '?.', startLine, startColumn));
757
+ } else {
758
+ this.tokens.push(new Token(TokenType.QUESTION, '?', startLine, startColumn));
759
+ }
760
+ } else {
761
+ this.tokens.push(new Token(TokenType.QUESTION, '?', startLine, startColumn));
762
+ }
630
763
  continue;
631
764
  case '%':
632
765
  this.advance();
@@ -668,7 +801,12 @@ export class Lexer {
668
801
  this.advance();
669
802
  if (this.current() === '&') {
670
803
  this.advance();
671
- this.tokens.push(new Token(TokenType.AND, '&&', startLine, startColumn));
804
+ if (this.current() === '=') {
805
+ this.advance();
806
+ this.tokens.push(new Token(TokenType.AND_ASSIGN, '&&=', startLine, startColumn));
807
+ } else {
808
+ this.tokens.push(new Token(TokenType.AND, '&&', startLine, startColumn));
809
+ }
672
810
  } else {
673
811
  // Single & is the CSS parent selector
674
812
  this.tokens.push(new Token(TokenType.AMPERSAND, '&', startLine, startColumn));
@@ -678,7 +816,12 @@ export class Lexer {
678
816
  this.advance();
679
817
  if (this.current() === '|') {
680
818
  this.advance();
681
- this.tokens.push(new Token(TokenType.OR, '||', startLine, startColumn));
819
+ if (this.current() === '=') {
820
+ this.advance();
821
+ this.tokens.push(new Token(TokenType.OR_ASSIGN, '||=', startLine, startColumn));
822
+ } else {
823
+ this.tokens.push(new Token(TokenType.OR, '||', startLine, startColumn));
824
+ }
682
825
  }
683
826
  continue;
684
827
  }
@@ -169,8 +169,13 @@ export function transformExpressionString(transformer, exprStr) {
169
169
  `${stateVar}.get()`
170
170
  );
171
171
  }
172
- // Add optional chaining after function calls followed by property access
173
- result = result.replace(/(\w+\([^)]*\))\.(\w)/g, '$1?.$2');
172
+
173
+ // NOTE: Removed aggressive optional chaining regex that was adding ?.
174
+ // after ALL function calls. This caused false positives like:
175
+ // "User.name" -> "User?.name" in string literals.
176
+ // Optional chaining should be explicitly written by developers, not auto-added.
177
+ // The lexer now properly tokenizes ?. as OPTIONAL_CHAIN for explicit usage.
178
+
174
179
  return result;
175
180
  }
176
181
 
@@ -63,6 +63,49 @@ function isKeyframesRule(selector) {
63
63
  return selector.trim().startsWith('@keyframes');
64
64
  }
65
65
 
66
+ /**
67
+ * Check if selector is @layer (CSS Cascade Layers)
68
+ * @param {string} selector - CSS selector
69
+ * @returns {boolean}
70
+ */
71
+ function isLayerRule(selector) {
72
+ return selector.trim().startsWith('@layer');
73
+ }
74
+
75
+ /**
76
+ * Check if selector is @supports (CSS Feature Queries)
77
+ * @param {string} selector - CSS selector
78
+ * @returns {boolean}
79
+ */
80
+ function isSupportsRule(selector) {
81
+ return selector.trim().startsWith('@supports');
82
+ }
83
+
84
+ /**
85
+ * Check if selector is @container (CSS Container Queries)
86
+ * @param {string} selector - CSS selector
87
+ * @returns {boolean}
88
+ */
89
+ function isContainerRule(selector) {
90
+ return selector.trim().startsWith('@container');
91
+ }
92
+
93
+ /**
94
+ * Check if selector is a conditional group at-rule that can contain nested rules
95
+ * These include @media, @supports, @container, @layer
96
+ * @param {string} selector - CSS selector
97
+ * @returns {boolean}
98
+ */
99
+ function isConditionalGroupAtRule(selector) {
100
+ const trimmed = selector.trim();
101
+ return trimmed.startsWith('@media') ||
102
+ trimmed.startsWith('@supports') ||
103
+ trimmed.startsWith('@container') ||
104
+ trimmed.startsWith('@layer') ||
105
+ trimmed.startsWith('@scope') ||
106
+ trimmed.startsWith('@document');
107
+ }
108
+
66
109
  /**
67
110
  * Check if a selector is a keyframe step (from, to, or percentage)
68
111
  * @param {string} selector - CSS selector
@@ -76,7 +119,7 @@ function isKeyframeStep(selector) {
76
119
  /**
77
120
  * Flatten nested CSS rules by combining selectors
78
121
  * Handles CSS nesting by prepending parent selector to nested rules
79
- * Special handling for @-rules (media queries, keyframes, etc.)
122
+ * Special handling for @-rules (media queries, keyframes, supports, container, layer, etc.)
80
123
  * @param {Object} transformer - Transformer instance
81
124
  * @param {Object} rule - CSS rule from AST
82
125
  * @param {string} parentSelector - Parent selector to prepend (empty for top-level)
@@ -90,27 +133,96 @@ export function flattenStyleRule(transformer, rule, parentSelector, output, atRu
90
133
  // Check if this is an @-rule
91
134
  if (isAtRule(selector)) {
92
135
  const isKeyframes = isKeyframesRule(selector);
136
+ const isLayer = isLayerRule(selector);
137
+ const isConditionalGroup = isConditionalGroupAtRule(selector);
93
138
 
94
139
  // @keyframes should be output as a complete block, not flattened
95
140
  if (isKeyframes) {
96
141
  const lines = [];
97
- lines.push(` ${selector} {`);
98
-
99
- // Output all keyframe steps
100
- for (const nested of rule.nestedRules) {
101
- lines.push(` ${nested.selector} {`);
102
- for (const prop of nested.properties) {
103
- lines.push(` ${prop.name}: ${prop.value};`);
142
+ // Wrap in existing @-rule if present
143
+ if (atRuleWrapper) {
144
+ lines.push(` ${atRuleWrapper} {`);
145
+ lines.push(` ${selector} {`);
146
+ for (const nested of rule.nestedRules) {
147
+ lines.push(` ${nested.selector} {`);
148
+ for (const prop of nested.properties) {
149
+ lines.push(` ${prop.name}: ${prop.value};`);
150
+ }
151
+ lines.push(' }');
104
152
  }
105
153
  lines.push(' }');
154
+ lines.push(' }');
155
+ } else {
156
+ lines.push(` ${selector} {`);
157
+ for (const nested of rule.nestedRules) {
158
+ lines.push(` ${nested.selector} {`);
159
+ for (const prop of nested.properties) {
160
+ lines.push(` ${prop.name}: ${prop.value};`);
161
+ }
162
+ lines.push(' }');
163
+ }
164
+ lines.push(' }');
165
+ }
166
+ output.push(lines.join('\n'));
167
+ return;
168
+ }
169
+
170
+ // @layer - output with its content, support both named layers and anonymous layer blocks
171
+ if (isLayer) {
172
+ // Check if it's just a layer statement (@layer name;) or a layer block (@layer name { ... })
173
+ if (rule.nestedRules.length === 0 && rule.properties.length === 0) {
174
+ // Layer order statement: @layer base, components, utilities;
175
+ output.push(` ${selector};`);
176
+ return;
106
177
  }
107
178
 
108
- lines.push(' }');
179
+ // Layer block with content
180
+ const lines = [];
181
+
182
+ if (atRuleWrapper) {
183
+ lines.push(` ${atRuleWrapper} {`);
184
+ lines.push(` ${selector} {`);
185
+ } else {
186
+ lines.push(` ${selector} {`);
187
+ }
188
+
189
+ // Process nested rules within the layer
190
+ const nestedOutput = [];
191
+ for (const nested of rule.nestedRules) {
192
+ flattenStyleRule(transformer, nested, '', nestedOutput, '', false);
193
+ }
194
+
195
+ // Add nested output with proper indentation
196
+ const baseIndent = atRuleWrapper ? ' ' : ' ';
197
+ for (const nestedRule of nestedOutput) {
198
+ // Adjust indentation for nested rules
199
+ const reindented = nestedRule.split('\n').map(line => baseIndent + line.trim()).join('\n');
200
+ lines.push(reindented);
201
+ }
202
+
203
+ if (atRuleWrapper) {
204
+ lines.push(' }');
205
+ lines.push(' }');
206
+ } else {
207
+ lines.push(' }');
208
+ }
109
209
  output.push(lines.join('\n'));
110
210
  return;
111
211
  }
112
212
 
113
- // Other @-rules (@media, @supports) wrap their nested rules
213
+ // Conditional group @-rules (@media, @supports, @container) wrap their nested rules
214
+ // They can be nested inside each other
215
+ if (isConditionalGroup) {
216
+ // Combine with existing wrapper if present
217
+ const combinedWrapper = atRuleWrapper ? `${atRuleWrapper} { ${selector}` : selector;
218
+
219
+ for (const nested of rule.nestedRules) {
220
+ flattenStyleRule(transformer, nested, parentSelector, output, combinedWrapper, false);
221
+ }
222
+ return;
223
+ }
224
+
225
+ // Other @-rules (unknown) - output as-is with nested content
114
226
  for (const nested of rule.nestedRules) {
115
227
  flattenStyleRule(transformer, nested, '', output, selector, false);
116
228
  }
@@ -172,6 +284,9 @@ export function flattenStyleRule(transformer, rule, parentSelector, output, atRu
172
284
  * .container -> .container.p123abc
173
285
  * div -> div.p123abc
174
286
  * .a .b -> .a.p123abc .b.p123abc
287
+ * .a > .b -> .a.p123abc > .b.p123abc (preserves combinators)
288
+ * .a + .b -> .a.p123abc + .b.p123abc
289
+ * .a ~ .b -> .a.p123abc ~ .b.p123abc
175
290
  * @media (max-width: 900px) -> @media (max-width: 900px) (unchanged)
176
291
  * :root, body, *, html -> unchanged (global selectors)
177
292
  * @param {Object} transformer - Transformer instance
@@ -196,31 +311,81 @@ export function scopeStyleSelector(transformer, selector) {
196
311
  return selector;
197
312
  }
198
313
 
314
+ // CSS combinators that should be preserved
315
+ const combinators = new Set(['>', '+', '~']);
316
+
199
317
  // Split by comma for multiple selectors
200
318
  return selector.split(',').map(part => {
201
319
  part = part.trim();
202
320
 
203
- // Split by space for descendant selectors
204
- return part.split(/\s+/).map(segment => {
321
+ // Split by whitespace but preserve combinators
322
+ // This regex splits on whitespace but keeps combinators as separate tokens
323
+ const tokens = part.split(/(\s*[>+~]\s*|\s+)/).filter(t => t.trim());
324
+ const result = [];
325
+
326
+ for (let i = 0; i < tokens.length; i++) {
327
+ const token = tokens[i].trim();
328
+
329
+ // Check if this is a combinator
330
+ if (combinators.has(token)) {
331
+ result.push(` ${token} `);
332
+ continue;
333
+ }
334
+
335
+ // Skip empty tokens
336
+ if (!token) continue;
337
+
205
338
  // Check if this segment is a global selector
206
- const segmentBase = segment.split(/[.#\[]/)[0];
207
- if (globalSelectors.has(segmentBase) || globalSelectors.has(segment)) {
208
- return segment;
339
+ const segmentBase = token.split(/[.#\[]/)[0];
340
+ if (globalSelectors.has(segmentBase) || globalSelectors.has(token)) {
341
+ result.push(token);
342
+ continue;
343
+ }
344
+
345
+ // Handle :has(), :is(), :where(), :not() - scope selectors inside
346
+ if (token.includes(':has(') || token.includes(':is(') ||
347
+ token.includes(':where(') || token.includes(':not(')) {
348
+ result.push(scopePseudoClassSelector(transformer, token));
349
+ continue;
209
350
  }
210
351
 
211
352
  // Skip pseudo-elements and pseudo-classes at the end
212
- const pseudoMatch = segment.match(/^([^:]+)(:.+)?$/);
353
+ const pseudoMatch = token.match(/^([^:]+)(:.+)?$/);
213
354
  if (pseudoMatch) {
214
355
  const base = pseudoMatch[1];
215
356
  const pseudo = pseudoMatch[2] || '';
216
357
 
217
358
  // Skip if it's just a pseudo selector (like :root)
218
- if (!base || globalSelectors.has(`:${pseudo.slice(1)}`)) return segment;
359
+ if (!base || globalSelectors.has(`:${pseudo.slice(1)}`)) {
360
+ result.push(token);
361
+ continue;
362
+ }
219
363
 
220
364
  // Add scope class
221
- return `${base}.${transformer.scopeId}${pseudo}`;
365
+ result.push(`${base}.${transformer.scopeId}${pseudo}`);
366
+ continue;
222
367
  }
223
- return `${segment}.${transformer.scopeId}`;
224
- }).join(' ');
368
+ result.push(`${token}.${transformer.scopeId}`);
369
+ }
370
+
371
+ return result.join('');
225
372
  }).join(', ');
226
373
  }
374
+
375
+ /**
376
+ * Scope selectors inside functional pseudo-classes like :has(), :is(), :where(), :not()
377
+ * @param {Object} transformer - Transformer instance
378
+ * @param {string} selector - Selector containing functional pseudo-class
379
+ * @returns {string} Scoped selector
380
+ */
381
+ function scopePseudoClassSelector(transformer, selector) {
382
+ // Match functional pseudo-classes: :has(), :is(), :where(), :not()
383
+ return selector.replace(
384
+ /:(has|is|where|not)\(([^)]+)\)/g,
385
+ (_match, pseudoClass, inner) => {
386
+ // Recursively scope the inner selector
387
+ const scopedInner = scopeStyleSelector(transformer, inner);
388
+ return `:${pseudoClass}(${scopedInner})`;
389
+ }
390
+ );
391
+ }
@@ -355,8 +355,17 @@ function parseBalancedExpression(str, start) {
355
355
  * @param {string} selector - CSS selector with potential dynamic attributes
356
356
  * @returns {Object} { cleanSelector, dynamicAttrs }
357
357
  */
358
+ /**
359
+ * Extract all attributes (static and dynamic) from a selector string.
360
+ * Static attributes become part of the attrs object passed to el().
361
+ * Dynamic attributes (with {expr} values) are bound via bind().
362
+ *
363
+ * @param {string} selector - Element selector like "div.class[href=url][value={expr}]"
364
+ * @returns {{cleanSelector: string, staticAttrs: Array<{name: string, value: string}>, dynamicAttrs: Array<{name: string, expr: string}>}}
365
+ */
358
366
  function extractDynamicAttributes(selector) {
359
367
  const dynamicAttrs = [];
368
+ const staticAttrs = [];
360
369
  let cleanSelector = '';
361
370
  let i = 0;
362
371
 
@@ -367,7 +376,7 @@ function extractDynamicAttributes(selector) {
367
376
 
368
377
  // Parse attribute name
369
378
  let attrName = '';
370
- while (i < selector.length && /[a-zA-Z0-9-]/.test(selector[i])) {
379
+ while (i < selector.length && /[a-zA-Z0-9-_]/.test(selector[i])) {
371
380
  attrName += selector[i];
372
381
  i++;
373
382
  }
@@ -382,9 +391,12 @@ function extractDynamicAttributes(selector) {
382
391
  // Skip whitespace
383
392
  while (i < selector.length && /\s/.test(selector[i])) i++;
384
393
 
385
- // Check for optional quote
386
- const hasQuote = selector[i] === '"';
387
- if (hasQuote) i++;
394
+ // Determine quote character (or none)
395
+ let quoteChar = null;
396
+ if (selector[i] === '"' || selector[i] === "'") {
397
+ quoteChar = selector[i];
398
+ i++;
399
+ }
388
400
 
389
401
  // Check for dynamic expression {
390
402
  if (selector[i] === '{') {
@@ -394,7 +406,7 @@ function extractDynamicAttributes(selector) {
394
406
  i = result.end + 1; // Skip past closing }
395
407
 
396
408
  // Skip optional closing quote
397
- if (hasQuote && selector[i] === '"') i++;
409
+ if (quoteChar && selector[i] === quoteChar) i++;
398
410
 
399
411
  // Skip closing ]
400
412
  if (selector[i] === ']') i++;
@@ -403,16 +415,40 @@ function extractDynamicAttributes(selector) {
403
415
  continue;
404
416
  }
405
417
  }
406
- }
407
418
 
408
- // Not a dynamic attribute, copy everything from [ to ]
409
- let bracketDepth = 1;
410
- cleanSelector += '[';
411
- while (i < selector.length && bracketDepth > 0) {
412
- if (selector[i] === '[') bracketDepth++;
413
- else if (selector[i] === ']') bracketDepth--;
414
- cleanSelector += selector[i];
415
- i++;
419
+ // Static attribute - parse the value
420
+ let attrValue = '';
421
+ if (quoteChar) {
422
+ // Quoted value - read until closing quote
423
+ while (i < selector.length && selector[i] !== quoteChar) {
424
+ attrValue += selector[i];
425
+ i++;
426
+ }
427
+ // Skip closing quote
428
+ if (selector[i] === quoteChar) i++;
429
+ } else {
430
+ // Unquoted value - read until ]
431
+ while (i < selector.length && selector[i] !== ']') {
432
+ attrValue += selector[i];
433
+ i++;
434
+ }
435
+ }
436
+
437
+ // Skip closing ]
438
+ if (selector[i] === ']') i++;
439
+
440
+ // Add to static attrs (don't put in selector)
441
+ staticAttrs.push({ name: attrName, value: attrValue });
442
+ continue;
443
+ } else {
444
+ // Boolean attribute (no value) like [disabled]
445
+ // Skip to closing ]
446
+ while (i < selector.length && selector[i] !== ']') i++;
447
+ if (selector[i] === ']') i++;
448
+
449
+ // Add as boolean attribute
450
+ staticAttrs.push({ name: attrName, value: '' });
451
+ continue;
416
452
  }
417
453
  } else {
418
454
  cleanSelector += selector[i];
@@ -420,7 +456,7 @@ function extractDynamicAttributes(selector) {
420
456
  }
421
457
  }
422
458
 
423
- return { cleanSelector, dynamicAttrs };
459
+ return { cleanSelector, staticAttrs, dynamicAttrs };
424
460
  }
425
461
 
426
462
  /**
@@ -445,8 +481,8 @@ export function transformElement(transformer, node, indent) {
445
481
  return transformComponentCall(transformer, node, indent);
446
482
  }
447
483
 
448
- // Extract dynamic attributes from selector (e.g., [value={searchQuery}])
449
- let { cleanSelector, dynamicAttrs } = extractDynamicAttributes(node.selector);
484
+ // Extract all attributes from selector (static and dynamic)
485
+ let { cleanSelector, staticAttrs, dynamicAttrs } = extractDynamicAttributes(node.selector);
450
486
 
451
487
  // Add scoped class to selector if CSS scoping is enabled
452
488
  let selector = cleanSelector;
@@ -483,51 +519,46 @@ export function transformElement(transformer, node, indent) {
483
519
  transformer.usesA11y.trapFocus = true;
484
520
  }
485
521
 
486
- // Build ARIA attributes from directives
487
- const ariaAttrs = [];
522
+ // Collect all static attributes for the el() attrs object
523
+ const allStaticAttrs = [];
488
524
 
489
- // Process @a11y directives
525
+ // Add attributes extracted from selector
526
+ for (const attr of staticAttrs) {
527
+ // Escape single quotes in values
528
+ const escapedValue = attr.value.replace(/'/g, "\\'");
529
+ if (attr.value === '') {
530
+ // Boolean attribute
531
+ allStaticAttrs.push(`'${attr.name}': true`);
532
+ } else {
533
+ allStaticAttrs.push(`'${attr.name}': '${escapedValue}'`);
534
+ }
535
+ }
536
+
537
+ // Process @a11y directives - add to static attrs
490
538
  for (const directive of a11yDirectives) {
491
539
  const attrs = buildA11yAttributes(transformer, directive);
492
540
  for (const [key, value] of Object.entries(attrs)) {
493
541
  const valueCode = typeof value === 'string' ? `'${value}'` : value;
494
- ariaAttrs.push(`'${key}': ${valueCode}`);
542
+ allStaticAttrs.push(`'${key}': ${valueCode}`);
495
543
  }
496
544
  }
497
545
 
498
546
  // Process @live directives (add aria-live and aria-atomic)
499
547
  for (const directive of liveDirectives) {
500
548
  const priority = directive.priority || 'polite';
501
- ariaAttrs.push(`'aria-live': '${priority}'`);
502
- ariaAttrs.push(`'aria-atomic': 'true'`);
503
- }
504
-
505
- // Build selector with inline ARIA attributes
506
- let enhancedSelector = selector;
507
- if (ariaAttrs.length > 0) {
508
- // Convert ARIA attrs to selector attribute syntax where possible
509
- // For dynamic values, we'll need to use setAriaAttributes
510
- const staticAttrs = [];
511
- const dynamicAttrs = [];
512
-
513
- for (const attr of ariaAttrs) {
514
- const match = attr.match(/^'([^']+)':\s*'([^']+)'$/);
515
- if (match) {
516
- // Static attribute - can embed in selector
517
- staticAttrs.push(`[${match[1]}=${match[2]}]`);
518
- } else {
519
- dynamicAttrs.push(attr);
520
- }
521
- }
522
-
523
- // Add static ARIA attributes to selector
524
- enhancedSelector = selector + staticAttrs.join('');
549
+ allStaticAttrs.push(`'aria-live': '${priority}'`);
550
+ allStaticAttrs.push(`'aria-atomic': 'true'`);
525
551
  }
526
552
 
527
553
  // Start with el() call - escape single quotes in selector
528
- const escapedSelector = enhancedSelector.replace(/'/g, "\\'");
554
+ const escapedSelector = selector.replace(/'/g, "\\'");
529
555
  parts.push(`${pad}el('${escapedSelector}'`);
530
556
 
557
+ // Add attributes object if we have any static attributes
558
+ if (allStaticAttrs.length > 0) {
559
+ parts.push(`, { ${allStaticAttrs.join(', ')} }`);
560
+ }
561
+
531
562
  // Add text content
532
563
  if (node.textContent.length > 0) {
533
564
  for (const text of node.textContent) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.7.25",
3
+ "version": "1.7.29",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "main": "index.js",