@esportsplus/template 0.16.13 → 0.16.15
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/bench/runtime.bench.ts +207 -0
- package/build/attributes.js +4 -1
- package/build/slot/array.js +50 -4
- package/build/slot/render.js +1 -2
- package/build/utilities.d.ts +2 -1
- package/build/utilities.js +2 -1
- package/package.json +5 -5
- package/src/attributes.ts +4 -1
- package/src/slot/array.ts +76 -4
- package/src/slot/render.ts +1 -4
- package/src/utilities.ts +3 -1
- package/storage/feature-research-2026-03-24.md +475 -0
- package/test-output.txt +0 -0
- package/{test/attributes.test.ts → tests/attributes.ts} +3 -2
- package/tests/compiler/codegen.ts +292 -0
- package/tests/compiler/integration.ts +252 -0
- package/tests/compiler/ts-parser.ts +160 -0
- package/{test/constants.test.ts → tests/constants.ts} +5 -1
- package/tests/event/onconnect.ts +147 -0
- package/tests/event/onresize.ts +187 -0
- package/tests/event/ontick.ts +273 -0
- package/{test/slot/array.test.ts → tests/slot/array.ts} +274 -0
- package/vitest.bench.config.ts +18 -0
- package/vitest.config.ts +1 -1
- package/storage/compiler-architecture-2026-01-13.md +0 -420
- /package/{test → examples}/index.ts +0 -0
- /package/{test → examples}/vite.config.ts +0 -0
- /package/{test/compiler/parser.test.ts → tests/compiler/parser.ts} +0 -0
- /package/{test/compiler/ts-analyzer.test.ts → tests/compiler/ts-analyzer.ts} +0 -0
- /package/{test → tests}/dist/test.js +0 -0
- /package/{test → tests}/dist/test.js.map +0 -0
- /package/{test/event/index.test.ts → tests/event/index.ts} +0 -0
- /package/{test/html.test.ts → tests/html.ts} +0 -0
- /package/{test/render.test.ts → tests/render.ts} +0 -0
- /package/{test/slot/cleanup.test.ts → tests/slot/cleanup.ts} +0 -0
- /package/{test/slot/effect.test.ts → tests/slot/effect.ts} +0 -0
- /package/{test/slot/index.test.ts → tests/slot/index.ts} +0 -0
- /package/{test/slot/render.test.ts → tests/slot/render.ts} +0 -0
- /package/{test/svg.test.ts → tests/svg.ts} +0 -0
- /package/{test/utilities.test.ts → tests/utilities.ts} +0 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ts } from '@esportsplus/typescript';
|
|
3
|
+
import { generateCode, rewriteExpression } from '../../src/compiler/codegen';
|
|
4
|
+
import { findHtmlTemplates } from '../../src/compiler/ts-parser';
|
|
5
|
+
import { NAMESPACE } from '../../src/compiler/constants';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
function codegen(source: string) {
|
|
9
|
+
let sourceFile = ts.createSourceFile('test.ts', source, ts.ScriptTarget.Latest, true),
|
|
10
|
+
templates = findHtmlTemplates(sourceFile);
|
|
11
|
+
|
|
12
|
+
return { result: generateCode(templates, sourceFile), sourceFile, templates };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
describe('compiler/codegen', () => {
|
|
17
|
+
describe('generateCode - static templates', () => {
|
|
18
|
+
it('returns empty result for no templates', () => {
|
|
19
|
+
let { result } = codegen('let x = 1;');
|
|
20
|
+
|
|
21
|
+
expect(result.prepend).toHaveLength(0);
|
|
22
|
+
expect(result.replacements).toHaveLength(0);
|
|
23
|
+
expect(result.templates.size).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('generates template factory for static template', () => {
|
|
27
|
+
let { result } = codegen(`let x = html\`<div>hello</div>\`;`);
|
|
28
|
+
|
|
29
|
+
expect(result.templates.size).toBe(1);
|
|
30
|
+
expect(result.prepend).toHaveLength(1);
|
|
31
|
+
expect(result.prepend[0]).toContain(`${NAMESPACE}.template(\`<div>hello</div>\`)`);
|
|
32
|
+
expect(result.replacements).toHaveLength(1);
|
|
33
|
+
|
|
34
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
35
|
+
|
|
36
|
+
expect(code).toContain('()');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('generates walker for static template with no slots', () => {
|
|
40
|
+
let { result } = codegen(`let x = html\`<div>hello</div>\`;`);
|
|
41
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
42
|
+
|
|
43
|
+
// Static template without slots just calls the template factory
|
|
44
|
+
expect(code).toMatch(/^[a-zA-Z_$][\w$]*\(\)$/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('generateCode - node slots', () => {
|
|
49
|
+
it('generates slot binding for node expression', () => {
|
|
50
|
+
let { result } = codegen(`let x = html\`<div>\${value}</div>\`;`);
|
|
51
|
+
|
|
52
|
+
expect(result.replacements).toHaveLength(1);
|
|
53
|
+
|
|
54
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
55
|
+
|
|
56
|
+
// Should contain slot() call for unknown type expression
|
|
57
|
+
expect(code).toContain(`${NAMESPACE}.slot(`);
|
|
58
|
+
expect(code).toContain('value');
|
|
59
|
+
expect(code).toContain('return');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('generates EffectSlot for arrow function expression', () => {
|
|
63
|
+
let { result } = codegen(`let x = html\`<div>\${() => count}</div>\`;`);
|
|
64
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
65
|
+
|
|
66
|
+
expect(code).toContain(`${NAMESPACE}.EffectSlot`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('generates static text binding for string literal', () => {
|
|
70
|
+
let { result } = codegen(`let x = html\`<div>\${"hello"}</div>\`;`);
|
|
71
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
72
|
+
|
|
73
|
+
expect(code).toContain(`${NAMESPACE}.text(`);
|
|
74
|
+
expect(code).toContain('"hello"');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('generates insertBefore for nested html template', () => {
|
|
78
|
+
let { result } = codegen(`let x = html\`<div>\${html\`<span>nested</span>\`}</div>\`;`);
|
|
79
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
80
|
+
|
|
81
|
+
expect(code).toContain('insertBefore');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('generates multiple node bindings', () => {
|
|
85
|
+
let { result } = codegen(`let x = html\`<div>\${a}</div><span>\${b}</span>\`;`);
|
|
86
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
87
|
+
|
|
88
|
+
expect(code).toContain('a');
|
|
89
|
+
expect(code).toContain('b');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('generateCode - attribute slots', () => {
|
|
94
|
+
it('generates setProperty for attribute binding', () => {
|
|
95
|
+
let { result } = codegen(`let x = html\`<div id=\${value}>text</div>\`;`);
|
|
96
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
97
|
+
|
|
98
|
+
expect(code).toContain(`${NAMESPACE}.setProperty(`);
|
|
99
|
+
expect(code).toContain("'id'");
|
|
100
|
+
expect(code).toContain('value');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('generates setList for class binding', () => {
|
|
104
|
+
let { result } = codegen(`let x = html\`<div class=\${cls}>text</div>\`;`);
|
|
105
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
106
|
+
|
|
107
|
+
expect(code).toContain(`${NAMESPACE}.setList(`);
|
|
108
|
+
expect(code).toContain("'class'");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('generates setList for style binding', () => {
|
|
112
|
+
let { result } = codegen(`let x = html\`<div style=\${sty}>text</div>\`;`);
|
|
113
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
114
|
+
|
|
115
|
+
expect(code).toContain(`${NAMESPACE}.setList(`);
|
|
116
|
+
expect(code).toContain("'style'");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('generateCode - event attributes', () => {
|
|
121
|
+
it('generates delegate for standard event', () => {
|
|
122
|
+
let { result } = codegen(`let x = html\`<div onclick=\${handler}>text</div>\`;`);
|
|
123
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
124
|
+
|
|
125
|
+
expect(code).toContain(`${NAMESPACE}.delegate(`);
|
|
126
|
+
expect(code).toContain("'click'");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('generates direct attach for focus event', () => {
|
|
130
|
+
let { result } = codegen(`let x = html\`<div onfocus=\${handler}>text</div>\`;`);
|
|
131
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
132
|
+
|
|
133
|
+
expect(code).toContain(`${NAMESPACE}.on(`);
|
|
134
|
+
expect(code).toContain("'focus'");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('generates lifecycle call for onconnect', () => {
|
|
138
|
+
let { result } = codegen(`let x = html\`<div onconnect=\${handler}>text</div>\`;`);
|
|
139
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
140
|
+
|
|
141
|
+
expect(code).toContain(`${NAMESPACE}.onconnect(`);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('generates lifecycle call for onresize', () => {
|
|
145
|
+
let { result } = codegen(`let x = html\`<div onresize=\${handler}>text</div>\`;`);
|
|
146
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
147
|
+
|
|
148
|
+
expect(code).toContain(`${NAMESPACE}.onresize(`);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('generateCode - nested templates', () => {
|
|
153
|
+
it('generates nested template with own factory', () => {
|
|
154
|
+
let { result } = codegen(`let x = html\`<div>\${html\`<span>inner</span>\`}</div>\`;`);
|
|
155
|
+
|
|
156
|
+
// Outer template + inner template
|
|
157
|
+
expect(result.templates.size).toBe(2);
|
|
158
|
+
expect(result.prepend).toHaveLength(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('rewrites nested html`` in expressions', () => {
|
|
162
|
+
let { result } = codegen(`let x = html\`<div>\${condition ? html\`<span>a</span>\` : html\`<em>b</em>\`}</div>\`;`);
|
|
163
|
+
|
|
164
|
+
// Outer + 2 inner templates
|
|
165
|
+
expect(result.templates.size).toBe(3);
|
|
166
|
+
expect(result.prepend).toHaveLength(3);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('generateCode - html.reactive()', () => {
|
|
171
|
+
it('generates ArraySlot for reactive call in node slot', () => {
|
|
172
|
+
let { result } = codegen(`let x = html\`<div>\${html.reactive(items, (item) => html\`<span>\${item}</span>\`)}</div>\`;`);
|
|
173
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
174
|
+
|
|
175
|
+
expect(code).toContain(`${NAMESPACE}.ArraySlot`);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('generateCode - mixed slots', () => {
|
|
180
|
+
it('handles attribute + node slots on same element', () => {
|
|
181
|
+
let { result } = codegen(`let x = html\`<div class=\${cls}>\${content}</div>\`;`);
|
|
182
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
183
|
+
|
|
184
|
+
expect(code).toContain(`${NAMESPACE}.setList(`);
|
|
185
|
+
expect(code).toContain(`${NAMESPACE}.slot(`);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('handles multiple attribute slots on same element', () => {
|
|
189
|
+
let { result } = codegen(`let x = html\`<div id=\${id} class=\${cls}>text</div>\`;`);
|
|
190
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
191
|
+
|
|
192
|
+
expect(code).toContain(`${NAMESPACE}.setProperty(`);
|
|
193
|
+
expect(code).toContain(`${NAMESPACE}.setList(`);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('generateCode - template deduplication', () => {
|
|
198
|
+
it('deduplicates identical template literals', () => {
|
|
199
|
+
let { result } = codegen(`let a = html\`<div>same</div>\`; let b = html\`<div>same</div>\`;`);
|
|
200
|
+
|
|
201
|
+
// Same HTML should produce only one template factory
|
|
202
|
+
expect(result.templates.size).toBe(1);
|
|
203
|
+
expect(result.prepend).toHaveLength(1);
|
|
204
|
+
expect(result.replacements).toHaveLength(2);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('creates separate templates for different HTML', () => {
|
|
208
|
+
let { result } = codegen(`let a = html\`<div>one</div>\`; let b = html\`<span>two</span>\`;`);
|
|
209
|
+
|
|
210
|
+
expect(result.templates.size).toBe(2);
|
|
211
|
+
expect(result.prepend).toHaveLength(2);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('generateCode - expression type detection', () => {
|
|
216
|
+
it('generates EffectSlot for function expression node', () => {
|
|
217
|
+
let { result } = codegen(`let x = html\`<div>\${function() { return 1; }}</div>\`;`);
|
|
218
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
219
|
+
|
|
220
|
+
expect(code).toContain(`${NAMESPACE}.EffectSlot`);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('generates slot() for unknown identifier', () => {
|
|
224
|
+
let { result } = codegen(`let x = html\`<div>\${someVar}</div>\`;`);
|
|
225
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
226
|
+
|
|
227
|
+
expect(code).toContain(`${NAMESPACE}.slot(`);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('generates text() for numeric literal', () => {
|
|
231
|
+
let { result } = codegen(`let x = html\`<div>\${42}</div>\`;`);
|
|
232
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
233
|
+
|
|
234
|
+
expect(code).toContain(`${NAMESPACE}.text(`);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('rewriteExpression', () => {
|
|
239
|
+
it('rewrites nested html template in expression', () => {
|
|
240
|
+
let source = `let x = html\`<div>\${html\`<span>inner</span>\`}</div>\`;`,
|
|
241
|
+
sourceFile = ts.createSourceFile('test.ts', source, ts.ScriptTarget.Latest, true),
|
|
242
|
+
templates = findHtmlTemplates(sourceFile);
|
|
243
|
+
|
|
244
|
+
let ctx = {
|
|
245
|
+
sourceFile,
|
|
246
|
+
templates: new Map<string, string>()
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
let expr = templates[0].expressions[0],
|
|
250
|
+
rewritten = rewriteExpression(ctx, expr);
|
|
251
|
+
|
|
252
|
+
// Nested html`` should be rewritten to template factory call
|
|
253
|
+
expect(rewritten).not.toContain('html`');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('prints plain expression as-is', () => {
|
|
257
|
+
let source = `let x = html\`<div>\${value}</div>\`;`,
|
|
258
|
+
sourceFile = ts.createSourceFile('test.ts', source, ts.ScriptTarget.Latest, true),
|
|
259
|
+
templates = findHtmlTemplates(sourceFile);
|
|
260
|
+
|
|
261
|
+
let ctx = {
|
|
262
|
+
sourceFile,
|
|
263
|
+
templates: new Map<string, string>()
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
let expr = templates[0].expressions[0],
|
|
267
|
+
rewritten = rewriteExpression(ctx, expr);
|
|
268
|
+
|
|
269
|
+
expect(rewritten).toBe('value');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('generateCode - arrow function body optimization', () => {
|
|
274
|
+
it('generates template ID directly for parameterless arrow with static body', () => {
|
|
275
|
+
let { result } = codegen(`let fn = () => html\`<div>static</div>\`;`);
|
|
276
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
277
|
+
|
|
278
|
+
// Should be just the template ID (no () call, no IIFE)
|
|
279
|
+
expect(code).not.toContain('()');
|
|
280
|
+
expect(code).not.toContain('return');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('generates IIFE for arrow with slots', () => {
|
|
284
|
+
let { result } = codegen(`let fn = () => html\`<div>\${value}</div>\`;`);
|
|
285
|
+
let code = result.replacements[0].generate(ts.createSourceFile('', '', ts.ScriptTarget.Latest));
|
|
286
|
+
|
|
287
|
+
// Arrow body with slots uses block syntax
|
|
288
|
+
expect(code).toContain('{');
|
|
289
|
+
expect(code).toContain('return');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ts } from '@esportsplus/typescript';
|
|
3
|
+
import { ast } from '@esportsplus/typescript/compiler';
|
|
4
|
+
import { generateCode, rewriteExpression } from '../../src/compiler/codegen';
|
|
5
|
+
import { NAMESPACE } from '../../src/compiler/constants';
|
|
6
|
+
import { findHtmlTemplates, findReactiveCalls } from '../../src/compiler/ts-parser';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
type TransformResult = {
|
|
10
|
+
imports: string[];
|
|
11
|
+
output: string;
|
|
12
|
+
prepend: string[];
|
|
13
|
+
replacements: { code: string; end: number; start: number }[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
function pipeline(source: string): TransformResult {
|
|
18
|
+
let callRanges: { end: number; start: number }[] = [],
|
|
19
|
+
callTemplates = new Map<string, string>(),
|
|
20
|
+
imports: string[] = [],
|
|
21
|
+
prepend: string[] = [],
|
|
22
|
+
replacements: { code: string; end: number; start: number }[] = [],
|
|
23
|
+
sourceFile = ts.createSourceFile('test.ts', source, ts.ScriptTarget.Latest, true),
|
|
24
|
+
templates = findHtmlTemplates(sourceFile);
|
|
25
|
+
|
|
26
|
+
let ranges: { end: number; start: number }[] = [];
|
|
27
|
+
|
|
28
|
+
for (let i = 0, n = templates.length; i < n; i++) {
|
|
29
|
+
ranges.push({
|
|
30
|
+
end: templates[i].end,
|
|
31
|
+
start: templates[i].start
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let calls = findReactiveCalls(sourceFile);
|
|
36
|
+
|
|
37
|
+
for (let i = 0, n = calls.length; i < n; i++) {
|
|
38
|
+
let call = calls[i];
|
|
39
|
+
|
|
40
|
+
if (ast.inRange(ranges, call.start, call.end)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
callRanges.push({
|
|
45
|
+
end: call.callbackArg.end,
|
|
46
|
+
start: call.callbackArg.getStart(sourceFile)
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
let rewrittenCallback = rewriteExpression({
|
|
50
|
+
sourceFile,
|
|
51
|
+
templates: callTemplates
|
|
52
|
+
}, call.callbackArg);
|
|
53
|
+
|
|
54
|
+
replacements.push({
|
|
55
|
+
code: `new ${NAMESPACE}.ArraySlot(\n ${call.arrayArg.getText(sourceFile)},\n ${rewrittenCallback}\n )`,
|
|
56
|
+
end: call.node.end,
|
|
57
|
+
start: call.node.getStart(sourceFile)
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (let [html, id] of callTemplates) {
|
|
62
|
+
prepend.push(`const ${id} = ${NAMESPACE}.template(\`${html}\`);`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (templates.length > 0) {
|
|
66
|
+
let result = generateCode(templates, sourceFile, undefined, callRanges);
|
|
67
|
+
|
|
68
|
+
prepend.push(...result.prepend);
|
|
69
|
+
|
|
70
|
+
for (let i = 0, n = result.replacements.length; i < n; i++) {
|
|
71
|
+
let r = result.replacements[i];
|
|
72
|
+
|
|
73
|
+
replacements.push({
|
|
74
|
+
code: r.generate(sourceFile),
|
|
75
|
+
end: r.node.end,
|
|
76
|
+
start: r.node.getStart(sourceFile)
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
imports.push('html');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Apply replacements to source in reverse order to preserve positions
|
|
84
|
+
let output = source;
|
|
85
|
+
|
|
86
|
+
replacements.sort((a, b) => b.start - a.start);
|
|
87
|
+
|
|
88
|
+
for (let i = 0, n = replacements.length; i < n; i++) {
|
|
89
|
+
let r = replacements[i];
|
|
90
|
+
|
|
91
|
+
output = output.slice(0, r.start) + r.code + output.slice(r.end);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Prepend template factories
|
|
95
|
+
if (prepend.length > 0) {
|
|
96
|
+
output = prepend.join('\n') + '\n' + output;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { imports, output, prepend, replacements };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
describe('compiler/integration', () => {
|
|
104
|
+
describe('static template - full pipeline', () => {
|
|
105
|
+
it('produces correct replacement code for static template', () => {
|
|
106
|
+
let source = `import { html } from '@esportsplus/template';\nlet el = html\`<div>hello</div>\`;`,
|
|
107
|
+
{ imports, output, prepend } = pipeline(source);
|
|
108
|
+
|
|
109
|
+
expect(imports).toContain('html');
|
|
110
|
+
expect(prepend.length).toBe(1);
|
|
111
|
+
expect(prepend[0]).toContain(`${NAMESPACE}.template(\`<div>hello</div>\`)`);
|
|
112
|
+
expect(output).not.toContain('html`');
|
|
113
|
+
expect(output).toContain(`${NAMESPACE}.template(`);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('template with expressions - full pipeline', () => {
|
|
118
|
+
it('produces slot bindings for expressions', () => {
|
|
119
|
+
let source = `import { html } from '@esportsplus/template';\nlet el = html\`<div>\${value}</div>\`;`,
|
|
120
|
+
{ output, prepend } = pipeline(source);
|
|
121
|
+
|
|
122
|
+
expect(prepend.length).toBe(1);
|
|
123
|
+
expect(output).toContain(`${NAMESPACE}.slot(`);
|
|
124
|
+
expect(output).toContain('value');
|
|
125
|
+
expect(output).not.toContain('html`');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('template with events - full pipeline', () => {
|
|
130
|
+
it('removes events from HTML and generates delegation code', () => {
|
|
131
|
+
let source = `import { html } from '@esportsplus/template';\nlet el = html\`<button onclick=\${handler}>click</button>\`;`,
|
|
132
|
+
{ output, prepend } = pipeline(source);
|
|
133
|
+
|
|
134
|
+
// Event attributes stripped from template HTML
|
|
135
|
+
expect(prepend.length).toBe(1);
|
|
136
|
+
expect(prepend[0]).not.toContain('onclick');
|
|
137
|
+
expect(prepend[0]).toContain(`${NAMESPACE}.template(`);
|
|
138
|
+
|
|
139
|
+
// Delegation code generated
|
|
140
|
+
expect(output).toContain(`${NAMESPACE}.delegate(`);
|
|
141
|
+
expect(output).toContain("'click'");
|
|
142
|
+
expect(output).toContain('handler');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('multiple templates in one file - full pipeline', () => {
|
|
147
|
+
it('compiles all templates correctly', () => {
|
|
148
|
+
let source = [
|
|
149
|
+
`import { html } from '@esportsplus/template';`,
|
|
150
|
+
`let a = html\`<div>first</div>\`;`,
|
|
151
|
+
`let b = html\`<span>second</span>\`;`
|
|
152
|
+
].join('\n'),
|
|
153
|
+
{ output, prepend, replacements } = pipeline(source);
|
|
154
|
+
|
|
155
|
+
// Two different templates = two factories
|
|
156
|
+
expect(prepend.length).toBe(2);
|
|
157
|
+
expect(replacements.length).toBe(2);
|
|
158
|
+
|
|
159
|
+
// Both rewritten - no html`` left
|
|
160
|
+
expect(output).not.toContain('html`');
|
|
161
|
+
|
|
162
|
+
// Both template factories present
|
|
163
|
+
let hasFirst = prepend.some(p => p.includes('<div>first</div>')),
|
|
164
|
+
hasSecond = prepend.some(p => p.includes('<span>second</span>'));
|
|
165
|
+
|
|
166
|
+
expect(hasFirst).toBe(true);
|
|
167
|
+
expect(hasSecond).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('nested templates - full pipeline', () => {
|
|
172
|
+
it('compiles outer and inner templates', () => {
|
|
173
|
+
let source = `import { html } from '@esportsplus/template';\nlet el = html\`<div>\${html\`<span>inner</span>\`}</div>\`;`,
|
|
174
|
+
{ output, prepend } = pipeline(source);
|
|
175
|
+
|
|
176
|
+
// Outer + inner = 2 template factories
|
|
177
|
+
expect(prepend.length).toBe(2);
|
|
178
|
+
|
|
179
|
+
let hasOuter = prepend.some(p => p.includes('<div>')),
|
|
180
|
+
hasInner = prepend.some(p => p.includes('<span>inner</span>'));
|
|
181
|
+
|
|
182
|
+
expect(hasOuter).toBe(true);
|
|
183
|
+
expect(hasInner).toBe(true);
|
|
184
|
+
|
|
185
|
+
// Nested html`` rewritten
|
|
186
|
+
expect(output).not.toContain('html`');
|
|
187
|
+
expect(output).toContain('insertBefore');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('html.reactive() - full pipeline', () => {
|
|
192
|
+
it('generates ArraySlot binding', () => {
|
|
193
|
+
let source = `import { html } from '@esportsplus/template';\nlet el = html.reactive(items, (item) => html\`<li>\${item}</li>\`);`,
|
|
194
|
+
{ output, prepend } = pipeline(source);
|
|
195
|
+
|
|
196
|
+
// Template factory for callback template
|
|
197
|
+
expect(prepend.length).toBeGreaterThanOrEqual(1);
|
|
198
|
+
|
|
199
|
+
let hasLi = prepend.some(p => p.includes('<li>'));
|
|
200
|
+
|
|
201
|
+
expect(hasLi).toBe(true);
|
|
202
|
+
|
|
203
|
+
// ArraySlot generated
|
|
204
|
+
expect(output).toContain(`${NAMESPACE}.ArraySlot`);
|
|
205
|
+
expect(output).toContain('items');
|
|
206
|
+
expect(output).not.toContain('html.reactive');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('no html templates - full pipeline', () => {
|
|
211
|
+
it('produces no changes for source without templates', () => {
|
|
212
|
+
let source = `let x = 1;\nlet y = 'hello';\nconsole.log(x + y);`,
|
|
213
|
+
{ imports, output, prepend, replacements } = pipeline(source);
|
|
214
|
+
|
|
215
|
+
expect(imports).toHaveLength(0);
|
|
216
|
+
expect(prepend).toHaveLength(0);
|
|
217
|
+
expect(replacements).toHaveLength(0);
|
|
218
|
+
expect(output).toBe(source);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('mixed content - full pipeline', () => {
|
|
223
|
+
it('only rewrites templates, preserves surrounding code', () => {
|
|
224
|
+
let source = [
|
|
225
|
+
`import { html } from '@esportsplus/template';`,
|
|
226
|
+
`import { signal } from '@esportsplus/reactivity';`,
|
|
227
|
+
``,
|
|
228
|
+
`let count = signal(0);`,
|
|
229
|
+
`let label = 'Counter';`,
|
|
230
|
+
``,
|
|
231
|
+
`let el = html\`<div class=\${cls}>\${() => count()}</div>\`;`,
|
|
232
|
+
``,
|
|
233
|
+
`console.log('done');`
|
|
234
|
+
].join('\n'),
|
|
235
|
+
{ output, prepend } = pipeline(source);
|
|
236
|
+
|
|
237
|
+
// Template factory generated
|
|
238
|
+
expect(prepend.length).toBe(1);
|
|
239
|
+
|
|
240
|
+
// Non-template code preserved
|
|
241
|
+
expect(output).toContain(`import { signal } from '@esportsplus/reactivity';`);
|
|
242
|
+
expect(output).toContain(`let count = signal(0);`);
|
|
243
|
+
expect(output).toContain(`let label = 'Counter';`);
|
|
244
|
+
expect(output).toContain(`console.log('done');`);
|
|
245
|
+
|
|
246
|
+
// Template rewritten
|
|
247
|
+
expect(output).not.toContain('html`');
|
|
248
|
+
expect(output).toContain(`${NAMESPACE}.setList(`);
|
|
249
|
+
expect(output).toContain(`${NAMESPACE}.EffectSlot`);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|