pulse-js-framework 1.7.10 → 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.
package/README.md CHANGED
@@ -249,6 +249,7 @@ const count: Pulse<number> = pulse(0);
249
249
  | [HMR Demo](examples/hmr) | Hot module replacement |
250
250
  | [Router Demo](examples/router) | SPA routing |
251
251
  | [Store Demo](examples/store) | State with undo/redo |
252
+ | [Electron App](examples/electron) | Desktop notes app |
252
253
 
253
254
  ## Documentation
254
255
 
package/compiler/lexer.js CHANGED
@@ -33,6 +33,7 @@ export const TokenType = {
33
33
 
34
34
  // Directives
35
35
  AT: 'AT', // @
36
+ DIRECTIVE_MOD: 'DIRECTIVE_MOD', // .modifier after @directive (e.g., @click.prevent)
36
37
  PAGE: 'PAGE',
37
38
  ROUTE: 'ROUTE',
38
39
  IF: 'IF',
@@ -499,10 +500,31 @@ export class Lexer {
499
500
  continue;
500
501
  }
501
502
 
502
- // At-sign for directives
503
+ // At-sign for directives with optional modifiers
503
504
  if (char === '@') {
504
505
  this.advance();
505
506
  this.tokens.push(new Token(TokenType.AT, '@', startLine, startColumn));
507
+
508
+ // After @, read directive name (if identifier follows)
509
+ if (/[a-zA-Z]/.test(this.current())) {
510
+ // Read the directive name
511
+ const nameToken = this.readIdentifier();
512
+ this.tokens.push(nameToken);
513
+
514
+ // Read modifiers: .prevent, .stop, .enter, etc.
515
+ while (!this.isEOF() && this.current() === '.' && /[a-zA-Z]/.test(this.peek())) {
516
+ this.advance(); // skip '.'
517
+ const modStartLine = this.line;
518
+ const modStartColumn = this.column;
519
+ let modName = '';
520
+ while (!this.isEOF() && /[a-zA-Z0-9]/.test(this.current())) {
521
+ modName += this.advance();
522
+ }
523
+ if (modName) {
524
+ this.tokens.push(new Token(TokenType.DIRECTIVE_MOD, modName, modStartLine, modStartColumn));
525
+ }
526
+ }
527
+ }
506
528
  continue;
507
529
  }
508
530
 
@@ -27,6 +27,7 @@ export const NodeType = {
27
27
  IfDirective: 'IfDirective',
28
28
  EachDirective: 'EachDirective',
29
29
  EventDirective: 'EventDirective',
30
+ ModelDirective: 'ModelDirective',
30
31
 
31
32
  // Accessibility directives
32
33
  A11yDirective: 'A11yDirective',
@@ -769,6 +770,12 @@ export class Parser {
769
770
 
770
771
  const name = this.expect(TokenType.IDENT).value;
771
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
+
772
779
  if (name === 'if') {
773
780
  return this.parseIfDirective();
774
781
  }
@@ -790,8 +797,13 @@ export class Parser {
790
797
  return this.parseSrOnlyDirective();
791
798
  }
792
799
 
800
+ // @model directive for two-way binding
801
+ if (name === 'model') {
802
+ return this.parseModelDirective(modifiers);
803
+ }
804
+
793
805
  // Event directive like @click
794
- return this.parseEventDirective(name);
806
+ return this.parseEventDirective(name, modifiers);
795
807
  }
796
808
 
797
809
  /**
@@ -801,6 +813,12 @@ export class Parser {
801
813
  this.expect(TokenType.AT);
802
814
  const name = this.expect(TokenType.IDENT).value;
803
815
 
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
+
804
822
  // Check for a11y directives
805
823
  if (name === 'a11y') {
806
824
  return this.parseA11yDirective();
@@ -815,16 +833,22 @@ export class Parser {
815
833
  return this.parseSrOnlyDirective();
816
834
  }
817
835
 
836
+ // @model directive for two-way binding
837
+ if (name === 'model') {
838
+ return this.parseModelDirective(modifiers);
839
+ }
840
+
818
841
  // Event directive (click, submit, etc.)
819
842
  this.expect(TokenType.LPAREN);
820
843
  const expression = this.parseExpression();
821
844
  this.expect(TokenType.RPAREN);
822
845
 
823
- return new ASTNode(NodeType.EventDirective, { event: name, handler: expression });
846
+ return new ASTNode(NodeType.EventDirective, { event: name, handler: expression, modifiers });
824
847
  }
825
848
 
826
849
  /**
827
- * Parse @if directive
850
+ * Parse @if directive with @else-if/@else chains
851
+ * Syntax: @if (cond) { } @else-if (cond) { } @else { }
828
852
  */
829
853
  parseIfDirective() {
830
854
  this.expect(TokenType.LPAREN);
@@ -838,23 +862,76 @@ export class Parser {
838
862
  }
839
863
  this.expect(TokenType.RBRACE);
840
864
 
865
+ const elseIfBranches = [];
841
866
  let alternate = null;
842
- if (this.is(TokenType.AT) && this.peek()?.value === 'else') {
843
- this.advance(); // @
844
- this.advance(); // else
845
- this.expect(TokenType.LBRACE);
846
- alternate = [];
847
- while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
848
- 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
849
926
  }
850
- this.expect(TokenType.RBRACE);
851
927
  }
852
928
 
853
- return new ASTNode(NodeType.IfDirective, { condition, consequent, alternate });
929
+ return new ASTNode(NodeType.IfDirective, { condition, consequent, elseIfBranches, alternate });
854
930
  }
855
931
 
856
932
  /**
857
- * Parse @each/@for directive
933
+ * Parse @each/@for directive with optional key function
934
+ * Syntax: @for (item of items) key(item.id) { ... }
858
935
  */
859
936
  parseEachDirective() {
860
937
  this.expect(TokenType.LPAREN);
@@ -870,6 +947,15 @@ export class Parser {
870
947
  const iterable = this.parseExpression();
871
948
  this.expect(TokenType.RPAREN);
872
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
+
873
959
  this.expect(TokenType.LBRACE);
874
960
  const template = [];
875
961
  while (!this.is(TokenType.RBRACE) && !this.is(TokenType.EOF)) {
@@ -877,13 +963,15 @@ export class Parser {
877
963
  }
878
964
  this.expect(TokenType.RBRACE);
879
965
 
880
- return new ASTNode(NodeType.EachDirective, { itemName, iterable, template });
966
+ return new ASTNode(NodeType.EachDirective, { itemName, iterable, template, keyExpr });
881
967
  }
882
968
 
883
969
  /**
884
- * 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.)
885
973
  */
886
- parseEventDirective(event) {
974
+ parseEventDirective(event, modifiers = []) {
887
975
  this.expect(TokenType.LPAREN);
888
976
  const handler = this.parseExpression();
889
977
  this.expect(TokenType.RPAREN);
@@ -897,7 +985,20 @@ export class Parser {
897
985
  this.expect(TokenType.RBRACE);
898
986
  }
899
987
 
900
- 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 });
901
1002
  }
902
1003
 
903
1004
  /**
@@ -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',
@@ -15,6 +15,7 @@ export const VIEW_NODE_HANDLERS = {
15
15
  [NodeType.IfDirective]: 'transformIfDirective',
16
16
  [NodeType.EachDirective]: 'transformEachDirective',
17
17
  [NodeType.EventDirective]: 'transformEventDirective',
18
+ [NodeType.ModelDirective]: 'transformModelDirective',
18
19
  [NodeType.SlotElement]: 'transformSlot',
19
20
  [NodeType.LinkDirective]: 'transformLinkDirective',
20
21
  [NodeType.OutletDirective]: 'transformOutletDirective',
@@ -89,6 +90,8 @@ export function transformViewNode(transformer, node, indent = 0) {
89
90
  return transformEachDirective(transformer, node, indent);
90
91
  case NodeType.EventDirective:
91
92
  return transformEventDirective(transformer, node, indent);
93
+ case NodeType.ModelDirective:
94
+ return transformModelDirective(transformer, node, indent);
92
95
  case NodeType.SlotElement:
93
96
  return transformSlot(transformer, node, indent);
94
97
  case NodeType.LinkDirective:
@@ -295,6 +298,36 @@ export function transformFocusTrapDirective(transformer, node, indent) {
295
298
  return `{ ${optionsCode} }`;
296
299
  }
297
300
 
301
+ /**
302
+ * Extract dynamic attributes from a selector
303
+ * Returns { cleanSelector, dynamicAttrs } where dynamicAttrs is an array of { name, expr }
304
+ * @param {string} selector - CSS selector with potential dynamic attributes
305
+ * @returns {Object} { cleanSelector, dynamicAttrs }
306
+ */
307
+ function extractDynamicAttributes(selector) {
308
+ const dynamicAttrs = [];
309
+ // Match attributes with {expression} values: [name={expr}] or [name="{expr}"]
310
+ const attrPattern = /\[([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*\{([^}]+)\}\]/g;
311
+ const attrPatternQuoted = /\[([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"\{([^}]+)\}"\]/g;
312
+
313
+ let cleanSelector = selector;
314
+
315
+ // Extract unquoted dynamic attributes: [value={expr}]
316
+ let match;
317
+ while ((match = attrPattern.exec(selector)) !== null) {
318
+ dynamicAttrs.push({ name: match[1], expr: match[2] });
319
+ }
320
+ cleanSelector = cleanSelector.replace(attrPattern, '');
321
+
322
+ // Extract quoted dynamic attributes: [value="{expr}"]
323
+ while ((match = attrPatternQuoted.exec(selector)) !== null) {
324
+ dynamicAttrs.push({ name: match[1], expr: match[2] });
325
+ }
326
+ cleanSelector = cleanSelector.replace(attrPatternQuoted, '');
327
+
328
+ return { cleanSelector, dynamicAttrs };
329
+ }
330
+
298
331
  /**
299
332
  * Transform element
300
333
  * @param {Object} transformer - Transformer instance
@@ -317,14 +350,18 @@ export function transformElement(transformer, node, indent) {
317
350
  return transformComponentCall(transformer, node, indent);
318
351
  }
319
352
 
353
+ // Extract dynamic attributes from selector (e.g., [value={searchQuery}])
354
+ let { cleanSelector, dynamicAttrs } = extractDynamicAttributes(node.selector);
355
+
320
356
  // Add scoped class to selector if CSS scoping is enabled
321
- let selector = node.selector;
357
+ let selector = cleanSelector;
322
358
  if (transformer.scopeId && selector) {
323
359
  selector = addScopeToSelector(transformer, selector);
324
360
  }
325
361
 
326
362
  // Extract directives by type
327
363
  const eventHandlers = node.directives.filter(d => d.type === NodeType.EventDirective);
364
+ const modelDirectives = node.directives.filter(d => d.type === NodeType.ModelDirective);
328
365
  const a11yDirectives = node.directives.filter(d => d.type === NodeType.A11yDirective);
329
366
  const liveDirectives = node.directives.filter(d => d.type === NodeType.LiveDirective);
330
367
  const focusTrapDirectives = node.directives.filter(d => d.type === NodeType.FocusTrapDirective);
@@ -392,8 +429,9 @@ export function transformElement(transformer, node, indent) {
392
429
  enhancedSelector = selector + staticAttrs.join('');
393
430
  }
394
431
 
395
- // Start with el() call
396
- parts.push(`${pad}el('${enhancedSelector}'`);
432
+ // Start with el() call - escape single quotes in selector
433
+ const escapedSelector = enhancedSelector.replace(/'/g, "\\'");
434
+ parts.push(`${pad}el('${escapedSelector}'`);
397
435
 
398
436
  // Add text content
399
437
  if (node.textContent.length > 0) {
@@ -413,11 +451,37 @@ export function transformElement(transformer, node, indent) {
413
451
 
414
452
  parts.push(')');
415
453
 
416
- // Chain event handlers
454
+ // Chain event handlers with modifiers support
417
455
  let result = parts.join('');
418
456
  for (const handler of eventHandlers) {
419
457
  const handlerCode = transformExpression(transformer, handler.handler);
420
- result = `on(${result}, '${handler.event}', () => { ${handlerCode}; })`;
458
+ const modifiers = handler.modifiers || [];
459
+
460
+ if (modifiers.length === 0) {
461
+ // Always pass event parameter since handlers commonly use event.target, etc.
462
+ result = `on(${result}, '${handler.event}', (event) => { ${handlerCode}; })`;
463
+ } else {
464
+ const modifiedHandler = generateModifiedHandler(handler.event, handlerCode, modifiers);
465
+ result = `on(${result}, '${handler.event}', ${modifiedHandler})`;
466
+ }
467
+ }
468
+
469
+ // Chain model directives for two-way binding
470
+ for (const directive of modelDirectives) {
471
+ const binding = transformExpression(transformer, directive.binding);
472
+ const modifiers = directive.modifiers || [];
473
+
474
+ // Build options from modifiers
475
+ const options = [];
476
+ if (modifiers.includes('lazy')) options.push('lazy: true');
477
+ if (modifiers.includes('trim')) options.push('trim: true');
478
+ if (modifiers.includes('number')) options.push('number: true');
479
+
480
+ if (options.length > 0) {
481
+ result = `model(${result}, ${binding}, { ${options.join(', ')} })`;
482
+ } else {
483
+ result = `model(${result}, ${binding})`;
484
+ }
421
485
  }
422
486
 
423
487
  // Chain focus trap if present
@@ -426,6 +490,12 @@ export function transformElement(transformer, node, indent) {
426
490
  result = `trapFocus(${result}, ${optionsCode})`;
427
491
  }
428
492
 
493
+ // Chain dynamic attribute bindings (e.g., [value={searchQuery}])
494
+ for (const attr of dynamicAttrs) {
495
+ const exprCode = transformExpressionString(transformer, attr.expr);
496
+ result = `bind(${result}, '${attr.name}', () => ${exprCode})`;
497
+ }
498
+
429
499
  return result;
430
500
  }
431
501
 
@@ -538,25 +608,59 @@ export function transformTextNode(transformer, node, indent) {
538
608
  */
539
609
  export function transformIfDirective(transformer, node, indent) {
540
610
  const pad = ' '.repeat(indent);
541
- const condition = transformExpression(transformer, node.condition);
542
-
543
- const consequent = node.consequent.map(c =>
544
- transformViewNode(transformer, c, indent + 2)
545
- ).join(',\n');
546
611
 
547
- let code = `${pad}when(\n`;
548
- code += `${pad} () => ${condition},\n`;
549
- code += `${pad} () => (\n${consequent}\n${pad} )`;
612
+ // Helper to build nested when() calls for else-if chains
613
+ function buildConditionChain(condition, consequent, elseIfBranches, alternate, depth = 0) {
614
+ const innerPad = ' '.repeat(indent + depth * 2);
615
+ const conditionCode = transformExpression(transformer, condition);
616
+
617
+ // Wrap multiple children in array, single child returns directly
618
+ const consequentItems = consequent.map(c =>
619
+ transformViewNode(transformer, c, indent + depth * 2 + 4)
620
+ );
621
+ const consequentCode = consequentItems.length === 1
622
+ ? consequentItems[0]
623
+ : `[\n${consequentItems.join(',\n')}\n${innerPad} ]`;
624
+
625
+ let code = `${innerPad}when(\n`;
626
+ code += `${innerPad} () => ${conditionCode},\n`;
627
+ code += `${innerPad} () => ${consequentCode}`;
628
+
629
+ // Handle else-if branches
630
+ if (elseIfBranches && elseIfBranches.length > 0) {
631
+ const nextBranch = elseIfBranches[0];
632
+ const remainingBranches = elseIfBranches.slice(1);
633
+
634
+ code += `,\n${innerPad} () => (\n`;
635
+ code += buildConditionChain(
636
+ nextBranch.condition,
637
+ nextBranch.consequent,
638
+ remainingBranches,
639
+ alternate,
640
+ depth + 2
641
+ );
642
+ code += `\n${innerPad} )`;
643
+ } else if (alternate) {
644
+ // Final else branch - wrap multiple children in array
645
+ const alternateItems = alternate.map(c =>
646
+ transformViewNode(transformer, c, indent + depth * 2 + 4)
647
+ );
648
+ const alternateCode = alternateItems.length === 1
649
+ ? alternateItems[0]
650
+ : `[\n${alternateItems.join(',\n')}\n${innerPad} ]`;
651
+ code += `,\n${innerPad} () => ${alternateCode}`;
652
+ }
550
653
 
551
- if (node.alternate) {
552
- const alternate = node.alternate.map(c =>
553
- transformViewNode(transformer, c, indent + 2)
554
- ).join(',\n');
555
- code += `,\n${pad} () => (\n${alternate}\n${pad} )`;
654
+ code += `\n${innerPad})`;
655
+ return code;
556
656
  }
557
657
 
558
- code += `\n${pad})`;
559
- return code;
658
+ return buildConditionChain(
659
+ node.condition,
660
+ node.consequent,
661
+ node.elseIfBranches || [],
662
+ node.alternate
663
+ );
560
664
  }
561
665
 
562
666
  /**
@@ -574,10 +678,19 @@ export function transformEachDirective(transformer, node, indent) {
574
678
  transformViewNode(transformer, t, indent + 2)
575
679
  ).join(',\n');
576
680
 
577
- return `${pad}list(\n` +
578
- `${pad} () => ${iterable},\n` +
579
- `${pad} (${node.itemName}, _index) => (\n${template}\n${pad} )\n` +
580
- `${pad})`;
681
+ // Build list() call with optional key function
682
+ let code = `${pad}list(\n` +
683
+ `${pad} () => ${iterable},\n` +
684
+ `${pad} (${node.itemName}, _index) => (\n${template}\n${pad} )`;
685
+
686
+ // Add key function if provided
687
+ if (node.keyExpr) {
688
+ const keyExprCode = transformExpression(transformer, node.keyExpr);
689
+ code += `,\n${pad} (${node.itemName}) => ${keyExprCode}`;
690
+ }
691
+
692
+ code += `\n${pad})`;
693
+ return code;
581
694
  }
582
695
 
583
696
  /**
@@ -601,3 +714,85 @@ export function transformEventDirective(transformer, node, indent) {
601
714
 
602
715
  return `/* event: ${node.event} -> ${handler} */`;
603
716
  }
717
+
718
+ /**
719
+ * Transform @model directive for two-way binding
720
+ * @param {Object} transformer - Transformer instance
721
+ * @param {Object} node - Model directive node
722
+ * @param {number} indent - Indentation level
723
+ * @returns {string} JavaScript code
724
+ */
725
+ export function transformModelDirective(transformer, node, indent) {
726
+ const pad = ' '.repeat(indent);
727
+ const binding = transformExpression(transformer, node.binding);
728
+ const modifiers = node.modifiers || [];
729
+
730
+ // Build options from modifiers
731
+ const options = [];
732
+ if (modifiers.includes('lazy')) options.push('lazy: true');
733
+ if (modifiers.includes('trim')) options.push('trim: true');
734
+ if (modifiers.includes('number')) options.push('number: true');
735
+
736
+ if (options.length > 0) {
737
+ return `${pad}/* model: ${binding} { ${options.join(', ')} } */`;
738
+ }
739
+
740
+ return `${pad}/* model: ${binding} */`;
741
+ }
742
+
743
+ /**
744
+ * Generate event handler code with modifiers applied
745
+ * @param {string} event - Event name
746
+ * @param {string} handlerCode - Handler expression code
747
+ * @param {string[]} modifiers - Array of modifier names
748
+ * @returns {string} JavaScript handler code
749
+ */
750
+ function generateModifiedHandler(event, handlerCode, modifiers) {
751
+ // Key modifiers map
752
+ const keyMap = {
753
+ enter: 'Enter', tab: 'Tab', delete: 'Delete', esc: 'Escape', escape: 'Escape',
754
+ space: ' ', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight'
755
+ };
756
+
757
+ // System key modifiers
758
+ const systemModifiers = ['ctrl', 'alt', 'shift', 'meta'];
759
+
760
+ // Build handler code with checks
761
+ const checks = [];
762
+ let hasEventParam = false;
763
+
764
+ for (const mod of modifiers) {
765
+ if (mod === 'prevent') {
766
+ checks.push('event.preventDefault();');
767
+ hasEventParam = true;
768
+ } else if (mod === 'stop') {
769
+ checks.push('event.stopPropagation();');
770
+ hasEventParam = true;
771
+ } else if (mod === 'self') {
772
+ checks.push('if (event.target !== event.currentTarget) return;');
773
+ hasEventParam = true;
774
+ } else if (keyMap[mod]) {
775
+ checks.push(`if (event.key !== '${keyMap[mod]}') return;`);
776
+ hasEventParam = true;
777
+ } else if (systemModifiers.includes(mod)) {
778
+ checks.push(`if (!event.${mod}Key) return;`);
779
+ hasEventParam = true;
780
+ }
781
+ }
782
+
783
+ // Build options for addEventListener
784
+ const options = [];
785
+ if (modifiers.includes('capture')) options.push('capture: true');
786
+ if (modifiers.includes('once')) options.push('once: true');
787
+ if (modifiers.includes('passive')) options.push('passive: true');
788
+
789
+ const checksCode = checks.join(' ');
790
+ // Always pass event parameter since handler code commonly uses event.target, etc.
791
+ const handler = `(event) => { ${checksCode} ${handlerCode}; }`;
792
+
793
+ if (options.length > 0) {
794
+ return `${handler}, { ${options.join(', ')} }`;
795
+ }
796
+
797
+ return handler;
798
+ }
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  import { compile } from '../compiler/index.js';
8
+ import { existsSync } from 'fs';
9
+ import { resolve, dirname } from 'path';
8
10
 
9
11
  /**
10
12
  * Create Pulse Vite plugin
@@ -18,14 +20,35 @@ export default function pulsePlugin(options = {}) {
18
20
 
19
21
  return {
20
22
  name: 'vite-plugin-pulse',
23
+ enforce: 'pre',
21
24
 
22
25
  /**
23
- * Resolve .pulse files
26
+ * Resolve .pulse files and .js imports that map to .pulse files
27
+ * The compiler transforms .pulse imports to .js, so we need to
28
+ * resolve them back to .pulse for Vite to process them
24
29
  */
25
- resolveId(id) {
26
- if (id.endsWith('.pulse')) {
27
- return id;
30
+ resolveId(id, importer) {
31
+ // Direct .pulse imports - resolve to absolute path
32
+ if (id.endsWith('.pulse') && importer) {
33
+ const importerDir = dirname(importer);
34
+ const absolutePath = resolve(importerDir, id);
35
+ if (existsSync(absolutePath)) {
36
+ return absolutePath;
37
+ }
38
+ }
39
+
40
+ // Check if a .js import has a corresponding .pulse file
41
+ // This handles the compiler's transformation of .pulse -> .js imports
42
+ if (id.endsWith('.js') && importer) {
43
+ const pulseId = id.replace(/\.js$/, '.pulse');
44
+ const importerDir = dirname(importer);
45
+ const absolutePulsePath = resolve(importerDir, pulseId);
46
+
47
+ if (existsSync(absolutePulsePath)) {
48
+ return absolutePulsePath;
49
+ }
28
50
  }
51
+
29
52
  return null;
30
53
  },
31
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.7.10",
3
+ "version": "1.7.11",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -29,6 +29,19 @@ const BIND_URL_ATTRIBUTES = new Set([
29
29
  // REACTIVE BINDINGS
30
30
  // =============================================================================
31
31
 
32
+ /**
33
+ * Attributes that should be set as properties (not attributes) on form elements
34
+ * because the attribute doesn't reflect the current value after user input
35
+ * @private
36
+ */
37
+ const BIND_PROPERTY_ATTRIBUTES = new Set(['value', 'checked', 'selected']);
38
+
39
+ /**
40
+ * Tags where certain attributes should be set as properties
41
+ * @private
42
+ */
43
+ const FORM_ELEMENT_TAGS = new Set(['input', 'textarea', 'select', 'option']);
44
+
32
45
  /**
33
46
  * Bind an attribute reactively with XSS protection
34
47
  *
@@ -44,9 +57,22 @@ export function bind(element, attr, getValue) {
44
57
  const lowerAttr = attr.toLowerCase();
45
58
  const isUrlAttr = BIND_URL_ATTRIBUTES.has(lowerAttr);
46
59
 
60
+ // For form elements, certain attributes need to be set as properties
61
+ const tagName = dom.getTagName(element);
62
+ const useProperty = BIND_PROPERTY_ATTRIBUTES.has(lowerAttr) && FORM_ELEMENT_TAGS.has(tagName);
63
+
47
64
  if (typeof getValue === 'function') {
48
65
  effect(() => {
49
66
  const value = getValue();
67
+
68
+ // For form element properties (value, checked, selected), use setProperty
69
+ if (useProperty) {
70
+ if (dom.getProperty(element, attr) !== value) {
71
+ dom.setProperty(element, attr, value ?? '');
72
+ }
73
+ return;
74
+ }
75
+
50
76
  if (value == null || value === false) {
51
77
  dom.removeAttribute(element, attr);
52
78
  } else if (value === true) {
@@ -69,6 +95,12 @@ export function bind(element, attr, getValue) {
69
95
  }
70
96
  });
71
97
  } else {
98
+ // For form element properties, use setProperty
99
+ if (useProperty) {
100
+ dom.setProperty(element, attr, getValue ?? '');
101
+ return element;
102
+ }
103
+
72
104
  // Sanitize URL attributes for static values too
73
105
  if (isUrlAttr) {
74
106
  const sanitized = sanitizeUrl(String(getValue));