pulse-js-framework 1.7.9 → 1.7.11

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.
@@ -27,6 +27,13 @@ export const NodeType = {
27
27
  IfDirective: 'IfDirective',
28
28
  EachDirective: 'EachDirective',
29
29
  EventDirective: 'EventDirective',
30
+ ModelDirective: 'ModelDirective',
31
+
32
+ // Accessibility directives
33
+ A11yDirective: 'A11yDirective',
34
+ LiveDirective: 'LiveDirective',
35
+ FocusTrapDirective: 'FocusTrapDirective',
36
+
30
37
  Property: 'Property',
31
38
  ObjectLiteral: 'ObjectLiteral',
32
39
  ArrayLiteral: 'ArrayLiteral',
@@ -763,6 +770,12 @@ export class Parser {
763
770
 
764
771
  const name = this.expect(TokenType.IDENT).value;
765
772
 
773
+ // Collect modifiers (.prevent, .stop, .enter, .lazy, etc.)
774
+ const modifiers = [];
775
+ while (this.is(TokenType.DIRECTIVE_MOD)) {
776
+ modifiers.push(this.advance().value);
777
+ }
778
+
766
779
  if (name === 'if') {
767
780
  return this.parseIfDirective();
768
781
  }
@@ -770,8 +783,27 @@ export class Parser {
770
783
  return this.parseEachDirective();
771
784
  }
772
785
 
786
+ // Accessibility directives
787
+ if (name === 'a11y') {
788
+ return this.parseA11yDirective();
789
+ }
790
+ if (name === 'live') {
791
+ return this.parseLiveDirective();
792
+ }
793
+ if (name === 'focusTrap') {
794
+ return this.parseFocusTrapDirective();
795
+ }
796
+ if (name === 'srOnly') {
797
+ return this.parseSrOnlyDirective();
798
+ }
799
+
800
+ // @model directive for two-way binding
801
+ if (name === 'model') {
802
+ return this.parseModelDirective(modifiers);
803
+ }
804
+
773
805
  // Event directive like @click
774
- return this.parseEventDirective(name);
806
+ return this.parseEventDirective(name, modifiers);
775
807
  }
776
808
 
777
809
  /**
@@ -781,16 +813,42 @@ export class Parser {
781
813
  this.expect(TokenType.AT);
782
814
  const name = this.expect(TokenType.IDENT).value;
783
815
 
784
- // Event directive
816
+ // Collect modifiers (.prevent, .stop, .enter, .lazy, etc.)
817
+ const modifiers = [];
818
+ while (this.is(TokenType.DIRECTIVE_MOD)) {
819
+ modifiers.push(this.advance().value);
820
+ }
821
+
822
+ // Check for a11y directives
823
+ if (name === 'a11y') {
824
+ return this.parseA11yDirective();
825
+ }
826
+ if (name === 'live') {
827
+ return this.parseLiveDirective();
828
+ }
829
+ if (name === 'focusTrap') {
830
+ return this.parseFocusTrapDirective();
831
+ }
832
+ if (name === 'srOnly') {
833
+ return this.parseSrOnlyDirective();
834
+ }
835
+
836
+ // @model directive for two-way binding
837
+ if (name === 'model') {
838
+ return this.parseModelDirective(modifiers);
839
+ }
840
+
841
+ // Event directive (click, submit, etc.)
785
842
  this.expect(TokenType.LPAREN);
786
843
  const expression = this.parseExpression();
787
844
  this.expect(TokenType.RPAREN);
788
845
 
789
- return new ASTNode(NodeType.EventDirective, { event: name, handler: expression });
846
+ return new ASTNode(NodeType.EventDirective, { event: name, handler: expression, modifiers });
790
847
  }
791
848
 
792
849
  /**
793
- * Parse @if directive
850
+ * Parse @if directive with @else-if/@else chains
851
+ * Syntax: @if (cond) { } @else-if (cond) { } @else { }
794
852
  */
795
853
  parseIfDirective() {
796
854
  this.expect(TokenType.LPAREN);
@@ -804,23 +862,76 @@ export class Parser {
804
862
  }
805
863
  this.expect(TokenType.RBRACE);
806
864
 
865
+ const elseIfBranches = [];
807
866
  let alternate = null;
808
- if (this.is(TokenType.AT) && this.peek()?.value === 'else') {
809
- this.advance(); // @
810
- this.advance(); // else
811
- this.expect(TokenType.LBRACE);
812
- alternate = [];
813
- while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
814
- alternate.push(this.parseViewChild());
867
+
868
+ // Parse @else-if and @else chains
869
+ while (this.is(TokenType.AT)) {
870
+ const nextToken = this.peek();
871
+
872
+ // Check for @else or @else-if
873
+ if (nextToken?.value === 'else') {
874
+ this.advance(); // @
875
+ this.advance(); // else
876
+
877
+ // Check if followed by @if or -if (making @else @if or @else-if)
878
+ if (this.is(TokenType.AT) && (this.peek()?.type === TokenType.IF || this.peek()?.value === 'if')) {
879
+ // @else @if pattern
880
+ this.advance(); // @
881
+ this.advance(); // if
882
+
883
+ this.expect(TokenType.LPAREN);
884
+ const elseIfCondition = this.parseExpression();
885
+ this.expect(TokenType.RPAREN);
886
+
887
+ this.expect(TokenType.LBRACE);
888
+ const elseIfConsequent = [];
889
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
890
+ elseIfConsequent.push(this.parseViewChild());
891
+ }
892
+ this.expect(TokenType.RBRACE);
893
+
894
+ elseIfBranches.push({ condition: elseIfCondition, consequent: elseIfConsequent });
895
+ }
896
+ // Check for -if pattern (@else-if as hyphenated)
897
+ else if (this.is(TokenType.MINUS) && (this.peek()?.type === TokenType.IF || this.peek()?.value === 'if')) {
898
+ this.advance(); // -
899
+ this.advance(); // if
900
+
901
+ this.expect(TokenType.LPAREN);
902
+ const elseIfCondition = this.parseExpression();
903
+ this.expect(TokenType.RPAREN);
904
+
905
+ this.expect(TokenType.LBRACE);
906
+ const elseIfConsequent = [];
907
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
908
+ elseIfConsequent.push(this.parseViewChild());
909
+ }
910
+ this.expect(TokenType.RBRACE);
911
+
912
+ elseIfBranches.push({ condition: elseIfCondition, consequent: elseIfConsequent });
913
+ }
914
+ // Plain @else
915
+ else {
916
+ this.expect(TokenType.LBRACE);
917
+ alternate = [];
918
+ while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
919
+ alternate.push(this.parseViewChild());
920
+ }
921
+ this.expect(TokenType.RBRACE);
922
+ break; // @else terminates the chain
923
+ }
924
+ } else {
925
+ break; // Not an @else variant
815
926
  }
816
- this.expect(TokenType.RBRACE);
817
927
  }
