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.
- package/README.md +1 -0
- package/cli/lint.js +442 -3
- package/compiler/lexer.js +29 -1
- package/compiler/parser.js +262 -18
- package/compiler/transformer/export.js +41 -2
- package/compiler/transformer/expressions.js +148 -5
- package/compiler/transformer/imports.js +16 -0
- package/compiler/transformer/index.js +46 -0
- package/compiler/transformer/view.js +397 -27
- package/loader/vite-plugin.js +27 -4
- package/package.json +9 -2
- package/runtime/a11y.js +1005 -0
- package/runtime/devtools/a11y-audit.js +442 -0
- package/runtime/devtools/diagnostics.js +403 -0
- package/runtime/devtools/index.js +53 -0
- package/runtime/devtools/time-travel.js +189 -0
- package/runtime/devtools.js +138 -497
- package/runtime/dom-binding.js +39 -4
- package/runtime/dom-element.js +192 -1
- package/runtime/dom.js +8 -2
- package/runtime/index.js +2 -0
- package/runtime/native.js +2 -2
- package/runtime/security.js +461 -0
- package/runtime/utils.js +37 -16
- package/types/a11y.d.ts +336 -0
package/compiler/parser.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
this.
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
|