tova 0.8.2 → 0.9.4
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/bin/tova.js +1128 -137
- package/package.json +14 -2
- package/src/analyzer/analyzer.js +405 -9
- package/src/analyzer/browser-analyzer.js +56 -8
- package/src/analyzer/scope.js +7 -0
- package/src/analyzer/server-analyzer.js +33 -1
- package/src/codegen/base-codegen.js +137 -13
- package/src/codegen/browser-codegen.js +725 -20
- package/src/codegen/codegen.js +67 -5
- package/src/codegen/server-codegen.js +54 -6
- package/src/codegen/theme-codegen.js +69 -0
- package/src/config/module-path.js +34 -2
- package/src/config/resolve.js +9 -0
- package/src/config/toml.js +13 -1
- package/src/deploy/provision.js +6 -2
- package/src/diagnostics/security-scorecard.js +111 -0
- package/src/lexer/lexer.js +18 -3
- package/src/parser/animate-ast.js +45 -0
- package/src/parser/ast.js +15 -0
- package/src/parser/browser-ast.js +19 -1
- package/src/parser/browser-parser.js +221 -4
- package/src/parser/parser.js +21 -2
- package/src/parser/theme-ast.js +29 -0
- package/src/parser/theme-parser.js +70 -0
- package/src/registry/plugins/theme-plugin.js +20 -0
- package/src/registry/register-all.js +2 -0
- package/src/runtime/charts.js +547 -0
- package/src/runtime/embedded.js +6 -2
- package/src/runtime/reactivity.js +60 -0
- package/src/runtime/router.js +703 -295
- package/src/runtime/table.js +606 -33
- package/src/stdlib/inline.js +330 -7
- package/src/stdlib/string.js +84 -2
- package/src/stdlib/validation.js +1 -1
- package/src/version.js +1 -1
package/src/parser/ast.js
CHANGED
|
@@ -704,6 +704,7 @@ export {
|
|
|
704
704
|
ComponentDeclaration, ComponentStyleBlock, StoreDeclaration,
|
|
705
705
|
JSXElement, JSXAttribute, JSXSpreadAttribute, JSXFragment,
|
|
706
706
|
JSXText, JSXExpression, JSXFor, JSXIf, JSXMatch,
|
|
707
|
+
FontDeclaration,
|
|
707
708
|
} from './browser-ast.js';
|
|
708
709
|
|
|
709
710
|
// ============================================================
|
|
@@ -768,6 +769,20 @@ export {
|
|
|
768
769
|
|
|
769
770
|
export { SelectStatement, SelectCase } from './select-ast.js';
|
|
770
771
|
|
|
772
|
+
// ============================================================
|
|
773
|
+
// Theme-specific nodes (lazy-loaded from theme-ast.js, re-exported for backward compat)
|
|
774
|
+
// ============================================================
|
|
775
|
+
|
|
776
|
+
export { ThemeBlock, ThemeSection, ThemeToken } from './theme-ast.js';
|
|
777
|
+
|
|
778
|
+
// ============================================================
|
|
779
|
+
// Animate-specific nodes (lazy-loaded from animate-ast.js, re-exported for backward compat)
|
|
780
|
+
// ============================================================
|
|
781
|
+
|
|
782
|
+
export {
|
|
783
|
+
AnimateDeclaration, AnimatePrimitive, AnimateSequence, AnimateParallel,
|
|
784
|
+
} from './animate-ast.js';
|
|
785
|
+
|
|
771
786
|
export class TestBlock {
|
|
772
787
|
constructor(name, body, loc, options = {}) {
|
|
773
788
|
this.type = 'TestBlock';
|
|
@@ -39,13 +39,17 @@ export class ComponentDeclaration {
|
|
|
39
39
|
this.params = params;
|
|
40
40
|
this.body = body; // Array of JSX elements and statements
|
|
41
41
|
this.loc = loc;
|
|
42
|
+
// Compound component fields (set by parser for Dialog.Title etc.)
|
|
43
|
+
this.parent = null;
|
|
44
|
+
this.child = null;
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export class ComponentStyleBlock {
|
|
46
|
-
constructor(css, loc) {
|
|
49
|
+
constructor(css, loc, config) {
|
|
47
50
|
this.type = 'ComponentStyleBlock';
|
|
48
51
|
this.css = css; // raw CSS string
|
|
52
|
+
this.config = config || null; // e.g. { motion: 'full' } from style(motion: full)
|
|
49
53
|
this.loc = loc;
|
|
50
54
|
}
|
|
51
55
|
}
|
|
@@ -145,3 +149,17 @@ export class JSXMatch {
|
|
|
145
149
|
this.loc = loc;
|
|
146
150
|
}
|
|
147
151
|
}
|
|
152
|
+
|
|
153
|
+
// ============================================================
|
|
154
|
+
// Font loading
|
|
155
|
+
// ============================================================
|
|
156
|
+
|
|
157
|
+
export class FontDeclaration {
|
|
158
|
+
constructor(name, source, config, loc) {
|
|
159
|
+
this.type = 'FontDeclaration';
|
|
160
|
+
this.name = name; // string - font identifier
|
|
161
|
+
this.source = source; // string - URL or local path
|
|
162
|
+
this.config = config; // { weight, style, display } or null
|
|
163
|
+
this.loc = loc;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
import { TokenType, Keywords } from '../lexer/tokens.js';
|
|
5
5
|
import * as AST from './ast.js';
|
|
6
6
|
import { installFormParser } from './form-parser.js';
|
|
7
|
+
import {
|
|
8
|
+
AnimateDeclaration, AnimatePrimitive, AnimateSequence, AnimateParallel,
|
|
9
|
+
} from './animate-ast.js';
|
|
10
|
+
import { FontDeclaration } from './browser-ast.js';
|
|
7
11
|
|
|
8
12
|
export function installBrowserParser(ParserClass) {
|
|
9
13
|
if (ParserClass.prototype._browserParserInstalled) return;
|
|
@@ -112,7 +116,17 @@ export function installBrowserParser(ParserClass) {
|
|
|
112
116
|
ParserClass.prototype.parseComponent = function() {
|
|
113
117
|
const l = this.loc();
|
|
114
118
|
this.expect(TokenType.COMPONENT);
|
|
115
|
-
|
|
119
|
+
let name = this.expect(TokenType.IDENTIFIER, "Expected component name").value;
|
|
120
|
+
|
|
121
|
+
// Check for compound component: Dialog.Title
|
|
122
|
+
let parent = null;
|
|
123
|
+
let child = null;
|
|
124
|
+
if (this.check(TokenType.DOT)) {
|
|
125
|
+
this.advance(); // consume '.'
|
|
126
|
+
child = this.expect(TokenType.IDENTIFIER, "Expected sub-component name after '.'").value;
|
|
127
|
+
parent = name;
|
|
128
|
+
name = parent + '.' + child;
|
|
129
|
+
}
|
|
116
130
|
|
|
117
131
|
let params = [];
|
|
118
132
|
if (this.match(TokenType.LPAREN)) {
|
|
@@ -125,9 +139,23 @@ export function installBrowserParser(ParserClass) {
|
|
|
125
139
|
while (!this.check(TokenType.RBRACE) && !this.isAtEnd()) {
|
|
126
140
|
if (this.check(TokenType.STYLE_BLOCK)) {
|
|
127
141
|
const sl = this.loc();
|
|
128
|
-
|
|
142
|
+
let css = this.current().value;
|
|
143
|
+
let config = null;
|
|
144
|
+
// Parse __CONFIG:key:value__ prefix from lexer
|
|
145
|
+
if (css.startsWith('__CONFIG:')) {
|
|
146
|
+
const endIdx = css.indexOf('__', 9);
|
|
147
|
+
if (endIdx !== -1) {
|
|
148
|
+
const configStr = css.slice(9, endIdx);
|
|
149
|
+
config = {};
|
|
150
|
+
for (const part of configStr.split(',')) {
|
|
151
|
+
const [k, v] = part.split(':').map(s => s.trim());
|
|
152
|
+
if (k) config[k] = v || true;
|
|
153
|
+
}
|
|
154
|
+
css = css.slice(endIdx + 2).trim();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
129
157
|
this.advance();
|
|
130
|
-
body.push(new AST.ComponentStyleBlock(css, sl));
|
|
158
|
+
body.push(new AST.ComponentStyleBlock(css, sl, config));
|
|
131
159
|
} else if (this.check(TokenType.LESS) && this._looksLikeJSX()) {
|
|
132
160
|
body.push(this.parseJSXElementOrFragment());
|
|
133
161
|
} else if (this.check(TokenType.STATE)) {
|
|
@@ -140,13 +168,22 @@ export function installBrowserParser(ParserClass) {
|
|
|
140
168
|
body.push(this.parseComponent());
|
|
141
169
|
} else if (this.check(TokenType.FORM)) {
|
|
142
170
|
body.push(this.parseFormDeclaration());
|
|
171
|
+
} else if (this.check(TokenType.IDENTIFIER) && this.current().value === 'font' && this.peek(1).type === TokenType.IDENTIFIER && this.peek(2).type === TokenType.FROM) {
|
|
172
|
+
body.push(this.parseComponentFontDeclaration());
|
|
173
|
+
} else if (this.check(TokenType.IDENTIFIER) && this.current().value === 'animate' && this.peek(1).type === TokenType.IDENTIFIER && this.peek(2).type === TokenType.LBRACE) {
|
|
174
|
+
body.push(this.parseAnimateDeclaration());
|
|
143
175
|
} else {
|
|
144
176
|
body.push(this.parseStatement());
|
|
145
177
|
}
|
|
146
178
|
}
|
|
147
179
|
this.expect(TokenType.RBRACE, "Expected '}' to close component body");
|
|
148
180
|
|
|
149
|
-
|
|
181
|
+
const node = new AST.ComponentDeclaration(name, params, body, l);
|
|
182
|
+
if (parent) {
|
|
183
|
+
node.parent = parent;
|
|
184
|
+
node.child = child;
|
|
185
|
+
}
|
|
186
|
+
return node;
|
|
150
187
|
};
|
|
151
188
|
|
|
152
189
|
// ─── JSX-like parsing ─────────────────────────────────────
|
|
@@ -305,6 +342,13 @@ export function installBrowserParser(ParserClass) {
|
|
|
305
342
|
this.error("Expected attribute name");
|
|
306
343
|
}
|
|
307
344
|
|
|
345
|
+
// Handle hyphenated attribute names: aria-disabled, data-testid, stroke-width, etc.
|
|
346
|
+
while (this.check(TokenType.MINUS) && this.peek(1) &&
|
|
347
|
+
(this.peek(1).type === TokenType.IDENTIFIER || this.peek(1).value in Keywords)) {
|
|
348
|
+
this.advance(); // consume MINUS
|
|
349
|
+
name += '-' + this.advance().value;
|
|
350
|
+
}
|
|
351
|
+
|
|
308
352
|
// Handle namespaced attributes: on:click, bind:value, class:active
|
|
309
353
|
if (this.match(TokenType.COLON)) {
|
|
310
354
|
let suffix;
|
|
@@ -608,4 +652,177 @@ export function installBrowserParser(ParserClass) {
|
|
|
608
652
|
this.expect(TokenType.RBRACE, "Expected '}' to close JSX match body");
|
|
609
653
|
return new AST.JSXMatch(subject, arms, l);
|
|
610
654
|
};
|
|
655
|
+
|
|
656
|
+
// ─── Font declaration parsing ──────────────────────────────
|
|
657
|
+
|
|
658
|
+
ParserClass.prototype.parseComponentFontDeclaration = function() {
|
|
659
|
+
const l = this.loc();
|
|
660
|
+
this.advance(); // consume 'font' identifier
|
|
661
|
+
const name = this.expect(TokenType.IDENTIFIER, "Expected font name after 'font'").value;
|
|
662
|
+
this.expect(TokenType.FROM, "Expected 'from' after font name");
|
|
663
|
+
const source = this.expect(TokenType.STRING, "Expected URL or path string after 'from'").value;
|
|
664
|
+
|
|
665
|
+
// Optional config block: { weight: "400" style: "normal" display: "swap" }
|
|
666
|
+
let config = null;
|
|
667
|
+
if (this.check(TokenType.LBRACE)) {
|
|
668
|
+
this.advance(); // consume '{'
|
|
669
|
+
config = {};
|
|
670
|
+
while (!this.check(TokenType.RBRACE) && !this.isAtEnd()) {
|
|
671
|
+
const key = this.expect(TokenType.IDENTIFIER, "Expected config key (weight, style, display)").value;
|
|
672
|
+
this.expect(TokenType.COLON, `Expected ':' after '${key}'`);
|
|
673
|
+
const val = this.expect(TokenType.STRING, `Expected string value for '${key}'`).value;
|
|
674
|
+
config[key] = val;
|
|
675
|
+
}
|
|
676
|
+
this.expect(TokenType.RBRACE, "Expected '}' to close font config block");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return new FontDeclaration(name, source, config, l);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
// ─── Animate block parsing ─────────────────────────────────
|
|
683
|
+
|
|
684
|
+
ParserClass.prototype.parseAnimateDeclaration = function() {
|
|
685
|
+
const l = this.loc();
|
|
686
|
+
this.advance(); // consume 'animate' identifier
|
|
687
|
+
const name = this.expect(TokenType.IDENTIFIER, "Expected animation name after 'animate'").value;
|
|
688
|
+
this.expect(TokenType.LBRACE, "Expected '{' after animate name");
|
|
689
|
+
|
|
690
|
+
let enter = null;
|
|
691
|
+
let exit = null;
|
|
692
|
+
let duration = null;
|
|
693
|
+
let easing = null;
|
|
694
|
+
let stagger = null;
|
|
695
|
+
let stay = null;
|
|
696
|
+
|
|
697
|
+
while (!this.check(TokenType.RBRACE) && !this.isAtEnd()) {
|
|
698
|
+
const key = this.expect(TokenType.IDENTIFIER, "Expected property name inside animate block").value;
|
|
699
|
+
this.expect(TokenType.COLON, `Expected ':' after '${key}'`);
|
|
700
|
+
|
|
701
|
+
switch (key) {
|
|
702
|
+
case 'enter':
|
|
703
|
+
enter = this._parseAnimateComposition();
|
|
704
|
+
break;
|
|
705
|
+
case 'exit':
|
|
706
|
+
exit = this._parseAnimateComposition();
|
|
707
|
+
break;
|
|
708
|
+
case 'duration': {
|
|
709
|
+
const tok = this.expect(TokenType.NUMBER, "Expected number for 'duration'");
|
|
710
|
+
duration = Number(tok.value);
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
case 'easing': {
|
|
714
|
+
const tok = this.expect(TokenType.STRING, "Expected string for 'easing'");
|
|
715
|
+
easing = tok.value;
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
case 'stagger': {
|
|
719
|
+
const tok = this.expect(TokenType.NUMBER, "Expected number for 'stagger'");
|
|
720
|
+
stagger = Number(tok.value);
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
case 'stay': {
|
|
724
|
+
const tok = this.expect(TokenType.NUMBER, "Expected number for 'stay'");
|
|
725
|
+
stay = Number(tok.value);
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
default:
|
|
729
|
+
this.error(`Unknown animate property '${key}'. Expected enter, exit, duration, easing, stagger, or stay`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
this.expect(TokenType.RBRACE, "Expected '}' to close animate block");
|
|
734
|
+
return new AnimateDeclaration(name, enter, exit, duration, easing, stagger, stay, l);
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Parse animate composition with precedence:
|
|
739
|
+
* - `then` has lowest precedence (creates AnimateSequence)
|
|
740
|
+
* - `+` has higher precedence (creates AnimateParallel)
|
|
741
|
+
*/
|
|
742
|
+
ParserClass.prototype._parseAnimateComposition = function() {
|
|
743
|
+
const l = this.loc();
|
|
744
|
+
let left = this._parseAnimateParallel();
|
|
745
|
+
|
|
746
|
+
// Check for `then` (IDENTIFIER with value 'then') — sequential composition
|
|
747
|
+
const parts = [left];
|
|
748
|
+
while (this.check(TokenType.IDENTIFIER) && this.current().value === 'then') {
|
|
749
|
+
this.advance(); // consume 'then'
|
|
750
|
+
parts.push(this._parseAnimateParallel());
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (parts.length === 1) return left;
|
|
754
|
+
return new AnimateSequence(parts, l);
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Parse parallel composition: primitives joined with `+`
|
|
759
|
+
*/
|
|
760
|
+
ParserClass.prototype._parseAnimateParallel = function() {
|
|
761
|
+
const l = this.loc();
|
|
762
|
+
let left = this._parseAnimatePrimitive();
|
|
763
|
+
|
|
764
|
+
const parts = [left];
|
|
765
|
+
while (this.check(TokenType.PLUS)) {
|
|
766
|
+
this.advance(); // consume '+'
|
|
767
|
+
parts.push(this._parseAnimatePrimitive());
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (parts.length === 1) return left;
|
|
771
|
+
return new AnimateParallel(parts, l);
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Parse a single animation primitive: name(key: value, key: value)
|
|
776
|
+
* Supports parenthesized grouping: (expr)
|
|
777
|
+
*/
|
|
778
|
+
ParserClass.prototype._parseAnimatePrimitive = function() {
|
|
779
|
+
// Parenthesized grouping for precedence override
|
|
780
|
+
if (this.check(TokenType.LPAREN)) {
|
|
781
|
+
this.advance(); // consume '('
|
|
782
|
+
const inner = this._parseAnimateComposition();
|
|
783
|
+
this.expect(TokenType.RPAREN, "Expected ')' after grouped animation expression");
|
|
784
|
+
return inner;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const l = this.loc();
|
|
788
|
+
const primName = this.expect(TokenType.IDENTIFIER, "Expected animation primitive name (fade, slide, scale, rotate, blur)").value;
|
|
789
|
+
this.expect(TokenType.LPAREN, `Expected '(' after '${primName}'`);
|
|
790
|
+
|
|
791
|
+
const params = {};
|
|
792
|
+
while (!this.check(TokenType.RPAREN) && !this.isAtEnd()) {
|
|
793
|
+
// Accept identifiers and keywords as parameter names (e.g., 'from' is a keyword in Tova)
|
|
794
|
+
let paramKey;
|
|
795
|
+
if (this.check(TokenType.IDENTIFIER) || (typeof this.current().value === 'string' && this.current().value in Keywords)) {
|
|
796
|
+
paramKey = this.advance().value;
|
|
797
|
+
} else {
|
|
798
|
+
paramKey = this.expect(TokenType.IDENTIFIER, "Expected parameter name").value;
|
|
799
|
+
}
|
|
800
|
+
this.expect(TokenType.COLON, `Expected ':' after parameter name '${paramKey}'`);
|
|
801
|
+
|
|
802
|
+
// Value can be a number (including negative), identifier, or string
|
|
803
|
+
if (this.check(TokenType.MINUS)) {
|
|
804
|
+
this.advance(); // consume '-'
|
|
805
|
+
const tok = this.expect(TokenType.NUMBER, "Expected number after '-'");
|
|
806
|
+
params[paramKey] = -Number(tok.value);
|
|
807
|
+
} else if (this.check(TokenType.NUMBER)) {
|
|
808
|
+
const tok = this.advance();
|
|
809
|
+
params[paramKey] = Number(tok.value);
|
|
810
|
+
} else if (this.check(TokenType.STRING)) {
|
|
811
|
+
const tok = this.advance();
|
|
812
|
+
params[paramKey] = tok.value;
|
|
813
|
+
} else if (this.check(TokenType.IDENTIFIER)) {
|
|
814
|
+
const tok = this.advance();
|
|
815
|
+
params[paramKey] = tok.value;
|
|
816
|
+
} else {
|
|
817
|
+
this.error("Expected number, string, or identifier as animation parameter value");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (!this.check(TokenType.RPAREN)) {
|
|
821
|
+
this.expect(TokenType.COMMA, "Expected ',' between animation parameters");
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
this.expect(TokenType.RPAREN, `Expected ')' to close '${primName}' parameters`);
|
|
826
|
+
return new AnimatePrimitive(primName, params, l);
|
|
827
|
+
};
|
|
611
828
|
}
|
package/src/parser/parser.js
CHANGED
|
@@ -599,6 +599,12 @@ export class Parser {
|
|
|
599
599
|
if (this.check(TokenType.PUB)) {
|
|
600
600
|
this.error("Duplicate 'pub' modifier");
|
|
601
601
|
}
|
|
602
|
+
// Handle pub component at top level (parseComponent is installed by browser-parser plugin)
|
|
603
|
+
if (this.check(TokenType.COMPONENT) && typeof this.parseComponent === 'function') {
|
|
604
|
+
const comp = this.parseComponent();
|
|
605
|
+
comp.isPublic = true;
|
|
606
|
+
return comp;
|
|
607
|
+
}
|
|
602
608
|
const stmt = this.parseStatement();
|
|
603
609
|
if (stmt) stmt.isPublic = true;
|
|
604
610
|
return stmt;
|
|
@@ -1490,7 +1496,14 @@ export class Parser {
|
|
|
1490
1496
|
// Destructuring without let: {name, age} = user or [a, b] = list
|
|
1491
1497
|
if (expr.type === 'ObjectLiteral') {
|
|
1492
1498
|
const pattern = new AST.ObjectPattern(
|
|
1493
|
-
expr.properties.map(p =>
|
|
1499
|
+
expr.properties.map(p => {
|
|
1500
|
+
const key = typeof p.key === 'string' ? p.key : p.key.name || p.key;
|
|
1501
|
+
// For shorthand {name}, key and value are the same
|
|
1502
|
+
// For rename {name: alias}, value is the alias identifier
|
|
1503
|
+
const val = p.shorthand ? key
|
|
1504
|
+
: (p.value && p.value.type === 'Identifier' ? p.value.name : key);
|
|
1505
|
+
return { key, value: val };
|
|
1506
|
+
}),
|
|
1494
1507
|
expr.loc
|
|
1495
1508
|
);
|
|
1496
1509
|
const value = this.parseExpression();
|
|
@@ -1498,7 +1511,13 @@ export class Parser {
|
|
|
1498
1511
|
}
|
|
1499
1512
|
if (expr.type === 'ArrayLiteral') {
|
|
1500
1513
|
const pattern = new AST.ArrayPattern(
|
|
1501
|
-
expr.elements.map(e =>
|
|
1514
|
+
expr.elements.map(e => {
|
|
1515
|
+
if (e.type === 'Identifier') return e.name;
|
|
1516
|
+
if (e.type === 'SpreadExpression' && e.argument && e.argument.type === 'Identifier') {
|
|
1517
|
+
return '...' + e.argument.name;
|
|
1518
|
+
}
|
|
1519
|
+
return '_';
|
|
1520
|
+
}),
|
|
1502
1521
|
expr.loc
|
|
1503
1522
|
);
|
|
1504
1523
|
const value = this.parseExpression();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Theme-specific AST Node definitions for the Tova language
|
|
2
|
+
// Extracted for lazy loading — only loaded when theme { } blocks are used.
|
|
3
|
+
|
|
4
|
+
export class ThemeBlock {
|
|
5
|
+
constructor(sections, darkOverrides, loc) {
|
|
6
|
+
this.type = 'ThemeBlock';
|
|
7
|
+
this.sections = sections; // Array of ThemeSection
|
|
8
|
+
this.darkOverrides = darkOverrides; // Array of ThemeToken (flat dark mode overrides)
|
|
9
|
+
this.loc = loc;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ThemeSection {
|
|
14
|
+
constructor(name, tokens, loc) {
|
|
15
|
+
this.type = 'ThemeSection';
|
|
16
|
+
this.name = name; // string — section name, e.g. "colors", "spacing", "font"
|
|
17
|
+
this.tokens = tokens; // Array of ThemeToken
|
|
18
|
+
this.loc = loc;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ThemeToken {
|
|
23
|
+
constructor(name, value, loc) {
|
|
24
|
+
this.type = 'ThemeToken';
|
|
25
|
+
this.name = name; // string — dot-separated name, e.g. "primary.hover"
|
|
26
|
+
this.value = value; // string or number — token value
|
|
27
|
+
this.loc = loc;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Theme-specific parser methods for the Tova language
|
|
2
|
+
// Extracted from parser.js for lazy loading — only loaded when theme { } blocks are encountered.
|
|
3
|
+
|
|
4
|
+
import { TokenType } from '../lexer/tokens.js';
|
|
5
|
+
import { ThemeBlock, ThemeSection, ThemeToken } from './theme-ast.js';
|
|
6
|
+
|
|
7
|
+
export function installThemeParser(ParserClass) {
|
|
8
|
+
if (ParserClass.prototype._themeParserInstalled) return;
|
|
9
|
+
ParserClass.prototype._themeParserInstalled = true;
|
|
10
|
+
|
|
11
|
+
ParserClass.prototype.parseThemeBlock = function() {
|
|
12
|
+
const l = this.loc();
|
|
13
|
+
this.advance(); // consume 'theme'
|
|
14
|
+
this.expect(TokenType.LBRACE, "Expected '{' after 'theme'");
|
|
15
|
+
|
|
16
|
+
const sections = [];
|
|
17
|
+
const darkOverrides = [];
|
|
18
|
+
|
|
19
|
+
while (!this.check(TokenType.RBRACE) && !this.isAtEnd()) {
|
|
20
|
+
const sectionLoc = this.loc();
|
|
21
|
+
const sectionName = this.expect(TokenType.IDENTIFIER, "Expected section name inside theme block").value;
|
|
22
|
+
this.expect(TokenType.LBRACE, `Expected '{' after theme section '${sectionName}'`);
|
|
23
|
+
|
|
24
|
+
if (sectionName === 'dark') {
|
|
25
|
+
// dark section: flat overrides with dot-notation names
|
|
26
|
+
while (!this.check(TokenType.RBRACE) && !this.isAtEnd()) {
|
|
27
|
+
darkOverrides.push(this._parseThemeToken());
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
// Regular section: parse tokens into a ThemeSection
|
|
31
|
+
const tokens = [];
|
|
32
|
+
while (!this.check(TokenType.RBRACE) && !this.isAtEnd()) {
|
|
33
|
+
tokens.push(this._parseThemeToken());
|
|
34
|
+
}
|
|
35
|
+
sections.push(new ThemeSection(sectionName, tokens, sectionLoc));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.expect(TokenType.RBRACE, `Expected '}' to close theme section '${sectionName}'`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.expect(TokenType.RBRACE, "Expected '}' to close theme block");
|
|
42
|
+
return new ThemeBlock(sections, darkOverrides, l);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
ParserClass.prototype._parseThemeToken = function() {
|
|
46
|
+
const l = this.loc();
|
|
47
|
+
|
|
48
|
+
// Read dot-separated name: IDENTIFIER (DOT IDENTIFIER)*
|
|
49
|
+
let name = this.expect(TokenType.IDENTIFIER, "Expected token name").value;
|
|
50
|
+
while (this.check(TokenType.DOT)) {
|
|
51
|
+
this.advance(); // consume DOT
|
|
52
|
+
const part = this.expect(TokenType.IDENTIFIER, "Expected identifier after '.' in token name").value;
|
|
53
|
+
name += '.' + part;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.expect(TokenType.COLON, `Expected ':' after token name '${name}'`);
|
|
57
|
+
|
|
58
|
+
// Read value: STRING or NUMBER
|
|
59
|
+
let value;
|
|
60
|
+
if (this.check(TokenType.STRING)) {
|
|
61
|
+
value = this.advance().value;
|
|
62
|
+
} else if (this.check(TokenType.NUMBER)) {
|
|
63
|
+
value = this.advance().value;
|
|
64
|
+
} else {
|
|
65
|
+
this.error(`Expected string or number value for token '${name}'`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return new ThemeToken(name, value, l);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { installThemeParser } from '../../parser/theme-parser.js';
|
|
2
|
+
|
|
3
|
+
export const themePlugin = {
|
|
4
|
+
name: 'theme',
|
|
5
|
+
astNodeType: 'ThemeBlock',
|
|
6
|
+
detection: {
|
|
7
|
+
strategy: 'identifier',
|
|
8
|
+
identifierValue: 'theme',
|
|
9
|
+
},
|
|
10
|
+
parser: {
|
|
11
|
+
install: installThemeParser,
|
|
12
|
+
installedFlag: '_themeParserInstalled',
|
|
13
|
+
method: 'parseThemeBlock',
|
|
14
|
+
},
|
|
15
|
+
analyzer: {
|
|
16
|
+
visit: (analyzer, node) => analyzer.visitThemeBlock(node),
|
|
17
|
+
noopNodeTypes: ['ThemeSection', 'ThemeToken'],
|
|
18
|
+
},
|
|
19
|
+
codegen: {},
|
|
20
|
+
};
|
|
@@ -13,7 +13,9 @@ import { benchPlugin } from './plugins/bench-plugin.js';
|
|
|
13
13
|
import { edgePlugin } from './plugins/edge-plugin.js';
|
|
14
14
|
import { concurrencyPlugin } from './plugins/concurrency-plugin.js';
|
|
15
15
|
import { deployPlugin } from './plugins/deploy-plugin.js';
|
|
16
|
+
import { themePlugin } from './plugins/theme-plugin.js';
|
|
16
17
|
|
|
18
|
+
BlockRegistry.register(themePlugin);
|
|
17
19
|
BlockRegistry.register(serverPlugin);
|
|
18
20
|
BlockRegistry.register(browserPlugin);
|
|
19
21
|
BlockRegistry.register(sharedPlugin);
|