818
928
 
819
- return new ASTNode(NodeType.IfDirective, { condition, consequent, alternate });
929
+ return new ASTNode(NodeType.IfDirective, { condition, consequent, elseIfBranches, alternate });
820
930
  }
821
931
 
822
932
  /**
823
- * Parse @each/@for directive
933
+ * Parse @each/@for directive with optional key function
934
+ * Syntax: @for (item of items) key(item.id) { ... }
824
935
  */
825
936
  parseEachDirective() {
826
937
  this.expect(TokenType.LPAREN);
@@ -836,6 +947,15 @@ export class Parser {
836
947
  const iterable = this.parseExpression();
837
948
  this.expect(TokenType.RPAREN);
838
949
 
950
+ // Parse optional key function: key(item.id)
951
+ let keyExpr = null;
952
+ if (this.is(TokenType.IDENT) && this.current().value === 'key') {
953
+ this.advance(); // consume 'key'
954
+ this.expect(TokenType.LPAREN);
955
+ keyExpr = this.parseExpression();
956
+ this.expect(TokenType.RPAREN);
957
+ }
958
+
839
959
  this.expect(TokenType.LBRACE);
840
960
  const template = [];
841
961
  while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
@@ -843,13 +963,15 @@ export class Parser {
843
963
  }
844
964
  this.expect(TokenType.RBRACE);
845
965
 
846
- return new ASTNode(NodeType.EachDirective, { itemName, iterable, template });
966
+ return new ASTNode(NodeType.EachDirective, { itemName, iterable, template, keyExpr });
847
967
  }
848
968
 
849
969
  /**
850
- * Parse event directive
970
+ * Parse event directive with optional modifiers
971
+ * @param {string} event - Event name (click, keydown, etc.)
972
+ * @param {string[]} modifiers - Array of modifier names (prevent, stop, enter, etc.)
851
973
  */
852
- parseEventDirective(event) {
974
+ parseEventDirective(event, modifiers = []) {
853
975
  this.expect(TokenType.LPAREN);
854
976
  const handler = this.parseExpression();
855
977
  this.expect(TokenType.RPAREN);
@@ -863,7 +985,129 @@ export class Parser {
863
985
  this.expect(TokenType.RBRACE);
864
986
  }
865
987
 
866
- return new ASTNode(NodeType.EventDirective, { event, handler, children });
988
+ return new ASTNode(NodeType.EventDirective, { event, handler, children, modifiers });
989
+ }
990
+
991
+ /**
992
+ * Parse @model directive for two-way binding
993
+ * @model(name) or @model.lazy(name) or @model.lazy.trim(name)
994
+ * @param {string[]} modifiers - Array of modifier names (lazy, trim, number)
995
+ */
996
+ parseModelDirective(modifiers = []) {
997
+ this.expect(TokenType.LPAREN);
998
+ const binding = this.parseExpression();
999
+ this.expect(TokenType.RPAREN);
1000
+
1001
+ return new ASTNode(NodeType.ModelDirective, { binding, modifiers });
1002
+ }
1003
+
1004
+ /**
1005
+ * Parse @a11y directive - sets aria attributes
1006
+ * @a11y(label="Close menu") or @a11y(label="Close", describedby="desc")
1007
+ */
1008
+ parseA11yDirective() {
1009
+ this.expect(TokenType.LPAREN);
1010
+
1011
+ const attrs = {};
1012
+
1013
+ // Parse key=value pairs
1014
+ while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
1015
+ const key = this.expect(TokenType.IDENT).value;
1016
+ this.expect(TokenType.EQ);
1017
+
1018
+ let value;
1019
+ if (this.is(TokenType.STRING)) {
1020
+ value = this.advance().value;
1021
+ } else if (this.is(TokenType.TRUE)) {
1022
+ value = true;
1023
+ this.advance();
1024
+ } else if (this.is(TokenType.FALSE)) {
1025
+ value = false;
1026
+ this.advance();
1027
+ } else if (this.is(TokenType.IDENT)) {
1028
+ // Treat unquoted identifier as a string (e.g., role=dialog -> "dialog")
1029
+ value = this.advance().value;
1030
+ } else {
1031
+ value = this.parseExpression();
1032
+ }
1033
+
1034
+ attrs[key] = value;
1035
+
1036
+ if (this.is(TokenType.COMMA)) {
1037
+ this.advance();
1038
+ }
1039
+ }
1040
+
1041
+ this.expect(TokenType.RPAREN);
1042
+
1043
+ return new ASTNode(NodeType.A11yDirective, { attrs });
1044
+ }
1045
+
1046
+ /**
1047
+ * Parse @live directive - creates live region for screen readers
1048
+ * @live(polite) or @live(assertive)
1049
+ */
1050
+ parseLiveDirective() {
1051
+ this.expect(TokenType.LPAREN);
1052
+
1053
+ let priority = 'polite';
1054
+ if (this.is(TokenType.IDENT)) {
1055
+ priority = this.advance().value;
1056
+ }
1057
+
1058
+ this.expect(TokenType.RPAREN);
1059
+
1060
+ return new ASTNode(NodeType.LiveDirective, { priority });
1061
+ }
1062
+
1063
+ /**
1064
+ * Parse @focusTrap directive - traps focus within element
1065
+ * @focusTrap or @focusTrap(autoFocus=true)
1066
+ */
1067
+ parseFocusTrapDirective() {
1068
+ const options = {};
1069
+
1070
+ if (this.is(TokenType.LPAREN)) {
1071
+ this.advance();
1072
+
1073
+ while (!this.is(TokenType.RPAREN) && !this.is(TokenType.EOF)) {
1074
+ const key = this.expect(TokenType.IDENT).value;
1075
+
1076
+ if (this.is(TokenType.EQ)) {
1077
+ this.advance();
1078
+ if (this.is(TokenType.TRUE)) {
1079
+ options[key] = true;
1080
+ this.advance();
1081
+ } else if (this.is(TokenType.FALSE)) {
1082
+ options[key] = false;
1083
+ this.advance();
1084
+ } else if (this.is(TokenType.STRING)) {
1085
+ options[key] = this.advance().value;
1086
+ } else {
1087
+ options[key] = this.parseExpression();
1088
+ }
1089
+ } else {
1090
+ options[key] = true;
1091
+ }
1092
+
1093
+ if (this.is(TokenType.COMMA)) {
1094
+ this.advance();
1095
+ }
1096
+ }
1097
+
1098
+ this.expect(TokenType.RPAREN);
1099
+ }
1100
+
1101
+ return new ASTNode(NodeType.FocusTrapDirective, { options });
1102
+ }
1103
+
1104
+ /**
1105
+ * Parse @srOnly directive - visually hidden but accessible text
1106
+ */
1107
+ parseSrOnlyDirective() {
1108
+ return new ASTNode(NodeType.A11yDirective, {
1109
+ attrs: { srOnly: true }
1110
+ });
867
1111
  }
868
1112
 
869
1113
  /**
@@ -12,6 +12,7 @@
12
12
  export function generateExport(transformer) {
13
13
  const pageName = transformer.ast.page?.name || 'Component';
14
14
  const routePath = transformer.ast.route?.path || null;
15
+ const hasInit = transformer.actionNames.has('init');
15
16
 
16
17
  const lines = ['// Export'];
17
18
  lines.push(`export const ${pageName} = {`);
@@ -21,9 +22,47 @@ export function generateExport(transformer) {
21
22
  lines.push(` route: ${JSON.stringify(routePath)},`);
22
23
  }
23
24
 
25
+ // Mount with reactive re-rendering (preserves focus)
24
26
  lines.push(' mount: (target) => {');
25
- lines.push(' const el = render();');
26
- lines.push(' return mount(target, el);');
27
+ lines.push(' const container = typeof target === "string" ? document.querySelector(target) : target;');
28
+ lines.push(' let currentEl = null;');
29
+ lines.push(' effect(() => {');
30
+ lines.push(' // Save focus state before re-render');
31
+ lines.push(' const activeEl = document.activeElement;');
32
+ lines.push(' const isInput = activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA");');
33
+ lines.push(' const focusInfo = isInput ? {');
34
+ lines.push(' tag: activeEl.tagName.toLowerCase(),');
35
+ lines.push(' type: activeEl.type || "",');
36
+ lines.push(' placeholder: activeEl.placeholder || "",');
37
+ lines.push(' ariaLabel: activeEl.getAttribute("aria-label") || "",');
38
+ lines.push(' start: activeEl.selectionStart,');
39
+ lines.push(' end: activeEl.selectionEnd');
40
+ lines.push(' } : null;');
41
+ lines.push(' const newEl = render();');
42
+ lines.push(' if (currentEl) {');
43
+ lines.push(' container.replaceChild(newEl, currentEl);');
44
+ lines.push(' } else {');
45
+ lines.push(' container.appendChild(newEl);');
46
+ lines.push(' }');
47
+ lines.push(' currentEl = newEl;');
48
+ lines.push(' // Restore focus after re-render');
49
+ lines.push(' if (focusInfo) {');
50
+ lines.push(' let selector = focusInfo.tag;');
51
+ lines.push(' if (focusInfo.ariaLabel) selector += `[aria-label="${focusInfo.ariaLabel}"]`;');
52
+ lines.push(' else if (focusInfo.placeholder) selector += `[placeholder="${focusInfo.placeholder}"]`;');
53
+ lines.push(' const newActive = newEl.querySelector(selector);');
54
+ lines.push(' if (newActive) {');
55
+ lines.push(' newActive.focus();');
56
+ lines.push(' if (typeof focusInfo.start === "number") {');
57
+ lines.push(' try { newActive.setSelectionRange(focusInfo.start, focusInfo.end); } catch(e) {}');
58
+ lines.push(' }');
59
+ lines.push(' }');
60
+ lines.push(' }');
61
+ lines.push(' });');
62
+ if (hasInit) {
63
+ lines.push(' init();');
64
+ }
65
+ lines.push(' return { unmount: () => currentEl?.remove() };');
27
66
  lines.push(' }');
28
67
  lines.push('};');
29
68
  lines.push('');
@@ -177,8 +177,22 @@ export function transformFunctionBody(transformer, tokens) {
177
177
  let lastToken = null;
178
178
  let lastNonSpaceToken = null;
179
179
 
180
+ // Tokens that must follow } directly without semicolon
181
+ const NO_SEMI_BEFORE = new Set(['catch', 'finally', 'else']);
182
+
180
183
  const needsManualSemicolon = (token, nextToken, lastNonSpace) => {
181
184
  if (!token || lastNonSpace?.value === 'new') return false;
185
+ // Don't add semicolon after 'await' - it always needs its expression
186
+ if (lastNonSpace?.value === 'await') return false;
187
+ // For 'return': bare return followed by statement keyword needs semicolon
188
+ if (lastNonSpace?.value === 'return') {
189
+ // If followed by a statement keyword, it's a bare return - needs semicolon
190
+ if (token.type === 'IDENT' && STATEMENT_KEYWORDS.has(token.value)) return true;
191
+ if (STATEMENT_TOKEN_TYPES.has(token.type)) return true;
192
+ return false; // return expression - no semicolon
193
+ }
194
+ // Don't add semicolon before catch/finally/else after }
195
+ if (lastNonSpace?.type === 'RBRACE' && NO_SEMI_BEFORE.has(token.value)) return false;
182
196
  if (STATEMENT_TOKEN_TYPES.has(token.type)) return true;
183
197
  if (token.type !== 'IDENT') return false;
184
198
  if (STATEMENT_KEYWORDS.has(token.value)) return true;
@@ -245,29 +259,158 @@ export function transformFunctionBody(transformer, tokens) {
245
259
  lastNonSpaceToken = token;
246
260
  }
247
261
 
262
+ // Protect string literals from state var replacement
263
+ const stringPlaceholders = [];
264
+ const protectStrings = (str) => {
265
+ // Match strings and template literals, handling escapes
266
+ return str.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/g, (match) => {
267
+ const index = stringPlaceholders.length;
268
+ stringPlaceholders.push(match);
269
+ return `__STRING_${index}__`;
270
+ });
271
+ };
272
+ const restoreStrings = (str) => {
273
+ return str.replace(/__STRING_(\d+)__/g, (_, index) => stringPlaceholders[parseInt(index)]);
274
+ };
275
+
276
+ // Protect strings before transformations
277
+ code = protectStrings(code);
278
+
248
279
  // Build patterns for state variable transformation
249
280
  const stateVarPattern = [...stateVars].join('|');
250
281
  const funcPattern = [...actionNames, ...BUILTIN_FUNCTIONS].join('|');
251
282
  const keywordsPattern = [...STATEMENT_KEYWORDS].join('|');
252
283
 
253
284
  // Transform state var assignments: stateVar = value -> stateVar.set(value)
285
+ // Match assignment and find end by tracking balanced brackets
254
286
  for (const stateVar of stateVars) {
255
- const boundaryPattern = `\\s+(?:${stateVarPattern})(?:\\s*=(?!=)|\\s*\\.set\\()|\\s+(?:${funcPattern})\\s*\\(|\\s+(?:${keywordsPattern})\\b|;|$`;
256
- const assignPattern = new RegExp(`\\b${stateVar}\\s*=(?!=)\\s*(.+?)(?=${boundaryPattern})`, 'g');
257
- code = code.replace(assignPattern, (_, value) => `${stateVar}.set(${value.trim()});`);
287
+ const pattern = new RegExp(`\\b${stateVar}\\s*=(?!=)`, 'g');
288
+ let match;
289
+ const replacements = [];
290
+
291
+ while ((match = pattern.exec(code)) !== null) {
292
+ const startIdx = match.index + match[0].length;
293
+
294
+ // Skip whitespace
295
+ let exprStart = startIdx;
296
+ while (exprStart < code.length && /\s/.test(code[exprStart])) exprStart++;
297
+
298
+ // Find end of expression with bracket balancing
299
+ let depth = 0;
300
+ let endIdx = exprStart;
301
+ let inString = false;
302
+ let stringChar = '';
303
+
304
+ for (let i = exprStart; i < code.length; i++) {
305
+ const ch = code[i];
306
+ const prevCh = i > 0 ? code[i-1] : '';
307
+
308
+ // Handle string literals
309
+ if (!inString && (ch === '"' || ch === "'" || ch === '`')) {
310
+ inString = true;
311
+ stringChar = ch;
312
+ endIdx = i + 1;
313
+ continue;
314
+ }
315
+ if (inString) {
316
+ if (ch === stringChar && prevCh !== '\\') {
317
+ inString = false;
318
+ }
319
+ endIdx = i + 1;
320
+ continue;
321
+ }
322
+
323
+ // Track bracket depth
324
+ if (ch === '(' || ch === '[' || ch === '{') {
325
+ depth++;
326
+ endIdx = i + 1;
327
+ continue;
328
+ }
329
+ if (ch === ')' || ch === ']' || ch === '}') {
330
+ if (depth > 0) {
331
+ depth--;
332
+ endIdx = i + 1;
333
+ continue;
334
+ }
335
+ // depth would go negative - this is a boundary (e.g., closing brace of if block)
336
+ break;
337
+ }
338
+
339
+ // At depth 0, check for statement boundaries
340
+ if (depth === 0) {
341
+ // Semicolon ends the expression
342
+ if (ch === ';') {
343
+ break;
344
+ }
345
+ // Check for whitespace followed by keyword/identifier that starts a new statement
346
+ if (/\s/.test(ch)) {
347
+ const rest = code.slice(i);
348
+ const keywordBoundary = new RegExp(`^\\s+(?:(?:${stateVarPattern})\\s*=(?!=)|(?:${keywordsPattern}|await|return)\\b|(?:${funcPattern})\\s*\\()`);
349
+ if (keywordBoundary.test(rest)) {
350
+ break;
351
+ }
352
+ }
353
+ }
354
+
355
+ endIdx = i + 1;
356
+ }
357
+
358
+ const value = code.slice(exprStart, endIdx).trim();
359
+ if (value) {
360
+ replacements.push({
361
+ start: match.index,
362
+ end: endIdx,
363
+ replacement: `${stateVar}.set(${value});`
364
+ });
365
+ }
366
+ }
367
+
368
+ // Apply replacements in reverse order
369
+ for (let i = replacements.length - 1; i >= 0; i--) {
370
+ const r = replacements[i];
371
+ code = code.slice(0, r.start) + r.replacement + code.slice(r.end);
372
+ }
258
373
  }
259
374
 
260
375
  // Clean up any double semicolons
261
376
  code = code.replace(/;+/g, ';');
262
377
  code = code.replace(/; ;/g, ';');
263
378
 
264
- // Replace state var reads
379
+ // Handle post-increment/decrement on state vars: stateVar++ -> ((v) => (stateVar.set(v + 1), v))(stateVar.get())
380
+ for (const stateVar of stateVars) {
381
+ // Post-increment: stateVar++ (returns old value)
382
+ code = code.replace(
383
+ new RegExp(`\\b${stateVar}\\s*\\+\\+`, 'g'),
384
+ `((v) => (${stateVar}.set(v + 1), v))(${stateVar}.get())`
385
+ );
386
+ // Post-decrement: stateVar-- (returns old value)
387
+ code = code.replace(
388
+ new RegExp(`\\b${stateVar}\\s*--`, 'g'),
389
+ `((v) => (${stateVar}.set(v - 1), v))(${stateVar}.get())`
390
+ );
391
+ // Pre-increment: ++stateVar (returns new value)
392
+ code = code.replace(
393
+ new RegExp(`\\+\\+\\s*${stateVar}\\b`, 'g'),
394
+ `(${stateVar}.set(${stateVar}.get() + 1), ${stateVar}.get())`
395
+ );
396
+ // Pre-decrement: --stateVar (returns new value)
397
+ code = code.replace(
398
+ new RegExp(`--\\s*${stateVar}\\b`, 'g'),
399
+ `(${stateVar}.set(${stateVar}.get() - 1), ${stateVar}.get())`
400
+ );
401
+ }
402
+
403
+ // Replace state var reads (not in assignments, not already with .get/.set)
404
+ // Allow spread operators (...stateVar) but block member access (obj.stateVar)
265
405
  for (const stateVar of stateVars) {
266
406
  code = code.replace(
267
- new RegExp(`(?<!\\.\\s*)\\b${stateVar}\\b(?!\\s*=(?!=)|\\s*\\(|\\s*\\.(?:get|set))`, 'g'),
407
+ new RegExp(`(?:(?<=\\.\\.\\.)|(?<!\\.))\\b${stateVar}\\b(?!\\s*=(?!=)|\\s*\\(|\\s*\\.(?:get|set))`, 'g'),
268
408
  `${stateVar}.get()`
269
409
  );
270
410
  }
271
411
 
412
+ // Restore protected strings
413
+ code = restoreStrings(code);
414
+
272
415
  return code.trim();
273
416
  }
@@ -39,6 +39,7 @@ export function generateImports(transformer) {
39
39
  'el',
40
40
  'text',
41
41
  'on',
42
+ 'bind',
42
43
  'list',
43
44
  'when',
44
45
  'mount',
@@ -47,6 +48,21 @@ export function generateImports(transformer) {
47
48
 
48
49
  lines.push(`import { ${runtimeImports.join(', ')} } from '${options.runtime}';`);
49
50
 
51
+ // A11y imports (if a11y features are used)
52
+ const a11yImports = [];
53
+ if (transformer.usesA11y.srOnly) {
54
+ a11yImports.push('srOnly');
55
+ }
56
+ if (transformer.usesA11y.trapFocus) {
57
+ a11yImports.push('trapFocus');
58
+ }
59
+ if (transformer.usesA11y.announce) {
60
+ a11yImports.push('announce');
61
+ }
62
+ if (a11yImports.length > 0) {
63
+ lines.push(`import { ${a11yImports.join(', ')} } from '${options.runtime}/a11y';`);
64
+ }
65
+
50
66
  // Router imports (if router block exists)
51
67
  if (ast.router) {
52
68
  lines.push(`import { createRouter } from '${options.runtime}/router';`);
@@ -54,6 +54,13 @@ export class Transformer {
54
54
  this.importedComponents = new Map();
55
55
  this.scopeId = this.options.scopeStyles ? generateScopeId() : null;
56
56
 
57
+ // Track a11y feature usage for conditional imports
58
+ this.usesA11y = {
59
+ srOnly: false,
60
+ trapFocus: false,
61
+ announce: false
62
+ };
63
+
57
64
  // Source map tracking
58
65
  this.sourceMap = null;
59
66
  this._currentLine = 0;
@@ -126,6 +133,40 @@ export class Transformer {
126
133
  return this._trackCode(code);
127
134
  }
128
135
 
136
+ /**
137
+ * Pre-scan AST for a11y directive usage
138
+ */
139
+ _scanA11yUsage(node) {
140
+ if (!node) return;
141
+
142
+ // Check directives for a11y usage
143
+ if (node.directives) {
144
+ for (const directive of node.directives) {
145
+ if (directive.type === 'A11yDirective') {
146
+ if (directive.attrs && directive.attrs.srOnly) {
147
+ this.usesA11y.srOnly = true;
148
+ }
149
+ } else if (directive.type === 'FocusTrapDirective') {
150
+ this.usesA11y.trapFocus = true;
151
+ }
152
+ }
153
+ }
154
+
155
+ // Recursively scan children
156
+ if (node.children) {
157
+ for (const child of node.children) {
158
+ this._scanA11yUsage(child);
159
+ }
160
+ }
161
+
162
+ // Scan view block children
163
+ if (node.type === 'ViewBlock' && node.children) {
164
+ for (const child of node.children) {
165
+ this._scanA11yUsage(child);
166
+ }
167
+ }
168
+ }
169
+
129
170
  /**
130
171
  * Transform AST to JavaScript code
131
172
  */
@@ -137,6 +178,11 @@ export class Transformer {
137
178
  extractImportedComponents(this, this.ast.imports);
138
179
  }
139
180
 
181
+ // Pre-scan for a11y usage to determine imports
182
+ if (this.ast.view) {
183
+ this._scanA11yUsage(this.ast.view);
184
+ }
185
+
140
186
  // Imports (runtime + user imports)
141
187
  parts.push(generateImports(this));
142
188