@esportsplus/template 0.16.14 → 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.
Files changed (40) hide show
  1. package/bench/runtime.bench.ts +207 -0
  2. package/build/attributes.js +4 -1
  3. package/build/slot/array.js +50 -4
  4. package/build/slot/render.js +1 -2
  5. package/build/utilities.d.ts +2 -1
  6. package/build/utilities.js +2 -1
  7. package/package.json +2 -1
  8. package/src/attributes.ts +4 -1
  9. package/src/slot/array.ts +76 -4
  10. package/src/slot/render.ts +1 -4
  11. package/src/utilities.ts +3 -1
  12. package/storage/feature-research-2026-03-24.md +475 -0
  13. package/test-output.txt +0 -0
  14. package/{test/attributes.test.ts → tests/attributes.ts} +3 -2
  15. package/tests/compiler/codegen.ts +292 -0
  16. package/tests/compiler/integration.ts +252 -0
  17. package/tests/compiler/ts-parser.ts +160 -0
  18. package/{test/constants.test.ts → tests/constants.ts} +5 -1
  19. package/tests/event/onconnect.ts +147 -0
  20. package/tests/event/onresize.ts +187 -0
  21. package/tests/event/ontick.ts +273 -0
  22. package/{test/slot/array.test.ts → tests/slot/array.ts} +274 -0
  23. package/vitest.bench.config.ts +18 -0
  24. package/vitest.config.ts +1 -1
  25. package/storage/compiler-architecture-2026-01-13.md +0 -420
  26. /package/{test → examples}/index.ts +0 -0
  27. /package/{test → examples}/vite.config.ts +0 -0
  28. /package/{test/compiler/parser.test.ts → tests/compiler/parser.ts} +0 -0
  29. /package/{test/compiler/ts-analyzer.test.ts → tests/compiler/ts-analyzer.ts} +0 -0
  30. /package/{test → tests}/dist/test.js +0 -0
  31. /package/{test → tests}/dist/test.js.map +0 -0
  32. /package/{test/event/index.test.ts → tests/event/index.ts} +0 -0
  33. /package/{test/html.test.ts → tests/html.ts} +0 -0
  34. /package/{test/render.test.ts → tests/render.ts} +0 -0
  35. /package/{test/slot/cleanup.test.ts → tests/slot/cleanup.ts} +0 -0
  36. /package/{test/slot/effect.test.ts → tests/slot/effect.ts} +0 -0
  37. /package/{test/slot/index.test.ts → tests/slot/index.ts} +0 -0
  38. /package/{test/slot/render.test.ts → tests/slot/render.ts} +0 -0
  39. /package/{test/svg.test.ts → tests/svg.ts} +0 -0
  40. /package/{test/utilities.test.ts → tests/utilities.ts} +0 -0
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ts } from '@esportsplus/typescript';
3
+ import { extractTemplateParts, findHtmlTemplates, findReactiveCalls } from '../../src/compiler/ts-parser';
4
+
5
+
6
+ function createSourceFile(code: string): ts.SourceFile {
7
+ return ts.createSourceFile('test.ts', code, ts.ScriptTarget.Latest, true);
8
+ }
9
+
10
+
11
+ describe('compiler/ts-parser', () => {
12
+ describe('extractTemplateParts', () => {
13
+ it('NoSubstitutionTemplateLiteral → single literal, empty expressions', () => {
14
+ let sourceFile = createSourceFile('let x = `hello world`;');
15
+ let statement = sourceFile.statements[0] as ts.VariableStatement,
16
+ declaration = statement.declarationList.declarations[0],
17
+ template = declaration.initializer! as ts.NoSubstitutionTemplateLiteral;
18
+ let result = extractTemplateParts(template);
19
+
20
+ expect(result.literals).toEqual(['hello world']);
21
+ expect(result.expressions).toEqual([]);
22
+ });
23
+
24
+ it('TemplateExpression → correct literals + expressions', () => {
25
+ let sourceFile = createSourceFile('let x = `a${1}b${2}c`;');
26
+ let statement = sourceFile.statements[0] as ts.VariableStatement,
27
+ declaration = statement.declarationList.declarations[0],
28
+ template = declaration.initializer! as ts.TemplateExpression;
29
+ let result = extractTemplateParts(template);
30
+
31
+ expect(result.literals).toEqual(['a', 'b', 'c']);
32
+ expect(result.expressions.length).toBe(2);
33
+ });
34
+ });
35
+
36
+ describe('findHtmlTemplates', () => {
37
+ it('single html`` call → returns 1 TemplateInfo with correct literals/expressions', () => {
38
+ let source = `import { html } from '@esportsplus/template';\nlet x = html\`<div>\${value}</div>\`;`;
39
+ let sourceFile = createSourceFile(source);
40
+ let templates = findHtmlTemplates(sourceFile);
41
+
42
+ expect(templates.length).toBe(1);
43
+ expect(templates[0].literals).toEqual(['<div>', '</div>']);
44
+ expect(templates[0].expressions.length).toBe(1);
45
+ expect(templates[0].depth).toBe(0);
46
+ });
47
+
48
+ it('template with no expressions → single literal, no expressions', () => {
49
+ let source = `import { html } from '@esportsplus/template';\nlet x = html\`<div>hello</div>\`;`;
50
+ let sourceFile = createSourceFile(source);
51
+ let templates = findHtmlTemplates(sourceFile);
52
+
53
+ expect(templates.length).toBe(1);
54
+ expect(templates[0].literals).toEqual(['<div>hello</div>']);
55
+ expect(templates[0].expressions).toEqual([]);
56
+ });
57
+
58
+ it('no html import → returns empty array', () => {
59
+ let source = `let x = html\`<div>hello</div>\`;`;
60
+ let sourceFile = createSourceFile(source);
61
+ let templates = findHtmlTemplates(sourceFile);
62
+
63
+ // Without type checker, tag name match alone is sufficient
64
+ expect(templates.length).toBe(1);
65
+ });
66
+
67
+ it('no html tagged templates at all → returns empty array', () => {
68
+ let source = `let x = 'hello';`;
69
+ let sourceFile = createSourceFile(source);
70
+ let templates = findHtmlTemplates(sourceFile);
71
+
72
+ expect(templates.length).toBe(0);
73
+ });
74
+
75
+ it('multiple html`` calls in same file → all found', () => {
76
+ let source = [
77
+ `import { html } from '@esportsplus/template';`,
78
+ `let a = html\`<div>one</div>\`;`,
79
+ `let b = html\`<span>two</span>\`;`,
80
+ `let c = html\`<p>\${x}</p>\`;`
81
+ ].join('\n');
82
+ let sourceFile = createSourceFile(source);
83
+ let templates = findHtmlTemplates(sourceFile);
84
+
85
+ expect(templates.length).toBe(3);
86
+ });
87
+
88
+ it('html`` inside function body → found correctly', () => {
89
+ let source = [
90
+ `import { html } from '@esportsplus/template';`,
91
+ `function render() {`,
92
+ ` return html\`<div>\${value}</div>\`;`,
93
+ `}`
94
+ ].join('\n');
95
+ let sourceFile = createSourceFile(source);
96
+ let templates = findHtmlTemplates(sourceFile);
97
+
98
+ expect(templates.length).toBe(1);
99
+ expect(templates[0].depth).toBe(1);
100
+ expect(templates[0].expressions.length).toBe(1);
101
+ });
102
+
103
+ it('nested html`` → returns both with correct depths', () => {
104
+ let source = [
105
+ `import { html } from '@esportsplus/template';`,
106
+ `let outer = html\`<div>\${() => html\`<span>inner</span>\`}</div>\`;`
107
+ ].join('\n');
108
+ let sourceFile = createSourceFile(source);
109
+ let templates = findHtmlTemplates(sourceFile);
110
+
111
+ expect(templates.length).toBe(2);
112
+ // Sorted deepest first
113
+ expect(templates[0].depth).toBeGreaterThanOrEqual(templates[1].depth);
114
+ });
115
+
116
+ it('depth ordering: deepest first, then by position ascending', () => {
117
+ let source = [
118
+ `import { html } from '@esportsplus/template';`,
119
+ `let a = html\`<div>\${() => html\`<span>deep1</span>\`}</div>\`;`,
120
+ `let b = html\`<p>shallow</p>\`;`
121
+ ].join('\n');
122
+ let sourceFile = createSourceFile(source);
123
+ let templates = findHtmlTemplates(sourceFile);
124
+
125
+ expect(templates.length).toBe(3);
126
+ // First template should be deepest (the inner one from arrow fn)
127
+ expect(templates[0].depth).toBeGreaterThan(templates[1].depth);
128
+ // Same-depth items sorted by position ascending
129
+ let sameDepthtemplates = templates.filter(t => t.depth === 0);
130
+
131
+ for (let i = 1, n = sameDepthtemplates.length; i < n; i++) {
132
+ expect(sameDepthtemplates[i].start).toBeGreaterThan(sameDepthtemplates[i - 1].start);
133
+ }
134
+ });
135
+ });
136
+
137
+ describe('findReactiveCalls', () => {
138
+ it('html.reactive() → returns ReactiveCallInfo with array + callback args', () => {
139
+ let source = [
140
+ `import { html } from '@esportsplus/template';`,
141
+ `let x = html.reactive(items, (item) => html\`<li>\${item}</li>\`);`
142
+ ].join('\n');
143
+ let sourceFile = createSourceFile(source);
144
+ let calls = findReactiveCalls(sourceFile);
145
+
146
+ expect(calls.length).toBe(1);
147
+ expect(calls[0].arrayArg).toBeDefined();
148
+ expect(calls[0].callbackArg).toBeDefined();
149
+ expect(calls[0].start).toBeLessThan(calls[0].end);
150
+ });
151
+
152
+ it('no reactive calls → empty array', () => {
153
+ let source = `import { html } from '@esportsplus/template';\nlet x = html\`<div>hello</div>\`;`;
154
+ let sourceFile = createSourceFile(source);
155
+ let calls = findReactiveCalls(sourceFile);
156
+
157
+ expect(calls.length).toBe(0);
158
+ });
159
+ });
160
+ });
@@ -77,6 +77,11 @@ describe('constants', () => {
77
77
  expect(DIRECT_ATTACH_EVENTS.has('onload')).toBe(true);
78
78
  });
