pulse-js-framework 1.7.26 → 1.7.30
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/cli/dev.js +5 -2
- package/compiler/lexer.js +159 -16
- package/compiler/parser.js +6 -1
- package/compiler/sourcemap.js +5 -3
- package/compiler/transformer/constants.js +2 -1
- package/compiler/transformer/expressions.js +124 -18
- package/compiler/transformer/imports.js +5 -0
- package/compiler/transformer/index.js +9 -9
- package/compiler/transformer/state.js +51 -0
- package/compiler/transformer/style.js +185 -20
- package/compiler/transformer/view.js +118 -20
- package/package.json +3 -2
- package/runtime/pulse.js +36 -1
package/cli/dev.js
CHANGED
|
@@ -134,12 +134,15 @@ export async function startDevServer(args) {
|
|
|
134
134
|
});
|
|
135
135
|
res.end(result.code);
|
|
136
136
|
} else {
|
|
137
|
+
const errorDetails = result.errors.map(e => `${e.message} at line ${e.line || '?'}:${e.column || '?'}`).join('\n');
|
|
138
|
+
console.error(`[Pulse] Compilation error in ${filePath}:`, result.errors);
|
|
137
139
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
138
|
-
res.end(`Compilation error: ${
|
|
140
|
+
res.end(`Compilation error: ${errorDetails}\nFile: ${filePath}`);
|
|
139
141
|
}
|
|
140
142
|
} catch (error) {
|
|
143
|
+
console.error(`[Pulse] Error compiling ${filePath}:`, error);
|
|
141
144
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
142
|
-
res.end(`Error: ${error.message}`);
|
|
145
|
+
res.end(`Error: ${error.message}\nFile: ${filePath}`);
|
|
143
146
|
}
|
|
144
147
|
return;
|
|
145
148
|
}
|
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
|
-
//
|
|
375
|
-
while (!this.isEOF() && /[0-
|
|
376
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
482
|
+
rawValue += this.advance();
|
|
483
|
+
value += 'e';
|
|
398
484
|
if (this.current() === '+' || this.current() === '-') {
|
|
399
|
-
|
|
485
|
+
const sign = this.advance();
|
|
486
|
+
rawValue += sign;
|
|
487
|
+
value += sign;
|
|
400
488
|
}
|
|
401
|
-
while (!this.isEOF() && /[0-
|
|
402
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|
package/compiler/parser.js
CHANGED
|
@@ -1597,9 +1597,14 @@ export class Parser {
|
|
|
1597
1597
|
|
|
1598
1598
|
// Special case: . or # after an identifier needs space (descendant selector)
|
|
1599
1599
|
// e.g., ".school .date" - need space between "school" and "."
|
|
1600
|
+
// BUT NOT for "body.dark" where . is directly adjacent to body (no whitespace)
|
|
1601
|
+
// We check if tokens are adjacent by comparing positions
|
|
1602
|
+
const expectedNextCol = lastToken ? (lastToken.column + String(lastToken.value).length) : 0;
|
|
1603
|
+
const tokensAreAdjacent = token.column === expectedNextCol;
|
|
1600
1604
|
const isDescendantSelector = (tokenValue === '.' || tokenValue === '#') &&
|
|
1601
1605
|
lastToken?.type === TokenType.IDENT &&
|
|
1602
|
-
!inAtRule
|
|
1606
|
+
!inAtRule && // Don't add space in @media selectors
|
|
1607
|
+
!tokensAreAdjacent; // Only add space if not directly adjacent
|
|
1603
1608
|
|
|
1604
1609
|
// Special case: hyphenated class/id names like .job-title, .card-3d, max-width
|
|
1605
1610
|
// Check if we're continuing a class/id name - the last part should end with alphanumeric
|
package/compiler/sourcemap.js
CHANGED
|
@@ -210,9 +210,11 @@ export class SourceMapGenerator {
|
|
|
210
210
|
* @returns {string} Comment with base64 encoded source map
|
|
211
211
|
*/
|
|
212
212
|
toComment() {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
// Use Buffer for base64 encoding (supports UTF-8, unlike btoa which is Latin1-only)
|
|
214
|
+
const json = this.toString();
|
|
215
|
+
const base64 = typeof Buffer !== 'undefined'
|
|
216
|
+
? Buffer.from(json, 'utf-8').toString('base64')
|
|
217
|
+
: btoa(unescape(encodeURIComponent(json))); // Browser fallback for UTF-8
|
|
216
218
|
return `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64}`;
|
|
217
219
|
}
|
|
218
220
|
|
|
@@ -33,7 +33,8 @@ export const PUNCT_NO_SPACE_AFTER = [
|
|
|
33
33
|
/** JavaScript statement keywords */
|
|
34
34
|
export const STATEMENT_KEYWORDS = new Set([
|
|
35
35
|
'let', 'const', 'var', 'return', 'if', 'else', 'for', 'while',
|
|
36
|
-
'switch', 'throw', 'try', 'catch', 'finally'
|
|
36
|
+
'switch', 'throw', 'try', 'catch', 'finally', 'break', 'continue',
|
|
37
|
+
'case', 'default'
|
|
37
38
|
]);
|
|
38
39
|
|
|
39
40
|
/** Built-in JavaScript functions and objects */
|
|
@@ -25,9 +25,9 @@ export function transformExpression(transformer, node) {
|
|
|
25
25
|
|
|
26
26
|
switch (node.type) {
|
|
27
27
|
case NodeType.Identifier:
|
|
28
|
-
// Props
|
|
28
|
+
// Props and state are both reactive (wrapped in computed via useProp or pulse)
|
|
29
29
|
if (transformer.propVars.has(node.name)) {
|
|
30
|
-
return node.name
|
|
30
|
+
return `${node.name}.get()`;
|
|
31
31
|
}
|
|
32
32
|
if (transformer.stateVars.has(node.name)) {
|
|
33
33
|
return `${node.name}.get()`;
|
|
@@ -47,12 +47,18 @@ export function transformExpression(transformer, node) {
|
|
|
47
47
|
|
|
48
48
|
case NodeType.MemberExpression: {
|
|
49
49
|
const obj = transformExpression(transformer, node.object);
|
|
50
|
-
// Use optional chaining when accessing properties on
|
|
50
|
+
// Use optional chaining when accessing properties on:
|
|
51
|
+
// 1. Function call results (could return null/undefined)
|
|
52
|
+
// 2. Props (commonly receive null values like notification: null)
|
|
53
|
+
// Note: State vars don't get optional chaining to avoid breaking array/object methods
|
|
51
54
|
const isCallResult = node.object.type === NodeType.CallExpression;
|
|
52
|
-
const
|
|
55
|
+
const isProp = node.object.type === NodeType.Identifier &&
|
|
56
|
+
transformer.propVars.has(node.object.name);
|
|
57
|
+
const useOptionalChaining = isCallResult || isProp;
|
|
58
|
+
const accessor = useOptionalChaining ? '?.' : '.';
|
|
53
59
|
if (node.computed) {
|
|
54
60
|
const prop = transformExpression(transformer, node.property);
|
|
55
|
-
return
|
|
61
|
+
return useOptionalChaining ? `${obj}?.[${prop}]` : `${obj}[${prop}]`;
|
|
56
62
|
}
|
|
57
63
|
return `${obj}${accessor}${node.property}`;
|
|
58
64
|
}
|
|
@@ -156,21 +162,39 @@ export function transformExpression(transformer, node) {
|
|
|
156
162
|
* @returns {string} Transformed expression string
|
|
157
163
|
*/
|
|
158
164
|
export function transformExpressionString(transformer, exprStr) {
|
|
159
|
-
// Simple transformation: wrap state vars with .get()
|
|
160
|
-
//
|
|
165
|
+
// Simple transformation: wrap state and prop vars with .get()
|
|
166
|
+
// Both are now reactive (useProp returns computed for uniform interface)
|
|
161
167
|
let result = exprStr;
|
|
168
|
+
|
|
169
|
+
// Transform state vars
|
|
162
170
|
for (const stateVar of transformer.stateVars) {
|
|
163
|
-
// Skip if this var name is also a prop (props shadow state in render scope)
|
|
164
|
-
if (transformer.propVars.has(stateVar)) {
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
171
|
result = result.replace(
|
|
168
172
|
new RegExp(`\\b${stateVar}\\b`, 'g'),
|
|
169
173
|
`${stateVar}.get()`
|
|
170
174
|
);
|
|
171
175
|
}
|
|
172
|
-
|
|
173
|
-
|
|
176
|
+
|
|
177
|
+
// Transform prop vars (now also reactive via useProp)
|
|
178
|
+
// Add optional chaining when followed by property access for nullable props
|
|
179
|
+
// Props commonly receive null values (e.g., notification: null)
|
|
180
|
+
for (const propVar of transformer.propVars) {
|
|
181
|
+
result = result.replace(
|
|
182
|
+
new RegExp(`\\b${propVar}\\b(?=\\.)`, 'g'),
|
|
183
|
+
`${propVar}.get()?`
|
|
184
|
+
);
|
|
185
|
+
// Handle standalone prop var (not followed by property access)
|
|
186
|
+
result = result.replace(
|
|
187
|
+
new RegExp(`\\b${propVar}\\b(?!\\.)`, 'g'),
|
|
188
|
+
`${propVar}.get()`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// NOTE: Removed aggressive optional chaining regex that was adding ?.
|
|
193
|
+
// after ALL function calls. This caused false positives like:
|
|
194
|
+
// "User.name" -> "User?.name" in string literals.
|
|
195
|
+
// Optional chaining should be explicitly written by developers, not auto-added.
|
|
196
|
+
// The lexer now properly tokenizes ?. as OPTIONAL_CHAIN for explicit usage.
|
|
197
|
+
|
|
174
198
|
return result;
|
|
175
199
|
}
|
|
176
200
|
|
|
@@ -181,7 +205,7 @@ export function transformExpressionString(transformer, exprStr) {
|
|
|
181
205
|
* @returns {string} JavaScript code
|
|
182
206
|
*/
|
|
183
207
|
export function transformFunctionBody(transformer, tokens) {
|
|
184
|
-
const { stateVars, actionNames } = transformer;
|
|
208
|
+
const { stateVars, propVars, actionNames } = transformer;
|
|
185
209
|
let code = '';
|
|
186
210
|
let lastToken = null;
|
|
187
211
|
let lastNonSpaceToken = null;
|
|
@@ -189,15 +213,17 @@ export function transformFunctionBody(transformer, tokens) {
|
|
|
189
213
|
// Tokens that must follow } directly without semicolon
|
|
190
214
|
const NO_SEMI_BEFORE = new Set(['catch', 'finally', 'else']);
|
|
191
215
|
|
|
192
|
-
const needsManualSemicolon = (token, nextToken, lastNonSpace) => {
|
|
216
|
+
const needsManualSemicolon = (token, nextToken, lastNonSpace, tokenIndex) => {
|
|
193
217
|
if (!token || lastNonSpace?.value === 'new') return false;
|
|
194
218
|
// Don't add semicolon after 'await' - it always needs its expression
|
|
195
219
|
if (lastNonSpace?.value === 'await') return false;
|
|
196
|
-
// For 'return': bare return followed by statement keyword needs semicolon
|
|
220
|
+
// For 'return': bare return followed by statement keyword or state assignment needs semicolon
|
|
197
221
|
if (lastNonSpace?.value === 'return') {
|
|
198
222
|
// If followed by a statement keyword, it's a bare return - needs semicolon
|
|
199
223
|
if (token.type === 'IDENT' && STATEMENT_KEYWORDS.has(token.value)) return true;
|
|
200
224
|
if (STATEMENT_TOKEN_TYPES.has(token.type)) return true;
|
|
225
|
+
// If followed by a state variable assignment, it's a bare return - needs semicolon
|
|
226
|
+
if (token.type === 'IDENT' && stateVars.has(token.value) && nextToken?.type === 'EQ') return true;
|
|
201
227
|
return false; // return expression - no semicolon
|
|
202
228
|
}
|
|
203
229
|
// Don't add semicolon before catch/finally/else after }
|
|
@@ -206,9 +232,25 @@ export function transformFunctionBody(transformer, tokens) {
|
|
|
206
232
|
if (token.type !== 'IDENT') return false;
|
|
207
233
|
if (STATEMENT_KEYWORDS.has(token.value)) return true;
|
|
208
234
|
if (stateVars.has(token.value) && nextToken?.type === 'EQ') return true;
|
|
235
|
+
// Any identifier followed by = after a statement end is an assignment statement
|
|
236
|
+
if (nextToken?.type === 'EQ' && lastNonSpace?.type === 'RPAREN') return true;
|
|
209
237
|
if (nextToken?.type === 'LPAREN' &&
|
|
210
238
|
(BUILTIN_FUNCTIONS.has(token.value) || actionNames.has(token.value))) return true;
|
|
211
239
|
if (nextToken?.type === 'DOT' && BUILTIN_FUNCTIONS.has(token.value)) return true;
|
|
240
|
+
|
|
241
|
+
// Check for property assignment or method call: identifier.property = value OR identifier.method()
|
|
242
|
+
// This is a new statement if token is followed by .property = ... or .method(...)
|
|
243
|
+
if (nextToken?.type === 'DOT') {
|
|
244
|
+
// Look ahead to see if this is an assignment or method call
|
|
245
|
+
for (let j = tokenIndex + 1; j < tokens.length && j < tokenIndex + 10; j++) {
|
|
246
|
+
const t = tokens[j];
|
|
247
|
+
// Assignment: identifier.property = value
|
|
248
|
+
if (t.type === 'EQ' && tokens[j-1]?.type === 'IDENT') return true;
|
|
249
|
+
// Method call: identifier.method()
|
|
250
|
+
if (t.type === 'LPAREN' && tokens[j-1]?.type === 'IDENT') return true;
|
|
251
|
+
if (t.type === 'SEMI') break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
212
254
|
return false;
|
|
213
255
|
};
|
|
214
256
|
|
|
@@ -237,7 +279,7 @@ export function transformFunctionBody(transformer, tokens) {
|
|
|
237
279
|
}
|
|
238
280
|
|
|
239
281
|
// Add semicolon before statement starters
|
|
240
|
-
if (needsManualSemicolon(token, nextToken, lastNonSpaceToken) &&
|
|
282
|
+
if (needsManualSemicolon(token, nextToken, lastNonSpaceToken, i) &&
|
|
241
283
|
afterStatementEnd(lastNonSpaceToken)) {
|
|
242
284
|
if (!afterIfCondition && lastToken && lastToken.value !== ';' && lastToken.value !== '{') {
|
|
243
285
|
code += '; ';
|
|
@@ -411,10 +453,74 @@ export function transformFunctionBody(transformer, tokens) {
|
|
|
411
453
|
|
|
412
454
|
// Replace state var reads (not in assignments, not already with .get/.set)
|
|
413
455
|
// Allow spread operators (...stateVar) but block member access (obj.stateVar)
|
|
456
|
+
// Skip object literal keys (e.g., { users: value } - don't transform the key 'users')
|
|
414
457
|
for (const stateVar of stateVars) {
|
|
415
458
|
code = code.replace(
|
|
416
459
|
new RegExp(`(?:(?<=\\.\\.\\.)|(?<!\\.))\\b${stateVar}\\b(?!\\s*=(?!=)|\\s*\\(|\\s*\\.(?:get|set))`, 'g'),
|
|
417
|
-
|
|
460
|
+
(match, offset) => {
|
|
461
|
+
// Check if this is an object key by looking at context
|
|
462
|
+
// Pattern: after { or , and before : (with arbitrary content between)
|
|
463
|
+
const after = code.slice(offset + match.length, offset + match.length + 10);
|
|
464
|
+
|
|
465
|
+
// If followed by : (not ::), check if it's an object key
|
|
466
|
+
if (/^\s*:(?!:)/.test(after)) {
|
|
467
|
+
// Look backwards for the nearest { or , that would indicate object context
|
|
468
|
+
// We need to track bracket depth to handle nested structures
|
|
469
|
+
let depth = 0;
|
|
470
|
+
for (let i = offset - 1; i >= 0; i--) {
|
|
471
|
+
const ch = code[i];
|
|
472
|
+
if (ch === ')' || ch === ']') depth++;
|
|
473
|
+
else if (ch === '(' || ch === '[') depth--;
|
|
474
|
+
else if (ch === '}') depth++;
|
|
475
|
+
else if (ch === '{') {
|
|
476
|
+
if (depth === 0) {
|
|
477
|
+
// Found opening brace at same depth - this is an object key
|
|
478
|
+
return match;
|
|
479
|
+
}
|
|
480
|
+
depth--;
|
|
481
|
+
}
|
|
482
|
+
else if (ch === ',' && depth === 0) {
|
|
483
|
+
// Found comma at same depth - this is an object key
|
|
484
|
+
return match;
|
|
485
|
+
}
|
|
486
|
+
// Stop if we hit a semicolon at depth 0 (different statement)
|
|
487
|
+
else if (ch === ';' && depth === 0) break;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return `${stateVar}.get()`;
|
|
492
|
+
}
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Replace prop var reads (props are reactive via useProp, need .get() like state vars)
|
|
497
|
+
// For props: allow function calls (prop callbacks need .get()() pattern)
|
|
498
|
+
// Skip object keys, allow spreads, block member access
|
|
499
|
+
for (const propVar of propVars) {
|
|
500
|
+
code = code.replace(
|
|
501
|
+
new RegExp(`(?:(?<=\\.\\.\\.)|(?<!\\.))\\b${propVar}\\b(?!\\s*=(?!=)|\\s*\\.(?:get|set))`, 'g'),
|
|
502
|
+
(match, offset) => {
|
|
503
|
+
// Check if this is an object key
|
|
504
|
+
const after = code.slice(offset + match.length, offset + match.length + 10);
|
|
505
|
+
|
|
506
|
+
if (/^\s*:(?!:)/.test(after)) {
|
|
507
|
+
let depth = 0;
|
|
508
|
+
for (let i = offset - 1; i >= 0; i--) {
|
|
509
|
+
const ch = code[i];
|
|
510
|
+
if (ch === ')' || ch === ']') depth++;
|
|
511
|
+
else if (ch === '(' || ch === '[') depth--;
|
|
512
|
+
else if (ch === '}') depth++;
|
|
513
|
+
else if (ch === '{') {
|
|
514
|
+
if (depth === 0) return match;
|
|
515
|
+
depth--;
|
|
516
|
+
}
|
|
517
|
+
else if (ch === ',' && depth === 0) return match;
|
|
518
|
+
else if (ch === ';' && depth === 0) break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return `${propVar}.get()`;
|
|
523
|
+
}
|
|
418
524
|
);
|
|
419
525
|
}
|
|
420
526
|
|
|
@@ -46,6 +46,11 @@ export function generateImports(transformer) {
|
|
|
46
46
|
'model'
|
|
47
47
|
];
|
|
48
48
|
|
|
49
|
+
// Add useProp if component has props (for reactive prop support)
|
|
50
|
+
if (transformer.propVars.size > 0) {
|
|
51
|
+
runtimeImports.push('useProp');
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
lines.push(`import { ${runtimeImports.join(', ')} } from '${options.runtime}';`);
|
|
50
55
|
|
|
51
56
|
// A11y imports (if a11y features are used)
|
|
@@ -178,15 +178,7 @@ export class Transformer {
|
|
|
178
178
|
extractImportedComponents(this, this.ast.imports);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
//
|
|
182
|
-
if (this.ast.view) {
|
|
183
|
-
this._scanA11yUsage(this.ast.view);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Imports (runtime + user imports)
|
|
187
|
-
parts.push(generateImports(this));
|
|
188
|
-
|
|
189
|
-
// Extract prop variables
|
|
181
|
+
// Extract prop variables (before imports so useProp can be conditionally imported)
|
|
190
182
|
if (this.ast.props) {
|
|
191
183
|
extractPropVars(this, this.ast.props);
|
|
192
184
|
}
|
|
@@ -201,6 +193,14 @@ export class Transformer {
|
|
|
201
193
|
extractActionNames(this, this.ast.actions);
|
|
202
194
|
}
|
|
203
195
|
|
|
196
|
+
// Pre-scan for a11y usage to determine imports
|
|
197
|
+
if (this.ast.view) {
|
|
198
|
+
this._scanA11yUsage(this.ast.view);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Imports (runtime + user imports) - after extraction so we know what to import
|
|
202
|
+
parts.push(generateImports(this));
|
|
203
|
+
|
|
204
204
|
// Store (must come before router so $store is available to guards)
|
|
205
205
|
if (this.ast.store) {
|
|
206
206
|
parts.push(transformStore(this, this.ast.store, transformValue));
|
|
@@ -93,6 +93,22 @@ export function transformState(transformer, stateBlock) {
|
|
|
93
93
|
return lines.join('\n');
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Check if an action body references any prop variables
|
|
98
|
+
* @param {Array} bodyTokens - Function body tokens
|
|
99
|
+
* @param {Set} propVars - Set of prop variable names
|
|
100
|
+
* @returns {boolean} True if action references props
|
|
101
|
+
*/
|
|
102
|
+
export function actionReferencesProp(bodyTokens, propVars) {
|
|
103
|
+
if (propVars.size === 0) return false;
|
|
104
|
+
for (const token of bodyTokens) {
|
|
105
|
+
if (token.type === 'IDENT' && propVars.has(token.value)) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
96
112
|
/**
|
|
97
113
|
* Transform actions block to function declarations
|
|
98
114
|
* @param {Object} transformer - Transformer instance
|
|
@@ -104,6 +120,11 @@ export function transformActions(transformer, actionsBlock, transformFunctionBod
|
|
|
104
120
|
const lines = ['// Actions'];
|
|
105
121
|
|
|
106
122
|
for (const fn of actionsBlock.functions) {
|
|
123
|
+
// Skip actions that reference props - they'll be generated inside render()
|
|
124
|
+
if (actionReferencesProp(fn.body, transformer.propVars)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
107
128
|
const asyncKeyword = fn.async ? 'async ' : '';
|
|
108
129
|
const params = fn.params.join(', ');
|
|
109
130
|
const body = transformFunctionBody(transformer, fn.body);
|
|
@@ -116,3 +137,33 @@ export function transformActions(transformer, actionsBlock, transformFunctionBod
|
|
|
116
137
|
|
|
117
138
|
return lines.join('\n');
|
|
118
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Transform prop-dependent actions (to be placed inside render function)
|
|
143
|
+
* @param {Object} transformer - Transformer instance
|
|
144
|
+
* @param {Object} actionsBlock - Actions block from AST
|
|
145
|
+
* @param {Function} transformFunctionBody - Function to transform body tokens
|
|
146
|
+
* @param {string} indent - Indentation string
|
|
147
|
+
* @returns {string} JavaScript code
|
|
148
|
+
*/
|
|
149
|
+
export function transformPropDependentActions(transformer, actionsBlock, transformFunctionBody, indent = ' ') {
|
|
150
|
+
const lines = [];
|
|
151
|
+
|
|
152
|
+
for (const fn of actionsBlock.functions) {
|
|
153
|
+
// Only include actions that reference props
|
|
154
|
+
if (!actionReferencesProp(fn.body, transformer.propVars)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const asyncKeyword = fn.async ? 'async ' : '';
|
|
159
|
+
const params = fn.params.join(', ');
|
|
160
|
+
const body = transformFunctionBody(transformer, fn.body);
|
|
161
|
+
|
|
162
|
+
lines.push(`${indent}${asyncKeyword}function ${fn.name}(${params}) {`);
|
|
163
|
+
lines.push(`${indent} ${body}`);
|
|
164
|
+
lines.push(`${indent}}`);
|
|
165
|
+
lines.push('');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
|
@@ -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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
204
|
-
|
|
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 =
|
|
207
|
-
if (globalSelectors.has(segmentBase) || globalSelectors.has(
|
|
208
|
-
|
|
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 =
|
|
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)}`))
|
|
359
|
+
if (!base || globalSelectors.has(`:${pseudo.slice(1)}`)) {
|
|
360
|
+
result.push(token);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
219
363
|
|
|
220
364
|
// Add scope class
|
|
221
|
-
|
|
365
|
+
result.push(`${base}.${transformer.scopeId}${pseudo}`);
|
|
366
|
+
continue;
|
|
222
367
|
}
|
|
223
|
-
|
|
224
|
-
}
|
|
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
|
+
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { NodeType } from '../parser.js';
|
|
8
|
-
import { transformValue } from './state.js';
|
|
9
|
-
import { transformExpression, transformExpressionString } from './expressions.js';
|
|
8
|
+
import { transformValue, transformPropDependentActions } from './state.js';
|
|
9
|
+
import { transformExpression, transformExpressionString, transformFunctionBody } from './expressions.js';
|
|
10
10
|
|
|
11
11
|
/** View node transformers lookup table */
|
|
12
12
|
export const VIEW_NODE_HANDLERS = {
|
|
@@ -38,14 +38,27 @@ export function transformView(transformer, viewBlock) {
|
|
|
38
38
|
// Generate render function with props parameter
|
|
39
39
|
lines.push('function render({ props = {}, slots = {} } = {}) {');
|
|
40
40
|
|
|
41
|
-
//
|
|
41
|
+
// Extract props using useProp() for reactive prop support
|
|
42
|
+
// useProp checks for a getter (propName$) and returns a computed if present
|
|
42
43
|
if (transformer.propVars.size > 0) {
|
|
43
|
-
const
|
|
44
|
+
for (const name of transformer.propVars) {
|
|
44
45
|
const defaultValue = transformer.propDefaults.get(name);
|
|
45
46
|
const defaultCode = defaultValue ? transformValue(transformer, defaultValue) : 'undefined';
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
47
|
+
lines.push(` const ${name} = useProp(props, '${name}', ${defaultCode});`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Generate prop-dependent actions inside render (after useProp declarations)
|
|
52
|
+
if (transformer.ast.actions && transformer.propVars.size > 0) {
|
|
53
|
+
const propActions = transformPropDependentActions(
|
|
54
|
+
transformer,
|
|
55
|
+
transformer.ast.actions,
|
|
56
|
+
transformFunctionBody,
|
|
57
|
+
' '
|
|
58
|
+
);
|
|
59
|
+
if (propActions.trim()) {
|
|
60
|
+
lines.push(propActions);
|
|
61
|
+
}
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
lines.push(' return (');
|
|
@@ -416,7 +429,7 @@ function extractDynamicAttributes(selector) {
|
|
|
416
429
|
}
|
|
417
430
|
}
|
|
418
431
|
|
|
419
|
-
//
|
|
432
|
+
// Parse the attribute value (may be static or contain interpolations)
|
|
420
433
|
let attrValue = '';
|
|
421
434
|
if (quoteChar) {
|
|
422
435
|
// Quoted value - read until closing quote
|
|
@@ -437,8 +450,15 @@ function extractDynamicAttributes(selector) {
|
|
|
437
450
|
// Skip closing ]
|
|
438
451
|
if (selector[i] === ']') i++;
|
|
439
452
|
|
|
440
|
-
//
|
|
441
|
-
|
|
453
|
+
// Check if the value contains interpolation {expr}
|
|
454
|
+
// If so, treat as dynamic attribute with template string
|
|
455
|
+
if (attrValue.includes('{') && attrValue.includes('}')) {
|
|
456
|
+
// Convert "text {expr} more" to template string: `text ${expr} more`
|
|
457
|
+
dynamicAttrs.push({ name: attrName, expr: attrValue, isInterpolated: true });
|
|
458
|
+
} else {
|
|
459
|
+
// Pure static attribute
|
|
460
|
+
staticAttrs.push({ name: attrName, value: attrValue });
|
|
461
|
+
}
|
|
442
462
|
continue;
|
|
443
463
|
} else {
|
|
444
464
|
// Boolean attribute (no value) like [disabled]
|
|
@@ -618,7 +638,16 @@ export function transformElement(transformer, node, indent) {
|
|
|
618
638
|
|
|
619
639
|
// Chain dynamic attribute bindings (e.g., [value={searchQuery}])
|
|
620
640
|
for (const attr of dynamicAttrs) {
|
|
621
|
-
|
|
641
|
+
let exprCode;
|
|
642
|
+
if (attr.isInterpolated) {
|
|
643
|
+
// String with interpolation: "display: {show ? 'block' : 'none'}"
|
|
644
|
+
// Convert to template literal: `display: ${show.get() ? 'block' : 'none'}`
|
|
645
|
+
const templateStr = attr.expr.replace(/\{/g, '${');
|
|
646
|
+
exprCode = '`' + transformExpressionString(transformer, templateStr) + '`';
|
|
647
|
+
} else {
|
|
648
|
+
// Pure expression: {searchQuery}
|
|
649
|
+
exprCode = transformExpressionString(transformer, attr.expr);
|
|
650
|
+
}
|
|
622
651
|
result = `bind(${result}, '${attr.name}', () => ${exprCode})`;
|
|
623
652
|
}
|
|
624
653
|
|
|
@@ -642,8 +671,69 @@ export function addScopeToSelector(transformer, selector) {
|
|
|
642
671
|
return `${selector}.${transformer.scopeId}`;
|
|
643
672
|
}
|
|
644
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Check if an expression references any state variables
|
|
676
|
+
* @param {Object} transformer - Transformer instance
|
|
677
|
+
* @param {Object} node - AST node to check
|
|
678
|
+
* @returns {boolean} True if expression uses state variables
|
|
679
|
+
*/
|
|
680
|
+
function expressionUsesState(transformer, node) {
|
|
681
|
+
if (!node) return false;
|
|
682
|
+
|
|
683
|
+
switch (node.type) {
|
|
684
|
+
case NodeType.Identifier:
|
|
685
|
+
return transformer.stateVars.has(node.name);
|
|
686
|
+
|
|
687
|
+
case NodeType.MemberExpression:
|
|
688
|
+
return expressionUsesState(transformer, node.object) ||
|
|
689
|
+
(node.computed && expressionUsesState(transformer, node.property));
|
|
690
|
+
|
|
691
|
+
case NodeType.CallExpression:
|
|
692
|
+
return expressionUsesState(transformer, node.callee) ||
|
|
693
|
+
node.arguments.some(arg => expressionUsesState(transformer, arg));
|
|
694
|
+
|
|
695
|
+
case NodeType.BinaryExpression:
|
|
696
|
+
case NodeType.ConditionalExpression:
|
|
697
|
+
return expressionUsesState(transformer, node.left) ||
|
|
698
|
+
expressionUsesState(transformer, node.right) ||
|
|
699
|
+
(node.test && expressionUsesState(transformer, node.test)) ||
|
|
700
|
+
(node.consequent && expressionUsesState(transformer, node.consequent)) ||
|
|
701
|
+
(node.alternate && expressionUsesState(transformer, node.alternate));
|
|
702
|
+
|
|
703
|
+
case NodeType.UnaryExpression:
|
|
704
|
+
case NodeType.UpdateExpression:
|
|
705
|
+
return expressionUsesState(transformer, node.argument);
|
|
706
|
+
|
|
707
|
+
case NodeType.ArrayLiteral:
|
|
708
|
+
return node.elements?.some(el => expressionUsesState(transformer, el));
|
|
709
|
+
|
|
710
|
+
case NodeType.ObjectLiteral:
|
|
711
|
+
return node.properties?.some(prop =>
|
|
712
|
+
expressionUsesState(transformer, prop.value)
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
case NodeType.ArrowFunction:
|
|
716
|
+
// Arrow functions capture state, but don't need wrapping themselves
|
|
717
|
+
return false;
|
|
718
|
+
|
|
719
|
+
case NodeType.SpreadElement:
|
|
720
|
+
return expressionUsesState(transformer, node.argument);
|
|
721
|
+
|
|
722
|
+
default:
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
645
727
|
/**
|
|
646
728
|
* Transform a component call (imported component)
|
|
729
|
+
*
|
|
730
|
+
* For reactive props (those that reference state variables), we pass both:
|
|
731
|
+
* - The current value: `propName: value`
|
|
732
|
+
* - A getter function: `propName$: () => value`
|
|
733
|
+
*
|
|
734
|
+
* The child component can use `useProp(props, 'propName', default)` to get
|
|
735
|
+
* a reactive computed if a getter exists, or the static value otherwise.
|
|
736
|
+
*
|
|
647
737
|
* @param {Object} transformer - Transformer instance
|
|
648
738
|
* @param {Object} node - Element node (component)
|
|
649
739
|
* @param {number} indent - Indentation level
|
|
@@ -670,17 +760,26 @@ export function transformComponentCall(transformer, node, indent) {
|
|
|
670
760
|
}
|
|
671
761
|
|
|
672
762
|
// Build component call
|
|
673
|
-
let code = `${pad}${componentName}.render({ `;
|
|
674
|
-
|
|
675
763
|
const renderArgs = [];
|
|
676
764
|
|
|
677
765
|
// Add props if any
|
|
678
766
|
if (node.props && node.props.length > 0) {
|
|
679
|
-
const
|
|
767
|
+
const propEntries = [];
|
|
768
|
+
|
|
769
|
+
for (const prop of node.props) {
|
|
680
770
|
const valueCode = transformExpression(transformer, prop.value);
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
771
|
+
const usesState = expressionUsesState(transformer, prop.value);
|
|
772
|
+
|
|
773
|
+
if (usesState) {
|
|
774
|
+
// Reactive prop: pass both value and getter
|
|
775
|
+
// The getter allows child to create a reactive binding via useProp()
|
|
776
|
+
propEntries.push(`${prop.name}$: () => ${valueCode}`);
|
|
777
|
+
}
|
|
778
|
+
// Always pass the current value (for non-useProp access and initial render)
|
|
779
|
+
propEntries.push(`${prop.name}: ${valueCode}`);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
renderArgs.push(`props: { ${propEntries.join(', ')} }`);
|
|
684
783
|
}
|
|
685
784
|
|
|
686
785
|
// Add slots if any
|
|
@@ -691,9 +790,8 @@ export function transformComponentCall(transformer, node, indent) {
|
|
|
691
790
|
renderArgs.push(`slots: { ${slotCode} }`);
|
|
692
791
|
}
|
|
693
792
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
return code;
|
|
793
|
+
const renderCall = `${componentName}.render({ ${renderArgs.join(', ')} })`;
|
|
794
|
+
return `${pad}${renderCall}`;
|
|
697
795
|
}
|
|
698
796
|
|
|
699
797
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.30",
|
|
4
4
|
"description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"LICENSE"
|
|
110
110
|
],
|
|
111
111
|
"scripts": {
|
|
112
|
-
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:css-parsing && npm run test:pulse && npm run test:dom && npm run test:dom-element && npm run test:dom-adapter && 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:lru-cache && npm run test:utils && npm run test:docs && npm run test:docs-nav && npm run test:async && 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:errors && npm run test:security && npm run test:websocket && npm run test:graphql && 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",
|
|
112
|
+
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:css-parsing && npm run test:pulse && npm run test:dom && npm run test:dom-element && npm run test:dom-adapter && 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:docs && npm run test:docs-nav && npm run test:async && 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:errors && npm run test:security && npm run test:websocket && npm run test:graphql && 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",
|
|
113
113
|
"test:compiler": "node test/compiler.test.js",
|
|
114
114
|
"test:sourcemap": "node test/sourcemap.test.js",
|
|
115
115
|
"test:css-parsing": "node test/css-parsing.test.js",
|
|
@@ -127,6 +127,7 @@
|
|
|
127
127
|
"test:analyze": "node test/analyze.test.js",
|
|
128
128
|
"test:cli": "node test/cli.test.js",
|
|
129
129
|
"test:cli-ui": "node test/cli-ui.test.js",
|
|
130
|
+
"test:cli-create": "node test/cli-create.test.js",
|
|
130
131
|
"test:lru-cache": "node test/lru-cache.test.js",
|
|
131
132
|
"test:utils": "node test/utils.test.js",
|
|
132
133
|
"test:docs": "node test/docs.test.js",
|
package/runtime/pulse.js
CHANGED
|
@@ -1316,6 +1316,39 @@ export function untrack(fn) {
|
|
|
1316
1316
|
}
|
|
1317
1317
|
}
|
|
1318
1318
|
|
|
1319
|
+
/**
|
|
1320
|
+
* Create a reactive prop from component props object.
|
|
1321
|
+
* If the prop has a reactive getter (marked with $ suffix), returns a computed.
|
|
1322
|
+
* Otherwise returns the static value.
|
|
1323
|
+
*
|
|
1324
|
+
* @template T
|
|
1325
|
+
* @param {Object} props - Component props object
|
|
1326
|
+
* @param {string} name - Prop name to extract
|
|
1327
|
+
* @param {T} [defaultValue] - Default value if prop is undefined
|
|
1328
|
+
* @returns {T|Pulse<T>} The prop value (reactive if getter exists, static otherwise)
|
|
1329
|
+
*
|
|
1330
|
+
* @example
|
|
1331
|
+
* // In compiled component render function:
|
|
1332
|
+
* function render({ props = {} } = {}) {
|
|
1333
|
+
* const darkMode = useProp(props, 'darkMode', false);
|
|
1334
|
+
* // darkMode is now reactive - can use in text(() => darkMode.get())
|
|
1335
|
+
* }
|
|
1336
|
+
*/
|
|
1337
|
+
export function useProp(props, name, defaultValue) {
|
|
1338
|
+
const getter = props[`${name}$`];
|
|
1339
|
+
if (typeof getter === 'function') {
|
|
1340
|
+
// Reactive prop - wrap in computed for automatic dependency tracking
|
|
1341
|
+
return computed(() => {
|
|
1342
|
+
const value = getter();
|
|
1343
|
+
return value !== undefined ? value : defaultValue;
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
// Static prop - wrap in computed for uniform interface
|
|
1347
|
+
// This allows child components to always use prop.get() consistently
|
|
1348
|
+
const value = props[name];
|
|
1349
|
+
return computed(() => value !== undefined ? value : defaultValue);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1319
1352
|
export default {
|
|
1320
1353
|
Pulse,
|
|
1321
1354
|
pulse,
|
|
@@ -1337,5 +1370,7 @@ export default {
|
|
|
1337
1370
|
// HMR support
|
|
1338
1371
|
setCurrentModule,
|
|
1339
1372
|
clearCurrentModule,
|
|
1340
|
-
disposeModule
|
|
1373
|
+
disposeModule,
|
|
1374
|
+
// Component props helper
|
|
1375
|
+
useProp
|
|
1341
1376
|
};
|