@esportsplus/template 0.37.0 → 0.38.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.
@@ -1,10 +1,12 @@
1
+ import type { ReplacementIntent } from '@esportsplus/typescript/compiler';
1
2
  import { ts } from '@esportsplus/typescript';
2
- import type { ReactiveCallInfo, TemplateInfo } from './ts-parser.js';
3
+ import type { TemplateInfo } from './ts-parser.js';
3
4
  type CodegenResult = {
4
- changed: boolean;
5
- code: string;
5
+ imports: Map<string, string>;
6
+ prepend: string[];
7
+ replacements: ReplacementIntent[];
8
+ templates: Map<string, string>;
6
9
  };
7
- declare const generateCode: (templates: TemplateInfo[], originalCode: string, sourceFile: ts.SourceFile, checker?: ts.TypeChecker, existingAliases?: Map<string, string>) => CodegenResult;
8
- declare const generateReactiveInlining: (calls: ReactiveCallInfo[], code: string, sourceFile: ts.SourceFile, arraySlotAlias: string) => string;
9
- export { generateCode, generateReactiveInlining };
10
+ declare const generateCode: (templates: TemplateInfo[], sourceFile: ts.SourceFile, checker?: ts.TypeChecker, existingAliases?: Map<string, string>) => CodegenResult;
11
+ export { generateCode };
10
12
  export type { CodegenResult };
@@ -1,6 +1,6 @@
1
1
  import { ts } from '@esportsplus/typescript';
2
- import { ast, code as c, imports, uid } from '@esportsplus/typescript/compiler';
3
- import { COMPILER_ENTRYPOINT, COMPILER_TYPES, DIRECT_ATTACH_EVENTS, LIFECYCLE_EVENTS, PACKAGE } from '../constants.js';
2
+ import { ast, uid } from '@esportsplus/typescript/compiler';
3
+ import { COMPILER_ENTRYPOINT, COMPILER_TYPES, DIRECT_ATTACH_EVENTS, LIFECYCLE_EVENTS } from '../constants.js';
4
4
  import { analyze } from './analyzer.js';
5
5
  import parser from './parser.js';
6
6
  const ARROW_EMPTY_PARAMS = /\(\s*\)\s*=>\s*$/;
@@ -13,6 +13,18 @@ function addImport(ctx, name) {
13
13
  }
14
14
  return alias;
15
15
  }
