driftjs-compiler 0.0.0

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.
@@ -0,0 +1,288 @@
1
+ import * as acorn from 'acorn';
2
+ import type {
3
+ ProgramNode,
4
+ TemplateChildNode,
5
+ ElementNode,
6
+ InterpolationNode,
7
+ IfNode,
8
+ SwitchNode,
9
+ } from '../types/index.js';
10
+ import {
11
+ ASTNodeType,
12
+ DriftParserError,
13
+ } from '../types/index.js';
14
+
15
+ function cloneAstNode<T>(node: T): T {
16
+ if (node === null || typeof node !== 'object') return node;
17
+ if (Array.isArray(node)) return node.map(cloneAstNode) as any;
18
+ const copy: any = {};
19
+ for (const key of Object.keys(node)) {
20
+ copy[key] = cloneAstNode((node as any)[key]);
21
+ }
22
+ return copy as T;
23
+ }
24
+
25
+ /**
26
+ * Transformer for raw Drift template AST.
27
+ * Performs AST enrichment by:
28
+ * 1. Stripping redundant whitespace/newline TextNodes between element boundaries.
29
+ * 2. Parsing raw JS strings in interpolations into Acorn AST nodes.
30
+ * 3. Parsing raw JS strings inside <script> tags into Acorn AST nodes.
31
+ */
32
+ export class DriftTransformer {
33
+ private readonly rawAst: ProgramNode;
34
+
35
+ constructor(rawAst: ProgramNode) {
36
+ this.rawAst = rawAst;
37
+ }
38
+
39
+ /**
40
+ * Transforms raw AST into an enriched compiler AST.
41
+ * @returns Transformed ProgramNode.
42
+ */
43
+ public transform(): ProgramNode {
44
+ return {
45
+ ...this.rawAst,
46
+ body: this.transformChildren(this.rawAst.body),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Transforms array of child nodes, filtering out redundant whitespace-only TextNodes.
52
+ */
53
+ private transformChildren(children: readonly TemplateChildNode[]): TemplateChildNode[] {
54
+ const transformed: TemplateChildNode[] = [];
55
+
56
+ for (const child of children) {
57
+ if (child.type === ASTNodeType.Text && typeof child.content === 'string' && this.isWhitespaceOnly(child.content)) {
58
+ continue;
59
+ }
60
+
61
+ transformed.push(this.transformNode(child));
62
+ }
63
+
64
+ return transformed;
65
+ }
66
+
67
+ /**
68
+ * Transforms an individual AST node.
69
+ */
70
+ private transformNode(node: TemplateChildNode): TemplateChildNode {
71
+ if (node.type === ASTNodeType.Element) {
72
+ if (node.tagName === 'script') {
73
+ return this.transformScriptElement(node);
74
+ }
75
+ return {
76
+ ...node,
77
+ attributes: node.attributes.map((attr) => {
78
+ if (
79
+ attr.type === ASTNodeType.Attribute &&
80
+ attr.value !== null &&
81
+ typeof attr.value !== 'string' &&
82
+ attr.value.type === ASTNodeType.Interpolation
83
+ ) {
84
+ return { ...attr, value: this.transformInterpolation(attr.value) };
85
+ }
86
+ return attr;
87
+ }),
88
+ children: this.transformChildren(node.children),
89
+ };
90
+ }
91
+
92
+ if (node.type === ASTNodeType.Interpolation) {
93
+ return this.transformInterpolation(node);
94
+ }
95
+
96
+ if (node.type === ASTNodeType.If) {
97
+ const parsedTest = typeof node.test === 'string' && node.test.trim().length > 0
98
+ ? acorn.parseExpressionAt(node.test, 0, { ecmaVersion: 'latest' })
99
+ : node.test;
100
+
101
+ let transformedAlt: TemplateChildNode[] | IfNode | null = null;
102
+ if (Array.isArray(node.alternate)) {
103
+ transformedAlt = this.transformChildren(node.alternate);
104
+ } else if (node.alternate !== null) {
105
+ transformedAlt = this.transformNode(node.alternate as IfNode) as IfNode;
106
+ }
107
+
108
+ return {
109
+ ...node,
110
+ test: parsedTest,
111
+ consequent: this.transformChildren(node.consequent),
112
+ alternate: transformedAlt,
113
+ };
114
+ }
115
+
116
+ if (node.type === ASTNodeType.For) {
117
+ return {
118
+ ...node,
119
+ iterable: typeof node.iterable === 'string'
120
+ ? acorn.parseExpressionAt(node.iterable, 0, { ecmaVersion: 'latest' })
121
+ : node.iterable,
122
+ key: typeof node.key === 'string' && node.key.trim().length > 0
123
+ ? acorn.parseExpressionAt(node.key, 0, { ecmaVersion: 'latest' })
124
+ : (node.key ?? null),
125
+ body: this.transformChildren(node.body),
126
+ };
127
+ }
128
+
129
+ if (node.type === ASTNodeType.Switch) {
130
+ return this.transformSwitchToIfChain(node);
131
+ }
132
+
133
+ return node;
134
+ }
135
+
136
+ /**
137
+ * Transforms @switch node into a reactive @if / @else if / @else chain.
138
+ */
139
+ private transformSwitchToIfChain(node: SwitchNode): TemplateChildNode {
140
+ const discAst = typeof node.discriminant === 'string'
141
+ ? acorn.parseExpressionAt(node.discriminant, 0, { ecmaVersion: 'latest' })
142
+ : node.discriminant;
143
+
144
+ const buildIfChain = (index: number): TemplateChildNode | TemplateChildNode[] | null => {
145
+ const c = node.cases[index];
146
+ if (!c) return null;
147
+ if (c.expression === null) {
148
+ return this.transformChildren(c.body);
149
+ }
150
+
151
+ const caseAst = typeof c.expression === 'string' && c.expression.trim().length > 0
152
+ ? acorn.parseExpressionAt(c.expression, 0, { ecmaVersion: 'latest' })
153
+ : c.expression;
154
+
155
+ const parsedTest: acorn.Node = {
156
+ type: 'BinaryExpression',
157
+ operator: '===',
158
+ left: cloneAstNode(discAst) as any,
159
+ right: caseAst as any,
160
+ start: 0,
161
+ end: 0,
162
+ } as any;
163
+
164
+ const consequent = this.transformChildren(c.body);
165
+ const nextAlt = buildIfChain(index + 1);
166
+
167
+ let alternate: TemplateChildNode[] | IfNode | null = null;
168
+ if (Array.isArray(nextAlt)) {
169
+ alternate = nextAlt;
170
+ } else if (nextAlt !== null && (nextAlt as TemplateChildNode).type === ASTNodeType.If) {
171
+ alternate = nextAlt as IfNode;
172
+ }
173
+
174
+ return {
175
+ type: ASTNodeType.If,
176
+ test: parsedTest,
177
+ consequent,
178
+ alternate,
179
+ loc: c.loc,
180
+ };
181
+ };
182
+
183
+ const res = buildIfChain(0);
184
+ if (!res) {
185
+ return {
186
+ type: ASTNodeType.Comment,
187
+ content: 'empty switch',
188
+ loc: node.loc,
189
+ };
190
+ }
191
+ if (Array.isArray(res)) {
192
+ return res[0] || { type: ASTNodeType.Comment, content: 'empty switch', loc: node.loc };
193
+ }
194
+ return res;
195
+ }
196
+
197
+
198
+
199
+
200
+
201
+ /**
202
+ * Parses raw JS string expression in interpolation into an Acorn AST node.
203
+ */
204
+ private transformInterpolation(node: InterpolationNode): InterpolationNode {
205
+ if (typeof node.expression !== 'string') {
206
+ return node;
207
+ }
208
+
209
+ let parsedExpr: acorn.Node;
210
+ try {
211
+ parsedExpr = acorn.parseExpressionAt(node.expression, 0, {
212
+ ecmaVersion: 'latest',
213
+ allowAwaitOutsideFunction: true,
214
+ });
215
+ } catch (err: unknown) {
216
+ const msg = err instanceof Error ? err.message : String(err);
217
+ throw new DriftParserError(
218
+ `Failed to parse JS expression in interpolation: ${msg}`,
219
+ node.loc.start.line,
220
+ node.loc.start.column,
221
+ node.loc.start.offset
222
+ );
223
+ }
224
+
225
+ return {
226
+ ...node,
227
+ expression: parsedExpr,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Parses raw JS string inside <script> tags into Acorn AST statement(s), stripping top-level Program wrapper.
233
+ */
234
+ private transformScriptElement(node: ElementNode): ElementNode {
235
+ const processedChildren = node.children.map((child) => {
236
+ if (child.type === ASTNodeType.Text && typeof child.content === 'string') {
237
+ let scriptAst: acorn.Node | readonly acorn.Node[];
238
+ try {
239
+ const program = acorn.parse(child.content, {
240
+ ecmaVersion: 'latest',
241
+ sourceType: 'module',
242
+ allowAwaitOutsideFunction: true,
243
+ allowReturnOutsideFunction: true,
244
+ });
245
+
246
+ if (program.body.length === 1 && program.body[0] !== undefined) {
247
+ scriptAst = program.body[0];
248
+ } else {
249
+ scriptAst = program.body;
250
+ }
251
+ } catch (err: unknown) {
252
+ const msg = err instanceof Error ? err.message : String(err);
253
+ throw new DriftParserError(
254
+ `Failed to parse script tag JS content: ${msg}`,
255
+ child.loc.start.line,
256
+ child.loc.start.column,
257
+ child.loc.start.offset
258
+ );
259
+ }
260
+
261
+ return {
262
+ ...child,
263
+ content: scriptAst,
264
+ };
265
+ }
266
+ return child;
267
+ });
268
+
269
+ return {
270
+ ...node,
271
+ children: processedChildren,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Checks if string consists only of whitespace characters (space, tab, newline, carriage return) using ASCII codes.
277
+ */
278
+ private isWhitespaceOnly(text: string): boolean {
279
+ if (text.length === 0) return false;
280
+ for (let i = 0; i < text.length; i++) {
281
+ const code = text.charCodeAt(i);
282
+ if (code !== 32 && code !== 9 && code !== 10 && code !== 13) {
283
+ return false;
284
+ }
285
+ }
286
+ return true;
287
+ }
288
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { compile } from '../src/index.js';
3
+ import { Opcode } from '../types/index.js';
4
+
5
+ describe('DriftGenerator', () => {
6
+ it('generates fragment and return for empty templates', () => {
7
+ const module = compile('');
8
+
9
+ expect(module.constants).toEqual([]);
10
+ expect(module.bytecode).toEqual([Opcode.CREATE_FRAGMENT, 0, Opcode.RETURN, 0]);
11
+ });
12
+
13
+ it('generates direct root element for single top-level element', () => {
14
+ const module = compile('<div>Hello World</div>');
15
+
16
+ expect(module.constants).toContain('div');
17
+ expect(module.constants).toContain('Hello World');
18
+
19
+ const tagIdx = module.constants.indexOf('div');
20
+
21
+ expect(module.bytecode[0]).toBe(Opcode.CREATE_ELEMENT);
22
+ expect(module.bytecode[1]).toBe(0); // rootReg = 0
23
+ expect(module.bytecode[2]).toBe(tagIdx);
24
+
25
+ expect(module.bytecode[module.bytecode.length - 2]).toBe(Opcode.RETURN);
26
+ expect(module.bytecode[module.bytecode.length - 1]).toBe(0);
27
+ });
28
+
29
+ it('generates fragment container for multiple top-level nodes', () => {
30
+ const module = compile('<h1>Title</h1><p>Paragraph</p>');
31
+
32
+ expect(module.bytecode[0]).toBe(Opcode.CREATE_FRAGMENT);
33
+ expect(module.bytecode[1]).toBe(0); // fragment reg
34
+
35
+ expect(module.constants).toContain('h1');
36
+ expect(module.constants).toContain('p');
37
+ });
38
+
39
+ it('generates static, dynamic, and boolean attributes', () => {
40
+ const module = compile('<input type="checkbox" checked data-id={id} />');
41
+
42
+ expect(module.constants).toContain('type');
43
+ expect(module.constants).toContain('checkbox');
44
+ expect(module.constants).toContain('checked');
45
+ expect(module.constants).toContain('data-id');
46
+
47
+ // SET_ATTR opcode is present
48
+ expect(module.bytecode).toContain(Opcode.SET_ATTR);
49
+ });
50
+
51
+ it('generates interpolated text and comments', () => {
52
+ const module = compile('<!-- header --><div>{ user.name }</div>');
53
+
54
+ expect(module.constants).toContain(' header ');
55
+ expect(module.bytecode).toContain(Opcode.CREATE_COMMENT);
56
+ expect(module.bytecode).toContain(Opcode.INTERPOLATE_TEXT);
57
+ });
58
+
59
+ it('generates REACTIVE_IF opcode for @if, @else if, and @else control flows', () => {
60
+ const src = `@if isLoggedIn { <span>Welcome</span> } @else if isGuest { <span>Guest</span> } @else { <span>Login</span> }`;
61
+ const module = compile(src);
62
+
63
+ // New reactive encoding: no flat jumps for @if
64
+ expect(module.bytecode).toContain(Opcode.REACTIVE_IF);
65
+ expect(module.bytecode).not.toContain(Opcode.JUMP_IF_FALSE);
66
+ expect(module.bytecode).not.toContain(Opcode.EVAL_EXPR);
67
+
68
+ // The condition AST, consequent sub-module, and deps array must all be in the constant pool
69
+ const reactiveIfIdx = module.bytecode.indexOf(Opcode.REACTIVE_IF);
70
+ expect(reactiveIfIdx).toBeGreaterThan(-1);
71
+
72
+ // Operand layout: REACTIVE_IF parentReg condIdx consIdx altIdx depsIdx
73
+ expect(module.bytecode.length).toBeGreaterThan(reactiveIfIdx + 5);
74
+ });
75
+
76
+ it('generates REACTIVE_FOR opcode for @for loop directives', () => {
77
+ const src = `@for (item, index) in list { <li>{item}</li> }`;
78
+ const module = compile(src);
79
+
80
+ // New reactive encoding: no flat LOOP_ITER for @for
81
+ expect(module.bytecode).toContain(Opcode.REACTIVE_FOR);
82
+ expect(module.bytecode).not.toContain(Opcode.LOOP_ITER);
83
+ expect(module.bytecode).not.toContain(Opcode.EVAL_EXPR);
84
+
85
+ const reactiveForIdx = module.bytecode.indexOf(Opcode.REACTIVE_FOR);
86
+ expect(reactiveForIdx).toBeGreaterThan(-1);
87
+
88
+ // Operand layout: REACTIVE_FOR parentReg iterIdx itemNameIdx indexNameIdx bodyIdx depsIdx
89
+ expect(module.bytecode.length).toBeGreaterThan(reactiveForIdx + 6);
90
+ });
91
+
92
+ it('generates bytecode for @switch, @case, and @default directives', () => {
93
+ const src = `@switch role { @case "admin" { <p>Admin</p> } @default { <p>User</p> } }`;
94
+ const module = compile(src);
95
+
96
+ expect(module.bytecode).toContain(Opcode.REACTIVE_IF);
97
+ });
98
+
99
+ it('works end-to-end via compile() function', () => {
100
+ const template = `
101
+ <ul>
102
+ @for (item, index) in list {
103
+ <li key={index}>{item}</li>
104
+ }
105
+ </ul>
106
+ `;
107
+ const module = compile(template, false);
108
+
109
+ expect(module.bytecode.length).toBeGreaterThan(0);
110
+ expect(module.constants.length).toBeGreaterThan(0);
111
+ });
112
+
113
+ it('extracts imports metadata from script block', () => {
114
+ const src = `<script>import Header from "./Header.drift";</script><div><Header /></div>`;
115
+ const module = compile(src);
116
+
117
+ expect(module.imports).toBeDefined();
118
+ expect(module.imports).toHaveLength(1);
119
+ expect(module.imports![0]).toEqual({
120
+ localName: 'Header',
121
+ source: './Header.drift',
122
+ isDefault: true,
123
+ importedName: undefined,
124
+ });
125
+ expect(module.declaredVars).toContain('Header');
126
+ });
127
+
128
+ it('generates propsSpec for component elements with static and dynamic attributes', () => {
129
+ const src = `<script>import Header from "./Header.drift"; let count = 5;</script><div><Header title="Drift" count={count} /></div>`;
130
+ const module = compile(src);
131
+
132
+ expect(module.declaredVars).toContain('Header');
133
+ expect(module.declaredVars).toContain('count');
134
+
135
+ const propsSpec = module.constants.find(
136
+ (c) => typeof c === 'object' && c !== null && 'title' in c && 'count' in c
137
+ );
138
+ expect(propsSpec).toBeDefined();
139
+ expect((propsSpec as any).title).toBe('Drift');
140
+ });
141
+
142
+ it('extracts destructured prop variables from script block', () => {
143
+ const src = `<script>let { title = "Default", count = 0 } = props;</script><h1>{title}</h1>`;
144
+ const module = compile(src);
145
+
146
+ expect(module.declaredVars).toContain('title');
147
+ expect(module.declaredVars).toContain('count');
148
+ });
149
+ });