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 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: ${result.errors.map(e => e.message).join('\n')}`);
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
- // 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
  }
@@ -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; // Don't add space in @media selectors
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
@@ -210,9 +210,11 @@ export class SourceMapGenerator {
210
210
  * @returns {string} Comment with base64 encoded source map
211
211
  */
212
212
  toComment() {
213
- const base64 = typeof btoa === 'function'
214
- ? btoa(this.toString())
215
- : Buffer.from(this.toString()).toString('base64');
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 take precedence over state (props are destructured in render scope)
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 function call results
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 accessor = isCallResult ? '?.' : '.';
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 isCallResult ? `${obj}?.[${prop}]` : `${obj}[${prop}]`;
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
- // Props take precedence - don't wrap props with .get()
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
- // Add optional chaining after function calls followed by property access
173
- result = result.replace(/(\w+\([^)]*\))\.(\w)/g, '$1?.$2');
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
- `${stateVar}.get()`
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
- // Pre-scan for a11y usage to determine imports
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
- 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
+ }
@@ -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
- // Destructure props with defaults if component has props
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 propsDestructure = [...transformer.propVars].map(name => {
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
- return `${name} = ${defaultCode}`;
47
- }).join(', ');
48
- lines.push(` const { ${propsDestructure} } = props;`);
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
- // Static attribute - parse the value
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
- // Add to static attrs (don't put in selector)
441
- staticAttrs.push({ name: attrName, value: attrValue });
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
- const exprCode = transformExpressionString(transformer, attr.expr);
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 propsCode = node.props.map(prop => {
767
+ const propEntries = [];
768
+
769
+ for (const prop of node.props) {
680
770
  const valueCode = transformExpression(transformer, prop.value);
681
- return `${prop.name}: ${valueCode}`;
682
- }).join(', ');
683
- renderArgs.push(`props: { ${propsCode} }`);
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
- code += renderArgs.join(', ');
695
- code += ' })';
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.26",
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
  };