16
+ function collectNestedTemplateReplacements(ctx, node, exprStart, replacements) {
17
+ if (isNestedHtmlTemplate(node)) {
18
+ replacements.push({
19
+ end: node.end - exprStart,
20
+ newText: generateNestedTemplateCode(ctx, node),
21
+ start: node.getStart(ctx.sourceFile) - exprStart
22
+ });
23
+ }
24
+ else {
25
+ ts.forEachChild(node, child => collectNestedTemplateReplacements(ctx, child, exprStart, replacements));
26
+ }
27
+ }
16
28
  function generateAttributeBinding(ctx, element, name, expr, staticValue) {
17
29
  if (name.startsWith('on') && name.length > 2) {
18
30
  let event = name.slice(2).toLowerCase(), key = name.toLowerCase();
@@ -32,18 +44,6 @@ function generateAttributeBinding(ctx, element, name, expr, staticValue) {
32
44
  }
33
45
  return `${addImport(ctx, 'setProperty')}(${element}, '${name}', ${expr});`;
34
46
  }
35
- function collectNestedTemplateReplacements(ctx, node, exprStart, replacements) {
36
- if (isNestedHtmlTemplate(node)) {
37
- replacements.push({
38
- end: node.end - exprStart,
39
- newText: generateNestedTemplateCode(ctx, node),
40
- start: node.getStart() - exprStart
41
- });
42
- }
43
- else {
44
- ts.forEachChild(node, child => collectNestedTemplateReplacements(ctx, child, exprStart, replacements));
45
- }
46
- }
47
47
  function generateNestedTemplateCode(ctx, node) {
48
48
  let expressions = [], exprTexts = [], literals = [], template = node.template;
49
49
  if (ts.isNoSubstitutionTemplateLiteral(template)) {
@@ -151,6 +151,14 @@ function getOrCreateTemplateId(ctx, html) {
151
151
  function isNestedHtmlTemplate(expr) {
152
152
  return ts.isTaggedTemplateExpression(expr) && ts.isIdentifier(expr.tag) && expr.tag.text === COMPILER_ENTRYPOINT;
153
153
  }
154
+ function replaceReverse(text, replacements) {
155
+ let sorted = replacements.slice().sort((a, b) => b.start - a.start);
156
+ for (let i = 0, n = sorted.length; i < n; i++) {
157
+ let r = sorted[i];
158
+ text = text.slice(0, r.start) + r.newText + text.slice(r.end);
159
+ }
160
+ return text;
161
+ }
154
162
  function rewriteExpression(ctx, expr) {
155
163
  if (isNestedHtmlTemplate(expr)) {
156
164
  return generateNestedTemplateCode(ctx, expr);
@@ -159,87 +167,65 @@ function rewriteExpression(ctx, expr) {
159
167
  return ctx.printer.printNode(ts.EmitHint.Expression, expr, ctx.sourceFile);
160
168
  }
161
169
  let replacements = [];
162
- collectNestedTemplateReplacements(ctx, expr, expr.getStart(), replacements);
163
- return c.replaceReverse(expr.getText(ctx.sourceFile), replacements);
170
+ collectNestedTemplateReplacements(ctx, expr, expr.getStart(ctx.sourceFile), replacements);
171
+ return replaceReverse(expr.getText(ctx.sourceFile), replacements);
164
172
  }
165
- const generateCode = (templates, originalCode, sourceFile, checker, existingAliases) => {
173
+ const generateCode = (templates, sourceFile, checker, existingAliases) => {
174
+ let result = {
175
+ imports: existingAliases ?? new Map(),
176
+ prepend: [],
177
+ replacements: [],
178
+ templates: new Map()
179
+ };
166
180
  if (templates.length === 0) {
167
- return { changed: false, code: originalCode };
181
+ return result;
168
182
  }
169
183
  let ranges = [];
170
184
  for (let i = 0, n = templates.length; i < n; i++) {
171
185
  let exprs = templates[i].expressions;
172
186
  for (let j = 0, m = exprs.length; j < m; j++) {
173
- ranges.push({ end: exprs[j].end, start: exprs[j].getStart() });
187
+ ranges.push({ end: exprs[j].end, start: exprs[j].getStart(sourceFile) });
174
188
  }
175
189
  }
176
- let rootTemplates = templates.filter(t => !ast.inRange(ranges, t.start, t.end));
190
+ let rootTemplates = templates.filter(t => !ast.inRange(ranges, t.node.getStart(sourceFile), t.node.end));
177
191
  if (rootTemplates.length === 0) {
178
- return { changed: false, code: originalCode };
192
+ return result;
179
193
  }
180
194
  let ctx = {
181
195
  checker,
182
- imports: existingAliases ?? new Map(),
196
+ imports: result.imports,
183
197
  printer,
184
198
  sourceFile,
185
- templates: new Map(),
186
- }, replacements = [], templateAlias = addImport(ctx, 'template');
199
+ templates: result.templates
200
+ }, templateAlias = addImport(ctx, 'template');
187
201
  for (let i = 0, n = rootTemplates.length; i < n; i++) {
188
- let exprTexts = [], template = rootTemplates[i];
189
- for (let j = 0, m = template.expressions.length; j < m; j++) {
190
- exprTexts.push(rewriteExpression(ctx, template.expressions[j]));
191
- }
192
- let codeBefore = originalCode.slice(0, template.start), isArrowBody = codeBefore.trimEnd().endsWith('=>'), parsed = parser.parse(template.literals);
193
- if (isArrowBody && (!parsed.slots || parsed.slots.length === 0)) {
194
- let arrowMatch = codeBefore.match(ARROW_EMPTY_PARAMS);
195
- if (arrowMatch) {
196
- replacements.push({
197
- end: template.end,
198
- newText: getOrCreateTemplateId(ctx, parsed.html),
199
- start: template.start - arrowMatch[0].length
200
- });
201
- continue;
202
- }
203
- }
204
- replacements.push({
205
- end: template.end,
206
- newText: generateTemplateCode(ctx, parsed, exprTexts, template.expressions, isArrowBody),
207
- start: template.start
208
- });
209
- }
210
- let changed = replacements.length > 0, code = c.replaceReverse(originalCode, replacements);
211
- if (changed && ctx.templates.size > 0) {
212
- let aliasedImports = [], factories = [], updatedSourceFile = ts.createSourceFile(sourceFile.fileName, code, sourceFile.languageVersion, true);
213
- for (let [name, alias] of ctx.imports) {
214
- aliasedImports.push(`${name} as ${alias}`);
215
- }
216
- for (let [html, id] of ctx.templates) {
217
- factories.push(`const ${id} = ${templateAlias}(\`${html}\`);`);
218
- }
219
- code = imports.modify(code, updatedSourceFile, PACKAGE, {
220
- add: new Set(aliasedImports),
221
- remove: [COMPILER_ENTRYPOINT]
202
+ let template = rootTemplates[i];
203
+ result.replacements.push({
204
+ generate: (sf) => {
205
+ let codeBefore = sf.getFullText().slice(0, template.node.getStart(sf)), exprTexts = [], isArrowBody = codeBefore.trimEnd().endsWith('=>'), localCtx = {
206
+ checker,
207
+ imports: ctx.imports,
208
+ printer,
209
+ sourceFile: sf,
210
+ templates: ctx.templates
211
+ }, parsed = parser.parse(template.literals);
212
+ for (let j = 0, m = template.expressions.length; j < m; j++) {
213
+ exprTexts.push(rewriteExpression(localCtx, template.expressions[j]));
214
+ }
215
+ if (isArrowBody && (!parsed.slots || parsed.slots.length === 0)) {
216
+ let arrowMatch = codeBefore.match(ARROW_EMPTY_PARAMS);
217
+ if (arrowMatch) {
218
+ return getOrCreateTemplateId(localCtx, parsed.html);
219
+ }
220
+ }
221
+ return generateTemplateCode(localCtx, parsed, exprTexts, template.expressions, isArrowBody);
222
+ },
223
+ node: template.node
222
224
  });
223
- code = factories.join('\n') + '\n\n' + code;
224
225
  }
225
- return { changed, code };
226
- };
227
- const generateReactiveInlining = (calls, code, sourceFile, arraySlotAlias) => {
228
- if (calls.length === 0) {
229
- return code;
230
- }
231
- let replacements = [];
232
- for (let i = 0, n = calls.length; i < n; i++) {
233
- let call = calls[i];
234
- replacements.push({
235
- end: call.end,
236
- newText: `new ${arraySlotAlias}(
237
- ${printer.printNode(ts.EmitHint.Expression, call.arrayArg, sourceFile)},
238
- ${printer.printNode(ts.EmitHint.Expression, call.callbackArg, sourceFile)}
239
- )`,
240
- start: call.start
241
- });
226
+ for (let [html, id] of ctx.templates) {
227
+ result.prepend.push(`const ${id} = ${templateAlias}(\`${html}\`);`);
242
228
  }
243
- return c.replaceReverse(code, replacements);
229
+ return result;
244
230
  };
245
- export { generateCode, generateReactiveInlining };
231
+ export { generateCode };
@@ -1,10 +1,5 @@
1
- import type { PluginContext } from '@esportsplus/typescript/compiler';
2
- import { ts } from '@esportsplus/typescript';
3
- type TransformResult = {
4
- changed: boolean;
5
- code: string;
6
- sourceFile: ts.SourceFile;
7
- };
8
- declare const analyze: (sourceFile: ts.SourceFile, program: ts.Program, context: PluginContext) => void;
9
- declare const transform: (sourceFile: ts.SourceFile, program: ts.Program, context?: PluginContext) => TransformResult;
10
- export { analyze, transform };
1
+ import type { Plugin } from '@esportsplus/typescript/compiler';
2
+ import reactiveInliningPlugin from './reactive-inlining.js';
3
+ declare const templatePlugin: Plugin;
4
+ export default templatePlugin;
5
+ export { reactiveInliningPlugin, templatePlugin };
@@ -1,82 +1,38 @@
1
- import { ts } from '@esportsplus/typescript';
2
- import { code as c, imports, uid } from '@esportsplus/typescript/compiler';
3
- import { COMPILER_ENTRYPOINT, COMPILER_ENTRYPOINT_REACTIVITY, PACKAGE } from '../constants.js';
4
- import { generateCode, generateReactiveInlining } from './codegen.js';
5
- import { findHtmlTemplates, findReactiveCalls } from './ts-parser.js';
6
- const CONTEXT_KEY = 'template:analyzed';
7
- const PATTERNS = [`${COMPILER_ENTRYPOINT}\``, `${COMPILER_ENTRYPOINT}.${COMPILER_ENTRYPOINT_REACTIVITY}`];
8
- const REGEX_BACKSLASH = /\\/g;
9
- const REGEX_FORWARD_SLASH = /\//g;
10
- function getAnalyzedFile(context, filename) {
11
- return context?.get(CONTEXT_KEY)?.get(filename);
12
- }
13
- const analyze = (sourceFile, program, context) => {
14
- let code = sourceFile.getFullText();
15
- if (!c.contains(code, { patterns: PATTERNS })) {
16
- return;
17
- }
18
- let checker = program.getTypeChecker(), filename = sourceFile.fileName, files = context.get(CONTEXT_KEY), programSourceFile = program.getSourceFile(filename)
19
- || program.getSourceFile(filename.replace(REGEX_BACKSLASH, '/'))
20
- || program.getSourceFile(filename.replace(REGEX_FORWARD_SLASH, '\\'));
21
- if (programSourceFile) {
22
- sourceFile = programSourceFile;
23
- }
24
- if (!files) {
25
- files = new Map();
26
- context.set(CONTEXT_KEY, files);
27
- }
28
- files.set(filename, {
29
- reactiveCalls: findReactiveCalls(sourceFile, checker),
30
- templates: findHtmlTemplates(sourceFile, checker)
31
- });
32
- };
33
- const transform = (sourceFile, program, context) => {
34
- let code = sourceFile.getFullText(), filename = sourceFile.fileName;
35
- let analyzed = getAnalyzedFile(context, filename);
36
- if (!analyzed) {
37
- if (!c.contains(code, { patterns: PATTERNS })) {
38
- return { changed: false, code, sourceFile };
1
+ import { COMPILER_ENTRYPOINT, PACKAGE } from '../constants.js';
2
+ import { generateCode } from './codegen.js';
3
+ import reactiveInliningPlugin, { SHARED_KEY } from './reactive-inlining.js';
4
+ import { findHtmlTemplates } from './ts-parser.js';
5
+ const PATTERNS = [`${COMPILER_ENTRYPOINT}\``];
6
+ const templatePlugin = {
7
+ patterns: PATTERNS,
8
+ transform: (ctx) => {
9
+ let templates = findHtmlTemplates(ctx.sourceFile, ctx.checker);
10
+ if (templates.length === 0) {
11
+ return {};
39
12
  }
40
- let checker = program.getTypeChecker(), programSourceFile = program.getSourceFile(filename)
41
- || program.getSourceFile(filename.replace(REGEX_BACKSLASH, '/'))
42
- || program.getSourceFile(filename.replace(REGEX_FORWARD_SLASH, '\\'));
43
- if (programSourceFile) {
44
- sourceFile = programSourceFile;
13
+ let existingAliases = new Map(), existingArraySlotAlias = ctx.shared.get(SHARED_KEY);
14
+ if (existingArraySlotAlias) {
15
+ existingAliases.set('ArraySlot', existingArraySlotAlias);
45
16
  }
46
- analyzed = {
47
- reactiveCalls: findReactiveCalls(sourceFile, checker),
48
- templates: findHtmlTemplates(sourceFile, checker)
49
- };
50
- }
51
- let changed = false, codegenChanged = false, existingAliases = new Map(), result = code;
52
- if (analyzed.reactiveCalls.length > 0) {
53
- changed = true;
54
- existingAliases.set('ArraySlot', uid('ArraySlot'));
55
- result = generateReactiveInlining(analyzed.reactiveCalls, result, sourceFile, existingAliases.get('ArraySlot'));
56
- sourceFile = ts.createSourceFile(sourceFile.fileName, result, sourceFile.languageVersion, true);
57
- analyzed = {
58
- reactiveCalls: [],
59
- templates: findHtmlTemplates(sourceFile, program.getTypeChecker())
60
- };
61
- }
62
- if (analyzed.templates.length > 0) {
63
- let codegenResult = generateCode(analyzed.templates, result, sourceFile, program.getTypeChecker(), existingAliases);
64
- if (codegenResult.changed) {
65
- changed = true;
66
- codegenChanged = true;
67
- result = codegenResult.code;
17
+ let result = generateCode(templates, ctx.sourceFile, ctx.checker, existingAliases);
18
+ if (result.replacements.length === 0) {
19
+ return {};
68
20
  }
69
- }
70
- if (existingAliases.size > 0 && !codegenChanged) {
71
21
  let aliasedImports = [];
72
- for (let [name, alias] of existingAliases) {
22
+ for (let [name, alias] of result.imports) {
73
23
  aliasedImports.push(`${name} as ${alias}`);
74
24
  }
75
- result = imports.modify(result, sourceFile, PACKAGE, { add: aliasedImports });
76
- }
77
- if (changed) {
78
- sourceFile = ts.createSourceFile(sourceFile.fileName, result, sourceFile.languageVersion, true);
25
+ let imports = [{
26
+ add: aliasedImports,
27
+ package: PACKAGE,
28
+ remove: [COMPILER_ENTRYPOINT]
29
+ }];
30
+ return {
31
+ imports,
32
+ prepend: result.prepend,
33
+ replacements: result.replacements
34
+ };
79
35
  }
80
- return { changed, code: result, sourceFile };
81
36
  };
82
- export { analyze, transform };
37
+ export default templatePlugin;
38
+ export { reactiveInliningPlugin, templatePlugin };
@@ -1,3 +1,3 @@
1
1
  import { plugin } from '@esportsplus/typescript/compiler';
2
- import { analyze, transform } from '../index.js';
3
- export default plugin.tsc({ analyze, transform });
2
+ import { reactiveInliningPlugin, templatePlugin } from '../index.js';
3
+ export default plugin.tsc([reactiveInliningPlugin, templatePlugin]);
@@ -1,8 +1,7 @@
1
1
  import { plugin } from '@esportsplus/typescript/compiler';
2
2
  import { PACKAGE } from '../../constants.js';
3
- import { analyze, transform } from '../index.js';
3
+ import { reactiveInliningPlugin, templatePlugin } from '../index.js';
4
4
  export default plugin.vite({
5
- analyze,
6
5
  name: PACKAGE,
7
- transform
6
+ plugins: [reactiveInliningPlugin, templatePlugin]
8
7
  });
@@ -0,0 +1,5 @@
1
+ import type { Plugin } from '@esportsplus/typescript/compiler';
2
+ declare const SHARED_KEY = "template:arraySlotAlias";
3
+ declare const plugin: Plugin;
4
+ export default plugin;
5
+ export { SHARED_KEY };
@@ -0,0 +1,75 @@
1
+ import { ts } from '@esportsplus/typescript';
2
+ import { uid } from '@esportsplus/typescript/compiler';
3
+ import { COMPILER_ENTRYPOINT, COMPILER_ENTRYPOINT_REACTIVITY, PACKAGE } from '../constants.js';
4
+ const PATTERNS = [`${COMPILER_ENTRYPOINT}.${COMPILER_ENTRYPOINT_REACTIVITY}`];
5
+ const SHARED_KEY = 'template:arraySlotAlias';
6
+ let printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
7
+ function isHtmlFromPackage(node, checker) {
8
+ if (node.text !== COMPILER_ENTRYPOINT) {
9
+ return false;
10
+ }
11
+ if (!checker) {
12
+ return true;
13
+ }
14
+ let symbol = checker.getSymbolAtLocation(node);
15
+ if (!symbol) {
16
+ return true;
17
+ }
18
+ if (symbol.flags & ts.SymbolFlags.Alias) {
19
+ symbol = checker.getAliasedSymbol(symbol);
20
+ }
21
+ let declarations = symbol.getDeclarations();
22
+ if (!declarations || declarations.length === 0) {
23
+ return true;
24
+ }
25
+ for (let i = 0, n = declarations.length; i < n; i++) {
26
+ let filename = declarations[i].getSourceFile().fileName;
27
+ if (filename.includes(PACKAGE) || filename.includes('@esportsplus/template')) {
28
+ return true;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ function visit(node, calls, checker) {
34
+ if (ts.isCallExpression(node) &&
35
+ ts.isPropertyAccessExpression(node.expression) &&
36
+ ts.isIdentifier(node.expression.expression) &&
37
+ node.expression.name.text === COMPILER_ENTRYPOINT_REACTIVITY &&
38
+ node.arguments.length === 2 &&
39
+ isHtmlFromPackage(node.expression.expression, checker)) {
40
+ calls.push({
41
+ arrayArg: node.arguments[0],
42
+ callbackArg: node.arguments[1],
43
+ node
44
+ });
45
+ }
46
+ ts.forEachChild(node, child => visit(child, calls, checker));
47
+ }
48
+ const plugin = {
49
+ patterns: PATTERNS,
50
+ transform: (ctx) => {
51
+ let calls = [];
52
+ visit(ctx.sourceFile, calls, ctx.checker);
53
+ if (calls.length === 0) {
54
+ return {};
55
+ }
56
+ let arraySlotAlias = uid('ArraySlot'), replacements = [];
57
+ ctx.shared.set(SHARED_KEY, arraySlotAlias);
58
+ for (let i = 0, n = calls.length; i < n; i++) {
59
+ let call = calls[i];
60
+ replacements.push({
61
+ generate: (sourceFile) => `new ${arraySlotAlias}(${printer.printNode(ts.EmitHint.Expression, call.arrayArg, sourceFile)}, ${printer.printNode(ts.EmitHint.Expression, call.callbackArg, sourceFile)})`,
62
+ node: call.node
63
+ });
64
+ }
65
+ return {
66
+ imports: [{
67
+ add: [`ArraySlot as ${arraySlotAlias}`],
68
+ package: PACKAGE
69
+ }],
70
+ replacements
71
+ };
72
+ }
73
+ };
74
+ export default plugin;
75
+ export { SHARED_KEY };
package/package.json CHANGED
@@ -2,8 +2,8 @@
2
2
  "author": "ICJR",
3
3
  "dependencies": {
4
4
  "@esportsplus/queue": "^0.2.0",
5
- "@esportsplus/reactivity": "^0.27.3",
6
- "@esportsplus/typescript": "^0.24.2",
5
+ "@esportsplus/reactivity": "^0.28.0",
6
+ "@esportsplus/typescript": "^0.25.0",
7
7
  "@esportsplus/utilities": "^0.27.2",
8
8
  "serve": "^14.2.5"
9
9
  },
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "type": "module",
37
37
  "types": "./build/index.d.ts",
38
- "version": "0.37.0",
38
+ "version": "0.38.0",
39
39
  "scripts": {
40
40
  "build": "tsc",
41
41
  "build:test": "vite build --config test/vite.config.ts",
@@ -1,7 +1,8 @@
1
+ import type { ReplacementIntent } from '@esportsplus/typescript/compiler';
1
2
  import { ts } from '@esportsplus/typescript';
2
- import { ast, code as c, imports, uid, type Replacement } from '@esportsplus/typescript/compiler';
3
- import { COMPILER_ENTRYPOINT, COMPILER_TYPES, DIRECT_ATTACH_EVENTS, LIFECYCLE_EVENTS, PACKAGE } from '~/constants';
4
- import type { ReactiveCallInfo, TemplateInfo } from './ts-parser';
3
+ import { ast, uid } from '@esportsplus/typescript/compiler';
4
+ import { COMPILER_ENTRYPOINT, COMPILER_TYPES, DIRECT_ATTACH_EVENTS, LIFECYCLE_EVENTS } from '~/constants';
5
+ import type { TemplateInfo } from './ts-parser';
5
6
  import { analyze } from './analyzer';
6
7
  import parser from './parser';
7
8
 
@@ -24,8 +25,10 @@ type CodegenContext = {
24
25
  };
25
26
 
26
27
  type CodegenResult = {
27
- changed: boolean;
28
- code: string;
28
+ imports: Map<string, string>;
29
+ prepend: string[];
30
+ replacements: ReplacementIntent[];
31
+ templates: Map<string, string>;
29
32
  };
30
33
 
31
34
  type Node = {
@@ -38,6 +41,12 @@ type ParseResult = {
38
41
  slots: (Attribute | Node)[] | null;
39
42
  };
40
43
 
44
+ type Replacement = {
45
+ end: number;
46
+ newText: string;
47
+ start: number;
48
+ };
49
+
41
50
 
42
51
  const ARROW_EMPTY_PARAMS = /\(\s*\)\s*=>\s*$/;
43
52
 
@@ -56,6 +65,24 @@ function addImport(ctx: CodegenContext, name: string): string {
56
65
  return alias;
57
66
  }
58
67
 
68
+ function collectNestedTemplateReplacements(
69
+ ctx: CodegenContext,
70
+ node: ts.Node,
71
+ exprStart: number,
72
+ replacements: Replacement[]
73
+ ): void {
74
+ if (isNestedHtmlTemplate(node as ts.Expression)) {
75
+ replacements.push({
76
+ end: node.end - exprStart,
77
+ newText: generateNestedTemplateCode(ctx, node as ts.TaggedTemplateExpression),
78
+ start: node.getStart(ctx.sourceFile) - exprStart
79
+ });
80
+ }
81
+ else {
82
+ ts.forEachChild(node, child => collectNestedTemplateReplacements(ctx, child, exprStart, replacements));
83
+ }
84
+ }
85
+
59
86
  function generateAttributeBinding(ctx: CodegenContext, element: string, name: string, expr: string, staticValue: string): string {
60
87
  if (name.startsWith('on') && name.length > 2) {
61
88
  let event = name.slice(2).toLowerCase(),
@@ -83,24 +110,6 @@ function generateAttributeBinding(ctx: CodegenContext, element: string, name: st
83
110
  return `${addImport(ctx, 'setProperty')}(${element}, '${name}', ${expr});`;
84
111
  }
85
112
 
86
- function collectNestedTemplateReplacements(
87
- ctx: CodegenContext,
88
- node: ts.Node,
89
- exprStart: number,
90
- replacements: Replacement[]
91
- ): void {
92
- if (isNestedHtmlTemplate(node as ts.Expression)) {
93
- replacements.push({
94
- end: node.end - exprStart,
95
- newText: generateNestedTemplateCode(ctx, node as ts.TaggedTemplateExpression),
96
- start: node.getStart() - exprStart
97
- });
98
- }
99
- else {
100
- ts.forEachChild(node, child => collectNestedTemplateReplacements(ctx, child, exprStart, replacements));
101
- }
102
- }
103
-
104
113
  function generateNestedTemplateCode(ctx: CodegenContext, node: ts.TaggedTemplateExpression): string {
105
114
  let expressions: ts.Expression[] = [],
106
115
  exprTexts: string[] = [],
@@ -211,7 +220,6 @@ function generateTemplateCode(
211
220
  segments = path.slice(start),
212
221
  value = `${ancestor}.${segments.join('!.')}`;
213
222
 
214
- // Cast root.firstChild to Element since DocumentFragment.firstChild returns ChildNode
215
223
  if (ancestor === root && segments[0] === 'firstChild') {
216
224
  value = value.replace(`${ancestor}.firstChild!`, `(${ancestor}.firstChild! as ${alias})`);
217
225
  }
@@ -285,6 +293,18 @@ function isNestedHtmlTemplate(expr: ts.Expression): expr is ts.TaggedTemplateExp
285
293
  return ts.isTaggedTemplateExpression(expr) && ts.isIdentifier(expr.tag) && expr.tag.text === COMPILER_ENTRYPOINT;
286
294
  }
287
295
 
296
+ function replaceReverse(text: string, replacements: Replacement[]): string {
297
+ let sorted = replacements.slice().sort((a, b) => b.start - a.start);
298
+
299
+ for (let i = 0, n = sorted.length; i < n; i++) {
300
+ let r = sorted[i];
301
+
302
+ text = text.slice(0, r.start) + r.newText + text.slice(r.end);
303
+ }
304
+
305
+ return text;
306
+ }
307
+
288
308
  function rewriteExpression(ctx: CodegenContext, expr: ts.Expression): string {
289
309
  if (isNestedHtmlTemplate(expr)) {
290
310
  return generateNestedTemplateCode(ctx, expr);
@@ -296,132 +316,97 @@ function rewriteExpression(ctx: CodegenContext, expr: ts.Expression): string {
296
316
 
297
317
  let replacements: Replacement[] = [];
298
318
 
299
- collectNestedTemplateReplacements(ctx, expr, expr.getStart(), replacements);
319
+ collectNestedTemplateReplacements(ctx, expr, expr.getStart(ctx.sourceFile), replacements);
300
320
 
301
- return c.replaceReverse(expr.getText(ctx.sourceFile), replacements);
321
+ return replaceReverse(expr.getText(ctx.sourceFile), replacements);
302
322
  }
303
323
 
304
324
 
305
- const generateCode = (templates: TemplateInfo[], originalCode: string, sourceFile: ts.SourceFile, checker?: ts.TypeChecker, existingAliases?: Map<string, string>): CodegenResult => {
325
+ const generateCode = (templates: TemplateInfo[], sourceFile: ts.SourceFile, checker?: ts.TypeChecker, existingAliases?: Map<string, string>): CodegenResult => {
326
+ let result: CodegenResult = {
327
+ imports: existingAliases ?? new Map(),
328
+ prepend: [],
329
+ replacements: [],
330
+ templates: new Map()
331
+ };
332
+
306
333
  if (templates.length === 0) {
307
- return { changed: false, code: originalCode };
334
+ return result;
308
335
  }
309
336
 
310
- // Precompute expression ranges for nested template detection
311
337
  let ranges: { end: number; start: number }[] = [];
312
338
 
313
339
  for (let i = 0, n = templates.length; i < n; i++) {
314
340
  let exprs = templates[i].expressions;
315
341
 
316
342
  for (let j = 0, m = exprs.length; j < m; j++) {
317
- ranges.push({ end: exprs[j].end, start: exprs[j].getStart() });
343
+ ranges.push({ end: exprs[j].end, start: exprs[j].getStart(sourceFile) });
318
344
  }
319
345
  }
320
346
 
321
- let rootTemplates = templates.filter(t => !ast.inRange(ranges, t.start, t.end));
347
+ let rootTemplates = templates.filter(t => !ast.inRange(ranges, t.node.getStart(sourceFile), t.node.end));
322
348
 
323
349
  if (rootTemplates.length === 0) {
324
- return { changed: false, code: originalCode };
350
+ return result;
325
351
  }
326
352
 
327
353
  let ctx: CodegenContext = {
328
354
  checker,
329
- imports: existingAliases ?? new Map(),
355
+ imports: result.imports,
330
356
  printer,
331
357
  sourceFile,
332
- templates: new Map(),
358
+ templates: result.templates
333
359
  },
334
- replacements: Replacement[] = [],
335
360
  templateAlias = addImport(ctx, 'template');
336
361
 
337
362
  for (let i = 0, n = rootTemplates.length; i < n; i++) {
338
- let exprTexts: string[] = [],
339
- template = rootTemplates[i];
363
+ let template = rootTemplates[i];
364
+
365
+ result.replacements.push({
366
+ generate: (sf) => {
367
+ let codeBefore = sf.getFullText().slice(0, template.node.getStart(sf)),
368
+ exprTexts: string[] = [],
369
+ isArrowBody = codeBefore.trimEnd().endsWith('=>'),
370
+ localCtx: CodegenContext = {
371
+ checker,
372
+ imports: ctx.imports,
373
+ printer,
374
+ sourceFile: sf,
375
+ templates: ctx.templates
376
+ },
377
+ parsed = parser.parse(template.literals) as ParseResult;
378
+
379
+ for (let j = 0, m = template.expressions.length; j < m; j++) {
380
+ exprTexts.push(rewriteExpression(localCtx, template.expressions[j]));
381
+ }
340
382
 
341
- for (let j = 0, m = template.expressions.length; j < m; j++) {
342
- exprTexts.push(rewriteExpression(ctx, template.expressions[j]));
343
- }
383
+ if (isArrowBody && (!parsed.slots || parsed.slots.length === 0)) {
384
+ let arrowMatch = codeBefore.match(ARROW_EMPTY_PARAMS);
344
385
 
345
- let codeBefore = originalCode.slice(0, template.start),
346
- isArrowBody = codeBefore.trimEnd().endsWith('=>'),
347
- parsed = parser.parse(template.literals) as ParseResult;
348
-
349
- // Optimize: when template has no slots and is `() => template`, use template directly
350
- if (isArrowBody && (!parsed.slots || parsed.slots.length === 0)) {
351
- let arrowMatch = codeBefore.match(ARROW_EMPTY_PARAMS);
352
-
353
- if (arrowMatch) {
354
- replacements.push({
355
- end: template.end,
356
- newText: getOrCreateTemplateId(ctx, parsed.html),
357
- start: template.start - arrowMatch[0].length
358
- });
359
- continue;
360
- }
361
- }
386
+ if (arrowMatch) {
387
+ return getOrCreateTemplateId(localCtx, parsed.html);
388
+ }
389
+ }
362
390
 
363
- replacements.push({
364
- end: template.end,
365
- newText: generateTemplateCode(
366
- ctx,
367
- parsed,
368
- exprTexts,
369
- template.expressions,
370
- isArrowBody
371
- ),
372
- start: template.start
391
+ return generateTemplateCode(
392
+ localCtx,
393
+ parsed,
394
+ exprTexts,
395
+ template.expressions,
396
+ isArrowBody
397
+ );
398
+ },
399
+ node: template.node
373
400
  });
374
401
  }
375
402
 
376
- let changed = replacements.length > 0,
377
- code = c.replaceReverse(originalCode, replacements);
378
-
379
- if (changed && ctx.templates.size > 0) {
380
- let aliasedImports: string[] = [],
381
- factories: string[] = [],
382
- updatedSourceFile = ts.createSourceFile(sourceFile.fileName, code, sourceFile.languageVersion, true);
383
-
384
- for (let [name, alias] of ctx.imports) {
385
- aliasedImports.push(`${name} as ${alias}`);
386
- }
387
-
388
- for (let [html, id] of ctx.templates) {
389
- factories.push(`const ${id} = ${templateAlias}(\`${html}\`);`);
390
- }
391
-
392
- // Remove html entrypoint and add aliased imports
393
- code = imports.modify(code, updatedSourceFile, PACKAGE, {
394
- add: new Set(aliasedImports),
395
- remove: [COMPILER_ENTRYPOINT]
396
- });
397
- code = factories.join('\n') + '\n\n' + code;
403
+ for (let [html, id] of ctx.templates) {
404
+ result.prepend.push(`const ${id} = ${templateAlias}(\`${html}\`);`);
398
405
  }
399
406
 
400
- return { changed, code };
407
+ return result;
401
408
  };
402
409
 
403
- const generateReactiveInlining = (calls: ReactiveCallInfo[], code: string, sourceFile: ts.SourceFile, arraySlotAlias: string): string => {
404
- if (calls.length === 0) {
405
- return code;
406
- }
407
-
408
- let replacements: Replacement[] = [];
409
-
410
- for (let i = 0, n = calls.length; i < n; i++) {
411
- let call = calls[i];
412
-
413
- replacements.push({
414
- end: call.end,
415
- newText: `new ${arraySlotAlias}(
416
- ${printer.printNode(ts.EmitHint.Expression, call.arrayArg, sourceFile)},
417
- ${printer.printNode(ts.EmitHint.Expression, call.callbackArg, sourceFile)}
418
- )`,
419
- start: call.start
420
- });
421
- }
422
-
423
- return c.replaceReverse(code, replacements);
424
- };
425
410
 
426
- export { generateCode, generateReactiveInlining };
411
+ export { generateCode };
427
412
  export type { CodegenResult };
@@ -1,139 +1,56 @@
1
- import type { PluginContext } from '@esportsplus/typescript/compiler';
2
- import { ts } from '@esportsplus/typescript';
3
- import { code as c, imports, uid } from '@esportsplus/typescript/compiler';
4
- import { COMPILER_ENTRYPOINT, COMPILER_ENTRYPOINT_REACTIVITY, PACKAGE } from '~/constants';
5
- import { generateCode, generateReactiveInlining } from './codegen';
6
- import { findHtmlTemplates, findReactiveCalls, type ReactiveCallInfo, type TemplateInfo } from './ts-parser';
1
+ import type { ImportIntent, Plugin, TransformContext } from '@esportsplus/typescript/compiler';
2
+ import { COMPILER_ENTRYPOINT, PACKAGE } from '~/constants';
3
+ import { generateCode } from './codegen';
4
+ import reactiveInliningPlugin, { SHARED_KEY } from './reactive-inlining';
5
+ import { findHtmlTemplates } from './ts-parser';
7
6
 
8
7
 
9
- type AnalyzedFile = {
10
- reactiveCalls: ReactiveCallInfo[];
11
- templates: TemplateInfo[];
12
- };
13
-
14
- type TransformResult = {
15
- changed: boolean;
16
- code: string;
17
- sourceFile: ts.SourceFile;
18
- };
19
-
20
-
21
- const CONTEXT_KEY = 'template:analyzed';
22
-
23
- const PATTERNS = [`${COMPILER_ENTRYPOINT}\``, `${COMPILER_ENTRYPOINT}.${COMPILER_ENTRYPOINT_REACTIVITY}`];
24
-
25
- const REGEX_BACKSLASH = /\\/g;
26
-
27
- const REGEX_FORWARD_SLASH = /\//g;
28
-
29
-
30
- function getAnalyzedFile(context: PluginContext | undefined, filename: string): AnalyzedFile | undefined {
31
- return (context?.get(CONTEXT_KEY) as Map<string, AnalyzedFile> | undefined)?.get(filename);
32
- }
33
-
34
-
35
- const analyze = (sourceFile: ts.SourceFile, program: ts.Program, context: PluginContext): void => {
36
- let code = sourceFile.getFullText();
37
-
38
- if (!c.contains(code, { patterns: PATTERNS })) {
39
- return;
40
- }
41
-
42
- let checker = program.getTypeChecker(),
43
- filename = sourceFile.fileName,
44
- files = context.get(CONTEXT_KEY) as Map<string, AnalyzedFile> | undefined,
45
- programSourceFile = program.getSourceFile(filename)
46
- || program.getSourceFile(filename.replace(REGEX_BACKSLASH, '/'))
47
- || program.getSourceFile(filename.replace(REGEX_FORWARD_SLASH, '\\'));
8
+ const PATTERNS = [`${COMPILER_ENTRYPOINT}\``];
48
9
 
49
- if (programSourceFile) {
50
- sourceFile = programSourceFile;
51
- }
52
-
53
- if (!files) {
54
- files = new Map();
55
- context.set(CONTEXT_KEY, files);
56
- }
57
-
58
- files.set(filename, {
59
- reactiveCalls: findReactiveCalls(sourceFile, checker),
60
- templates: findHtmlTemplates(sourceFile, checker)
61
- });
62
- };
63
10
 
64
- const transform = (sourceFile: ts.SourceFile, program: ts.Program, context?: PluginContext): TransformResult => {
65
- let code = sourceFile.getFullText(),
66
- filename = sourceFile.fileName;
11
+ const templatePlugin: Plugin = {
12
+ patterns: PATTERNS,
67
13
 
68
- // Try to get pre-analyzed data from context
69
- let analyzed = getAnalyzedFile(context, filename);
14
+ transform: (ctx: TransformContext) => {
15
+ let templates = findHtmlTemplates(ctx.sourceFile, ctx.checker);
70
16
 
71
- // Fall back to inline analysis (for Vite or when context unavailable)
72
- if (!analyzed) {
73
- if (!c.contains(code, { patterns: PATTERNS })) {
74
- return { changed: false, code, sourceFile };
17
+ if (templates.length === 0) {
18
+ return {};
75
19
  }
76
20
 
77
- let checker = program.getTypeChecker(),
78
- programSourceFile = program.getSourceFile(filename)
79
- || program.getSourceFile(filename.replace(REGEX_BACKSLASH, '/'))
80
- || program.getSourceFile(filename.replace(REGEX_FORWARD_SLASH, '\\'));
21
+ let existingAliases = new Map<string, string>(),
22
+ existingArraySlotAlias = ctx.shared.get(SHARED_KEY) as string | undefined;
81
23
 
82
- if (programSourceFile) {
83
- sourceFile = programSourceFile;
24
+ if (existingArraySlotAlias) {
25
+ existingAliases.set('ArraySlot', existingArraySlotAlias);
84
26
  }
85
27
 
86
- analyzed = {
87
- reactiveCalls: findReactiveCalls(sourceFile, checker),
88
- templates: findHtmlTemplates(sourceFile, checker)
89
- };
90
- }
28
+ let result = generateCode(templates, ctx.sourceFile, ctx.checker, existingAliases);
91
29
 
92
- let changed = false,
93
- codegenChanged = false,
94
- existingAliases = new Map<string, string>(),
95
- result = code;
96
-
97
- if (analyzed.reactiveCalls.length > 0) {
98
- changed = true;
99
- existingAliases.set('ArraySlot', uid('ArraySlot'));
100
- result = generateReactiveInlining(analyzed.reactiveCalls, result, sourceFile, existingAliases.get('ArraySlot')!);
101
- sourceFile = ts.createSourceFile(sourceFile.fileName, result, sourceFile.languageVersion, true);
102
-
103
- // Re-analyze templates after reactive inlining modifies the code
104
- analyzed = {
105
- reactiveCalls: [],
106
- templates: findHtmlTemplates(sourceFile, program.getTypeChecker())
107
- };
108
- }
109
-
110
- if (analyzed.templates.length > 0) {
111
- let codegenResult = generateCode(analyzed.templates, result, sourceFile, program.getTypeChecker(), existingAliases);
112
-
113
- if (codegenResult.changed) {
114
- changed = true;
115
- codegenChanged = true;
116
- result = codegenResult.code;
30
+ if (result.replacements.length === 0) {
31
+ return {};
117
32
  }
118
- }
119
33
 
120
- // Add aliased ArraySlot import if reactive calls were processed but codegen didn't run
121
- if (existingAliases.size > 0 && !codegenChanged) {
122
34
  let aliasedImports: string[] = [];
123
35
 
124
- for (let [name, alias] of existingAliases) {
36
+ for (let [name, alias] of result.imports) {
125
37
  aliasedImports.push(`${name} as ${alias}`);
126
38
  }
127
39
 
128
- result = imports.modify(result, sourceFile, PACKAGE, { add: aliasedImports });
129
- }
40
+ let imports: ImportIntent[] = [{
41
+ add: aliasedImports,
42
+ package: PACKAGE,
43
+ remove: [COMPILER_ENTRYPOINT]
44
+ }];
130
45
 
131
- if (changed) {
132
- sourceFile = ts.createSourceFile(sourceFile.fileName, result, sourceFile.languageVersion, true);
46
+ return {
47
+ imports,
48
+ prepend: result.prepend,
49
+ replacements: result.replacements
50
+ };
133
51
  }
134
-
135
- return { changed, code: result, sourceFile };
136
52
  };
137
53
 
138
54
 
139
- export { analyze, transform };
55
+ export default templatePlugin;
56
+ export { reactiveInliningPlugin, templatePlugin };
@@ -1,5 +1,5 @@
1
1
  import { plugin } from '@esportsplus/typescript/compiler';
2
- import { analyze, transform } from '..';
2
+ import { reactiveInliningPlugin, templatePlugin } from '..';
3
3
 
4
4
 
5
- export default plugin.tsc({ analyze, transform }) as ReturnType<typeof plugin.tsc>;
5
+ export default plugin.tsc([reactiveInliningPlugin, templatePlugin]) as ReturnType<typeof plugin.tsc>;
@@ -1,10 +1,9 @@
1
1
  import { plugin } from '@esportsplus/typescript/compiler';
2
2
  import { PACKAGE } from '../../constants';
3
- import { analyze, transform } from '..';
3
+ import { reactiveInliningPlugin, templatePlugin } from '..';
4
4
 
5
5
 
6
6
  export default plugin.vite({
7
- analyze,
8
7
  name: PACKAGE,
9
- transform
8
+ plugins: [reactiveInliningPlugin, templatePlugin]
10
9
  });
@@ -0,0 +1,116 @@
1
+ import type { Plugin, ReplacementIntent, TransformContext } from '@esportsplus/typescript/compiler';
2
+ import { ts } from '@esportsplus/typescript';
3
+ import { uid } from '@esportsplus/typescript/compiler';
4
+ import { COMPILER_ENTRYPOINT, COMPILER_ENTRYPOINT_REACTIVITY, PACKAGE } from '~/constants';
5
+
6
+
7
+ type ReactiveCallInfo = {
8
+ arrayArg: ts.Expression;
9
+ callbackArg: ts.Expression;
10
+ node: ts.CallExpression;
11
+ };
12
+
13
+
14
+ const PATTERNS = [`${COMPILER_ENTRYPOINT}.${COMPILER_ENTRYPOINT_REACTIVITY}`];
15
+
16
+ const SHARED_KEY = 'template:arraySlotAlias';
17
+
18
+
19
+ let printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
20
+
21
+
22
+ function isHtmlFromPackage(node: ts.Identifier, checker: ts.TypeChecker | undefined): boolean {
23
+ if (node.text !== COMPILER_ENTRYPOINT) {
24
+ return false;
25
+ }
26
+
27
+ if (!checker) {
28
+ return true;
29
+ }
30
+
31
+ let symbol = checker.getSymbolAtLocation(node);
32
+
33
+ if (!symbol) {
34
+ return true;
35
+ }
36
+
37
+ if (symbol.flags & ts.SymbolFlags.Alias) {
38
+ symbol = checker.getAliasedSymbol(symbol);
39
+ }
40
+
41
+ let declarations = symbol.getDeclarations();
42
+
43
+ if (!declarations || declarations.length === 0) {
44
+ return true;
45
+ }
46
+
47
+ for (let i = 0, n = declarations.length; i < n; i++) {
48
+ let filename = declarations[i].getSourceFile().fileName;
49
+
50
+ if (filename.includes(PACKAGE) || filename.includes('@esportsplus/template')) {
51
+ return true;
52
+ }
53
+ }
54
+
55
+ return false;
56
+ }
57
+
58
+ function visit(node: ts.Node, calls: ReactiveCallInfo[], checker: ts.TypeChecker | undefined): void {
59
+ if (
60
+ ts.isCallExpression(node) &&
61
+ ts.isPropertyAccessExpression(node.expression) &&
62
+ ts.isIdentifier(node.expression.expression) &&
63
+ node.expression.name.text === COMPILER_ENTRYPOINT_REACTIVITY &&
64
+ node.arguments.length === 2 &&
65
+ isHtmlFromPackage(node.expression.expression, checker)
66
+ ) {
67
+ calls.push({
68
+ arrayArg: node.arguments[0],
69
+ callbackArg: node.arguments[1],
70
+ node
71
+ });
72
+ }
73
+
74
+ ts.forEachChild(node, child => visit(child, calls, checker));
75
+ }
76
+
77
+
78
+ const plugin: Plugin = {
79
+ patterns: PATTERNS,
80
+
81
+ transform: (ctx: TransformContext) => {
82
+ let calls: ReactiveCallInfo[] = [];
83
+
84
+ visit(ctx.sourceFile, calls, ctx.checker);
85
+
86
+ if (calls.length === 0) {
87
+ return {};
88
+ }
89
+
90
+ let arraySlotAlias = uid('ArraySlot'),
91
+ replacements: ReplacementIntent[] = [];
92
+
93
+ ctx.shared.set(SHARED_KEY, arraySlotAlias);
94
+
95
+ for (let i = 0, n = calls.length; i < n; i++) {
96
+ let call = calls[i];
97
+
98
+ replacements.push({
99
+ generate: (sourceFile) => `new ${arraySlotAlias}(${printer.printNode(ts.EmitHint.Expression, call.arrayArg, sourceFile)}, ${printer.printNode(ts.EmitHint.Expression, call.callbackArg, sourceFile)})`,
100
+ node: call.node
101
+ });
102
+ }
103
+
104
+ return {
105
+ imports: [{
106
+ add: [`ArraySlot as ${arraySlotAlias}`],
107
+ package: PACKAGE
108
+ }],
109
+ replacements
110
+ };
111
+ }
112
+ };
113
+
114
+
115
+ export default plugin;
116
+ export { SHARED_KEY };