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 +1 -0
- package/compiler/lexer.js +23 -1
- package/compiler/parser.js +118 -17
- package/compiler/transformer/export.js +41 -2
- package/compiler/transformer/expressions.js +148 -5
- package/compiler/transformer/imports.js +1 -0
- package/compiler/transformer/view.js +219 -24
- package/loader/vite-plugin.js +27 -4
- package/package.json +1 -1
- package/runtime/dom-binding.js +32 -0
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
|
|
package/compiler/parser.js
CHANGED
|
@@ -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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
this.
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
|
26
|
-
lines.push('
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
//
|
|
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(`(
|
|
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
|
}
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
+
}
|
package/loader/vite-plugin.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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
package/runtime/dom-binding.js
CHANGED
|
@@ -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));
|