79
79
 
80
+ it('contains mouse enter/leave events', () => {
81
+ expect(DIRECT_ATTACH_EVENTS.has('onmouseenter')).toBe(true);
82
+ expect(DIRECT_ATTACH_EVENTS.has('onmouseleave')).toBe(true);
83
+ });
84
+
80
85
  it('contains scroll event', () => {
81
86
  expect(DIRECT_ATTACH_EVENTS.has('onscroll')).toBe(true);
82
87
  });
@@ -84,7 +89,6 @@ describe('constants', () => {
84
89
  it('does not contain delegatable events', () => {
85
90
  expect(DIRECT_ATTACH_EVENTS.has('onclick')).toBe(false);
86
91
  expect(DIRECT_ATTACH_EVENTS.has('onkeydown')).toBe(false);
87
- expect(DIRECT_ATTACH_EVENTS.has('onmouseenter')).toBe(false);
88
92
  });
89
93
  });
90
94
 
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { Element } from '../../src/types';
3
+
4
+
5
+ let callbacks: VoidFunction[] = [];
6
+
7
+ vi.mock('../../src/utilities', async (importOriginal) => {
8
+ let original = await importOriginal<typeof import('../../src/utilities')>();
9
+
10
+ return {
11
+ ...original,
12
+ raf: (cb: VoidFunction) => { callbacks.push(cb); }
13
+ };
14
+ });
15
+
16
+
17
+ let { default: onconnect } = await import('../../src/event/onconnect');
18
+
19
+
20
+ describe('event/onconnect', () => {
21
+ let container: HTMLElement;
22
+
23
+ beforeEach(() => {
24
+ callbacks = [];
25
+ container = document.createElement('div');
26
+ document.body.appendChild(container);
27
+ });
28
+
29
+ afterEach(() => {
30
+ // Drain the RAF loop so tasks.running resets between tests
31
+ for (let i = 0; i < 65; i++) {
32
+ advanceFrame();
33
+ }
34
+
35
+ callbacks = [];
36
+ document.body.removeChild(container);
37
+ });
38
+
39
+ function advanceFrame() {
40
+ let current = callbacks.slice();
41
+
42
+ callbacks = [];
43
+
44
+ for (let i = 0, n = current.length; i < n; i++) {
45
+ current[i]();
46
+ }
47
+ }
48
+
49
+ it('calls listener on first tick when element is already connected', () => {
50
+ let called = false,
51
+ element = document.createElement('div') as unknown as Element;
52
+
53
+ container.appendChild(element as unknown as Node);
54
+
55
+ onconnect(element, () => { called = true; });
56
+ advanceFrame();
57
+
58
+ expect(called).toBe(true);
59
+ });
60
+
61
+ it('calls listener after element connects on tick N', () => {
62
+ let called = false,
63
+ element = document.createElement('div') as unknown as Element;
64
+
65
+ onconnect(element, () => { called = true; });
66
+
67
+ // Advance 10 frames without connecting
68
+ for (let i = 0; i < 10; i++) {
69
+ advanceFrame();
70
+ }
71
+
72
+ expect(called).toBe(false);
73
+
74
+ // Connect element
75
+ container.appendChild(element as unknown as Node);
76
+ advanceFrame();
77
+
78
+ expect(called).toBe(true);
79
+ });
80
+
81
+ it('never calls listener if element does not connect within 60 ticks', () => {
82
+ let called = false,
83
+ element = document.createElement('div') as unknown as Element;
84
+
85
+ onconnect(element, () => { called = true; });
86
+
87
+ // Advance 62 frames — retry decrements from 60, at 0 it removes
88
+ for (let i = 0; i < 62; i++) {
89
+ advanceFrame();
90
+ }
91
+
92
+ expect(called).toBe(false);
93
+
94
+ // Verify polling stopped — connecting now should have no effect
95
+ container.appendChild(element as unknown as Node);
96
+ advanceFrame();
97
+ advanceFrame();
98
+
99
+ expect(called).toBe(false);
100
+ });
101
+
102
+ it('passes element as argument to listener', () => {
103
+ let element = document.createElement('div') as unknown as Element,
104
+ received: unknown = null;
105
+
106
+ container.appendChild(element as unknown as Node);
107
+
108
+ onconnect(element, (el) => { received = el; });
109
+ advanceFrame();
110
+
111
+ expect(received).toBe(element);
112
+ });
113
+
114
+ it('calls listener only once', () => {
115
+ let count = 0,
116
+ element = document.createElement('div') as unknown as Element;
117
+
118
+ container.appendChild(element as unknown as Node);
119
+
120
+ onconnect(element, () => { count++; });
121
+
122
+ advanceFrame();
123
+ advanceFrame();
124
+ advanceFrame();
125
+
126
+ expect(count).toBe(1);
127
+ });
128
+
129
+ it('stops polling after listener is called', () => {
130
+ let element = document.createElement('div') as unknown as Element,
131
+ listenerCalled = false;
132
+
133
+ container.appendChild(element as unknown as Node);
134
+
135
+ onconnect(element, () => { listenerCalled = true; });
136
+ advanceFrame();
137
+
138
+ expect(listenerCalled).toBe(true);
139
+
140
+ // After listener fires and task is removed, further frames should not re-add
141
+ advanceFrame();
142
+ advanceFrame();
143
+
144
+ // If no tasks remain, callbacks should be empty after the last frame processes
145
+ expect(callbacks.length).toBe(0);
146
+ });
147
+ });
@@ -0,0 +1,187 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { Element } from '../../src/types';
3
+
4
+
5
+ let cleanups: VoidFunction[] = [];
6
+
7
+ vi.mock('@esportsplus/reactivity', async (importOriginal) => {
8
+ let original = await importOriginal<typeof import('@esportsplus/reactivity')>();
9
+
10
+ return {
11
+ ...original,
12
+ onCleanup: (fn: VoidFunction) => { cleanups.push(fn); return fn; }
13
+ };
14
+ });
15
+
16
+
17
+ let { default: onresize } = await import('../../src/event/onresize');
18
+
19
+
20
+ function createElement(connected = true): Element {
21
+ let element = document.createElement('div') as unknown as Element;
22
+
23
+ Object.defineProperty(element, 'isConnected', { get: () => connected, configurable: true });
24
+
25
+ return element;
26
+ }
27
+
28
+ function fireResize() {
29
+ window.dispatchEvent(new Event('resize'));
30
+ }
31
+
32
+
33
+ describe('event/onresize', () => {
34
+ let addSpy: ReturnType<typeof vi.spyOn>;
35
+ let removeSpy: ReturnType<typeof vi.spyOn>;
36
+
37
+ beforeEach(() => {
38
+ cleanups = [];
39
+ addSpy = vi.spyOn(window, 'addEventListener');
40
+ removeSpy = vi.spyOn(window, 'removeEventListener');
41
+ });
42
+
43
+ afterEach(() => {
44
+ // Run all captured cleanups to reset module state
45
+ for (let i = 0, n = cleanups.length; i < n; i++) {
46
+ cleanups[i]();
47
+ }
48
+
49
+ cleanups = [];
50
+ addSpy.mockRestore();
51
+ removeSpy.mockRestore();
52
+ });
53
+
54
+ it('single element receives resize callback', () => {
55
+ let called = false,
56
+ element = createElement();
57
+
58
+ onresize(element, () => { called = true; });
59
+ fireResize();
60
+
61
+ expect(called).toBe(true);
62
+ });
63
+
64
+ it('multiple elements all receive resize callback', () => {
65
+ let a = 0,
66
+ b = 0,
67
+ elementA = createElement(),
68
+ elementB = createElement();
69
+
70
+ onresize(elementA, () => { a++; });
71
+ onresize(elementB, () => { b++; });
72
+ fireResize();
73
+
74
+ expect(a).toBe(1);
75
+ expect(b).toBe(1);
76
+ });
77
+
78
+ it('disconnected element is auto-removed during next resize', () => {
79
+ let connected = true,
80
+ count = 0,
81
+ element = document.createElement('div') as unknown as Element;
82
+
83
+ Object.defineProperty(element, 'isConnected', {
84
+ get: () => connected,
85
+ configurable: true
86
+ });
87
+
88
+ onresize(element, () => { count++; });
89
+ fireResize();
90
+
91
+ expect(count).toBe(1);
92
+
93
+ connected = false;
94
+ fireResize();
95
+
96
+ // Should not have incremented — disconnected elements skipped
97
+ expect(count).toBe(1);
98
+
99
+ connected = true;
100
+ fireResize();
101
+
102
+ // Already removed from listeners map, should stay at 1
103
+ expect(count).toBe(1);
104
+ });
105
+
106
+ it('dedup: only one window resize listener registered', () => {
107
+ let elementA = createElement(),
108
+ elementB = createElement();
109
+
110
+ onresize(elementA, () => {});
111
+ onresize(elementB, () => {});
112
+
113
+ let resizeCalls = addSpy.mock.calls.filter(
114
+ (args) => args[0] === 'resize'
115
+ );
116
+
117
+ expect(resizeCalls.length).toBe(1);
118
+ });
119
+
120
+ it('listener receives element as argument', () => {
121
+ let element = createElement(),
122
+ received: unknown = null;
123
+
124
+ onresize(element, (el) => { received = el; });
125
+ fireResize();
126
+
127
+ expect(received).toBe(element);
128
+ });
129
+
130
+ it('onCleanup removes element from listeners', () => {
131
+ let count = 0,
132
+ element = createElement();
133
+
134
+ onresize(element, () => { count++; });
135
+ fireResize();
136
+
137
+ expect(count).toBe(1);
138
+
139
+ // Simulate reactive cleanup
140
+ for (let i = 0, n = cleanups.length; i < n; i++) {
141
+ cleanups[i]();
142
+ }
143
+
144
+ cleanups = [];
145
+ fireResize();
146
+
147
+ expect(count).toBe(1);
148
+ });
149
+
150
+ it('window listener removed when all elements gone', () => {
151
+ let element = createElement(false);
152
+
153
+ onresize(element, () => {});
154
+ fireResize();
155
+
156
+ let removeCalls = removeSpy.mock.calls.filter(
157
+ (args) => args[0] === 'resize'
158
+ );
159
+
160
+ expect(removeCalls.length).toBe(1);
161
+ });
162
+
163
+ it('re-registers window listener after all removed and new element added', () => {
164
+ let element = createElement(false);
165
+
166
+ onresize(element, () => {});
167
+ fireResize();
168
+
169
+ // Window listener should be removed
170
+ let removeCalls = removeSpy.mock.calls.filter(
171
+ (args) => args[0] === 'resize'
172
+ );
173
+
174
+ expect(removeCalls.length).toBe(1);
175
+
176
+ // Add new element — should re-register
177
+ let element2 = createElement();
178
+
179
+ onresize(element2, () => {});
180
+
181
+ let addCalls = addSpy.mock.calls.filter(
182
+ (args) => args[0] === 'resize'
183
+ );
184
+
185
+ expect(addCalls.length).toBe(2);
186
+ });
187
+ });