@defold-typescript/transpiler 0.1.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,4 @@
1
+ export type { TranspileSession } from "./session";
2
+ export { createTranspileSession } from "./session";
3
+ export type { TranspileDiagnostic, TranspileProjectInput, TranspileProjectResult, TranspileResult, } from "./transpile";
4
+ export { transpile, transpileProject } from "./transpile";
package/dist/index.js ADDED
@@ -0,0 +1,312 @@
1
+ // src/session.ts
2
+ import { readFileSync as readFileSync2 } from "node:fs";
3
+ import { createRequire as createRequire2 } from "node:module";
4
+ import * as path2 from "node:path";
5
+ import * as ts2 from "typescript";
6
+ import * as tstl2 from "typescript-to-lua";
7
+
8
+ // src/lifecycle-erasure.ts
9
+ import * as ts from "typescript";
10
+ import {
11
+ createAssignmentStatement,
12
+ createBlock,
13
+ createExpressionStatement,
14
+ createFunctionExpression,
15
+ createIdentifier,
16
+ NodeFlags
17
+ } from "typescript-to-lua";
18
+ var FACTORY_MODULE = "@defold-typescript/types";
19
+ var FACTORY_NAMES = new Set(["defineScript", "defineGuiScript", "defineRenderScript"]);
20
+ function resolvesToFactoryExport(callee, checker) {
21
+ let symbol = checker.getSymbolAtLocation(callee);
22
+ if (symbol === undefined) {
23
+ return false;
24
+ }
25
+ if (symbol.flags & ts.SymbolFlags.Alias) {
26
+ symbol = checker.getAliasedSymbol(symbol);
27
+ }
28
+ if (!FACTORY_NAMES.has(symbol.getName())) {
29
+ return false;
30
+ }
31
+ const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
32
+ if (declaration === undefined) {
33
+ return false;
34
+ }
35
+ return declaration.getSourceFile().fileName.includes(FACTORY_MODULE);
36
+ }
37
+ function isThisParameter(param) {
38
+ return ts.isIdentifier(param.name) && ts.identifierToKeywordKind(param.name) === ts.SyntaxKind.ThisKeyword;
39
+ }
40
+ function hookName(property) {
41
+ const name = property.name;
42
+ if (name === undefined) {
43
+ return;
44
+ }
45
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) {
46
+ return name.text;
47
+ }
48
+ return;
49
+ }
50
+ function hookFunction(property) {
51
+ if (ts.isMethodDeclaration(property) && property.body !== undefined) {
52
+ return property;
53
+ }
54
+ if (ts.isPropertyAssignment(property)) {
55
+ const initializer = property.initializer;
56
+ if (ts.isFunctionExpression(initializer) || ts.isArrowFunction(initializer)) {
57
+ return initializer;
58
+ }
59
+ }
60
+ return;
61
+ }
62
+ function transformHookBody(fn, context) {
63
+ if (ts.isBlock(fn.body)) {
64
+ return context.transformStatements(fn.body.statements);
65
+ }
66
+ return [createExpressionStatement(context.transformExpression(fn.body))];
67
+ }
68
+ function eraseFactoryCall(expression, context) {
69
+ if (!ts.isCallExpression(expression)) {
70
+ return;
71
+ }
72
+ if (!resolvesToFactoryExport(expression.expression, context.checker)) {
73
+ return;
74
+ }
75
+ const hooks = expression.arguments[0];
76
+ if (hooks === undefined || !ts.isObjectLiteralExpression(hooks)) {
77
+ return;
78
+ }
79
+ const statements = [];
80
+ for (const property of hooks.properties) {
81
+ const name = hookName(property);
82
+ const fn = hookFunction(property);
83
+ if (name === undefined || fn === undefined) {
84
+ continue;
85
+ }
86
+ const params = fn.parameters.filter((param) => !isThisParameter(param) && ts.isIdentifier(param.name)).map((param) => createIdentifier(param.name.text, param));
87
+ const fnExpression = createFunctionExpression(createBlock(transformHookBody(fn, context)), params, undefined, NodeFlags.Declaration, fn);
88
+ statements.push(createAssignmentStatement(createIdentifier(name), fnExpression, property));
89
+ }
90
+ return statements;
91
+ }
92
+ function isFactoryOnlyImport(node) {
93
+ if (!ts.isStringLiteral(node.moduleSpecifier) || node.moduleSpecifier.text !== FACTORY_MODULE) {
94
+ return false;
95
+ }
96
+ const clause = node.importClause;
97
+ if (clause === undefined || clause.name !== undefined) {
98
+ return false;
99
+ }
100
+ const bindings = clause.namedBindings;
101
+ if (bindings === undefined || !ts.isNamedImports(bindings)) {
102
+ return false;
103
+ }
104
+ return bindings.elements.every((element) => FACTORY_NAMES.has((element.propertyName ?? element.name).text));
105
+ }
106
+ var lifecycleErasurePlugin = {
107
+ visitors: {
108
+ [ts.SyntaxKind.ExpressionStatement]: (node, context) => eraseFactoryCall(node.expression, context) ?? context.superTransformStatements(node),
109
+ [ts.SyntaxKind.ExportAssignment]: (node, context) => {
110
+ if (!node.isExportEquals) {
111
+ const erased = eraseFactoryCall(node.expression, context);
112
+ if (erased !== undefined) {
113
+ return erased;
114
+ }
115
+ }
116
+ return context.superTransformStatements(node);
117
+ },
118
+ [ts.SyntaxKind.ImportDeclaration]: (node, context) => isFactoryOnlyImport(node) ? [] : context.superTransformStatements(node)
119
+ }
120
+ };
121
+
122
+ // src/transpile.ts
123
+ import { readFileSync } from "node:fs";
124
+ import { createRequire } from "node:module";
125
+ import * as path from "node:path";
126
+ import * as tstl from "typescript-to-lua";
127
+ function flattenDiagnosticMessage(text) {
128
+ if (typeof text === "string") {
129
+ return text;
130
+ }
131
+ return flattenDiagnosticMessage(text.messageText);
132
+ }
133
+ var requireFromHere = createRequire(import.meta.url);
134
+ var TYPES_PKG_ROOT = path.dirname(requireFromHere.resolve("@defold-typescript/types/package.json"));
135
+ var TSTL_LANG_EXT_ROOT = path.dirname(requireFromHere.resolve("@typescript-to-lua/language-extensions/package.json"));
136
+ function readAmbient(rel) {
137
+ return readFileSync(path.join(TYPES_PKG_ROOT, rel), "utf8");
138
+ }
139
+ var AMBIENT_FILES = {
140
+ "node_modules/@typescript-to-lua/language-extensions/index.d.ts": readFileSync(path.join(TSTL_LANG_EXT_ROOT, "index.d.ts"), "utf8"),
141
+ "node_modules/@defold-typescript/types/generated/vmath.d.ts": readAmbient("generated/vmath.d.ts"),
142
+ "node_modules/@defold-typescript/types/generated/msg.d.ts": readAmbient("generated/msg.d.ts"),
143
+ "node_modules/@defold-typescript/types/generated/go.d.ts": readAmbient("generated/go.d.ts"),
144
+ "node_modules/@defold-typescript/types/generated/builtin-messages.d.ts": readAmbient("generated/builtin-messages.d.ts"),
145
+ "node_modules/@defold-typescript/types/src/core-types.ts": readAmbient("src/core-types.ts"),
146
+ "node_modules/@defold-typescript/types/src/msg-overloads.d.ts": readAmbient("src/msg-overloads.d.ts"),
147
+ "node_modules/@defold-typescript/types/src/lifecycle.ts": readAmbient("src/lifecycle.ts"),
148
+ "node_modules/@defold-typescript/types/index.ts": `export { defineGuiScript, defineRenderScript, defineScript } from "./src/lifecycle";
149
+ `
150
+ };
151
+ function collectOutputs(transpiledFiles, diagnostics, userKeys) {
152
+ const lua = {};
153
+ const sourceMaps = {};
154
+ for (const file of transpiledFiles) {
155
+ const userSource = file.sourceFiles.find((s) => userKeys.has(s.fileName));
156
+ if (!userSource || typeof file.lua !== "string") {
157
+ continue;
158
+ }
159
+ lua[userSource.fileName] = file.lua;
160
+ if (typeof file.luaSourceMap === "string") {
161
+ sourceMaps[userSource.fileName] = file.luaSourceMap;
162
+ }
163
+ }
164
+ const collectedDiagnostics = diagnostics.map((d) => {
165
+ const fileName = d.file?.fileName;
166
+ const message = flattenDiagnosticMessage(d.messageText);
167
+ return fileName !== undefined && userKeys.has(fileName) ? { file: fileName, message } : { message };
168
+ });
169
+ return { lua, sourceMaps, diagnostics: collectedDiagnostics };
170
+ }
171
+ function transpileProject(input) {
172
+ const userKeys = new Set(Object.keys(input.files));
173
+ const merged = { ...AMBIENT_FILES, ...input.files };
174
+ const result = tstl.transpileVirtualProject(merged, {
175
+ luaTarget: tstl.LuaTarget.Lua54,
176
+ sourceMap: true,
177
+ luaPlugins: [{ plugin: lifecycleErasurePlugin }]
178
+ });
179
+ return collectOutputs(result.transpiledFiles, result.diagnostics, userKeys);
180
+ }
181
+ function transpile(source) {
182
+ const project = transpileProject({ files: { "main.ts": source } });
183
+ return {
184
+ lua: project.lua["main.ts"] ?? "",
185
+ sourceMap: project.sourceMaps["main.ts"] ?? "",
186
+ diagnostics: project.diagnostics.map((d) => d.message)
187
+ };
188
+ }
189
+
190
+ // src/session.ts
191
+ var requireFromHere2 = createRequire2(import.meta.url);
192
+ var COMPILER_OPTIONS = {
193
+ luaTarget: tstl2.LuaTarget.Lua54,
194
+ sourceMap: true,
195
+ luaPlugins: [{ plugin: lifecycleErasurePlugin }]
196
+ };
197
+ function normalizeSlashes(p) {
198
+ return p.replace(/\\/g, "/");
199
+ }
200
+ function createOutputCollector() {
201
+ const files = [];
202
+ const writeFile = (fileName, data, _bom, _onError, sourceFiles = []) => {
203
+ let file = files.find((f) => f.sourceFiles.some((s) => sourceFiles.includes(s)));
204
+ if (!file) {
205
+ file = { outPath: fileName, sourceFiles: [...sourceFiles] };
206
+ files.push(file);
207
+ } else {
208
+ for (const s of sourceFiles) {
209
+ if (!file.sourceFiles.includes(s)) {
210
+ file.sourceFiles.push(s);
211
+ }
212
+ }
213
+ }
214
+ if (fileName.endsWith(".lua")) {
215
+ file.lua = data;
216
+ } else if (fileName.endsWith(".lua.map")) {
217
+ file.luaSourceMap = data;
218
+ }
219
+ };
220
+ return { writeFile, files };
221
+ }
222
+ function createTranspileSession() {
223
+ const userFiles = new Map;
224
+ const sourceFileCache = new Map;
225
+ const libCache = new Map;
226
+ let program;
227
+ const getSourceFile = (fileName) => {
228
+ const normalized = normalizeSlashes(fileName);
229
+ const content = mergedContent(normalized);
230
+ if (content !== undefined) {
231
+ const cached = sourceFileCache.get(normalized);
232
+ if (cached) {
233
+ return cached;
234
+ }
235
+ const created = ts2.createSourceFile(fileName, content, ts2.ScriptTarget.Latest, false);
236
+ sourceFileCache.set(normalized, created);
237
+ return created;
238
+ }
239
+ let filePath;
240
+ if (fileName.startsWith("lib.")) {
241
+ const typeScriptDir = path2.dirname(requireFromHere2.resolve("typescript"));
242
+ filePath = path2.join(typeScriptDir, fileName);
243
+ }
244
+ if (fileName.includes("language-extensions")) {
245
+ const dtsName = fileName.replace(/(\.d)?(\.ts)$/, ".d.ts");
246
+ filePath = path2.resolve(dtsName);
247
+ }
248
+ if (filePath !== undefined) {
249
+ const cached = libCache.get(fileName);
250
+ if (cached) {
251
+ return cached;
252
+ }
253
+ const fileContent = readFileSync2(filePath, "utf8");
254
+ const created = ts2.createSourceFile(filePath, fileContent, ts2.ScriptTarget.Latest, false);
255
+ libCache.set(fileName, created);
256
+ return created;
257
+ }
258
+ return;
259
+ };
260
+ function mergedContent(normalized) {
261
+ if (userFiles.has(normalized)) {
262
+ return userFiles.get(normalized);
263
+ }
264
+ return AMBIENT_FILES[normalized];
265
+ }
266
+ const host = {
267
+ fileExists: (fileName) => mergedContent(normalizeSlashes(fileName)) !== undefined || ts2.sys.fileExists(fileName),
268
+ getCanonicalFileName: (fileName) => fileName,
269
+ getCurrentDirectory: () => "",
270
+ getDefaultLibFileName: ts2.getDefaultLibFileName,
271
+ readFile: () => "",
272
+ getNewLine: () => `
273
+ `,
274
+ useCaseSensitiveFileNames: () => false,
275
+ writeFile() {},
276
+ getSourceFile
277
+ };
278
+ function update(changes) {
279
+ for (const [name, value] of Object.entries(changes)) {
280
+ const normalized = normalizeSlashes(name);
281
+ if (value === null) {
282
+ userFiles.delete(normalized);
283
+ sourceFileCache.delete(normalized);
284
+ } else {
285
+ userFiles.set(normalized, value);
286
+ sourceFileCache.delete(normalized);
287
+ }
288
+ }
289
+ const userKeys = new Set(userFiles.keys());
290
+ const rootNames = [...Object.keys(AMBIENT_FILES).map(normalizeSlashes), ...userKeys];
291
+ program = ts2.createProgram(rootNames, COMPILER_OPTIONS, host, program);
292
+ const preEmitDiagnostics = ts2.getPreEmitDiagnostics(program);
293
+ const collector = createOutputCollector();
294
+ const { diagnostics: transpileDiagnostics } = new tstl2.Transpiler().emit({
295
+ program,
296
+ writeFile: collector.writeFile
297
+ });
298
+ const diagnostics = [
299
+ ...ts2.sortAndDeduplicateDiagnostics([...preEmitDiagnostics, ...transpileDiagnostics])
300
+ ];
301
+ return collectOutputs(collector.files, diagnostics, userKeys);
302
+ }
303
+ return {
304
+ update,
305
+ getProgram: () => program
306
+ };
307
+ }
308
+ export {
309
+ transpileProject,
310
+ transpile,
311
+ createTranspileSession
312
+ };
@@ -0,0 +1,2 @@
1
+ import { type Plugin } from "typescript-to-lua";
2
+ export declare const lifecycleErasurePlugin: Plugin;
@@ -0,0 +1,7 @@
1
+ import * as ts from "typescript";
2
+ import { type TranspileProjectResult } from "./transpile";
3
+ export interface TranspileSession {
4
+ update(changes: Readonly<Record<string, string | null>>): TranspileProjectResult;
5
+ getProgram(): ts.Program | undefined;
6
+ }
7
+ export declare function createTranspileSession(): TranspileSession;
@@ -0,0 +1,28 @@
1
+ import type * as ts from "typescript";
2
+ export interface TranspileResult {
3
+ readonly lua: string;
4
+ readonly sourceMap: string;
5
+ readonly diagnostics: readonly string[];
6
+ }
7
+ export interface TranspileDiagnostic {
8
+ readonly file?: string;
9
+ readonly message: string;
10
+ }
11
+ export interface TranspileProjectInput {
12
+ readonly files: Readonly<Record<string, string>>;
13
+ }
14
+ export interface TranspileProjectResult {
15
+ readonly lua: Readonly<Record<string, string>>;
16
+ readonly sourceMaps: Readonly<Record<string, string>>;
17
+ readonly diagnostics: readonly TranspileDiagnostic[];
18
+ }
19
+ export declare const AMBIENT_FILES: Readonly<Record<string, string>>;
20
+ interface CollectableFile {
21
+ readonly sourceFiles: readonly ts.SourceFile[];
22
+ readonly lua?: string;
23
+ readonly luaSourceMap?: string;
24
+ }
25
+ export declare function collectOutputs(transpiledFiles: readonly CollectableFile[], diagnostics: readonly ts.Diagnostic[], userKeys: ReadonlySet<string>): TranspileProjectResult;
26
+ export declare function transpileProject(input: TranspileProjectInput): TranspileProjectResult;
27
+ export declare function transpile(source: string): TranspileResult;
28
+ export {};
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@defold-typescript/transpiler",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript-to-Lua build pipeline tuned for Defold's runtime.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "bun": "./src/index.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src",
18
+ "!src/**/*.test.ts",
19
+ "!src/**/__snapshots__"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "build": "rm -rf dist && bun build src/index.ts --target=node --format=esm --packages=external --outdir=dist && tsc -p tsconfig.build.json --emitDeclarationOnly",
26
+ "typecheck": "tsc -p tsconfig.json --noEmit",
27
+ "test": "bun test"
28
+ },
29
+ "dependencies": {
30
+ "@defold-typescript/types": "0.1.0",
31
+ "@typescript-to-lua/language-extensions": "1.19.0",
32
+ "typescript-to-lua": "^1.36.0"
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export type { TranspileSession } from "./session";
2
+ export { createTranspileSession } from "./session";
3
+ export type {
4
+ TranspileDiagnostic,
5
+ TranspileProjectInput,
6
+ TranspileProjectResult,
7
+ TranspileResult,
8
+ } from "./transpile";
9
+ export { transpile, transpileProject } from "./transpile";
@@ -0,0 +1,147 @@
1
+ import * as ts from "typescript";
2
+ import {
3
+ createAssignmentStatement,
4
+ createBlock,
5
+ createExpressionStatement,
6
+ createFunctionExpression,
7
+ createIdentifier,
8
+ type Identifier,
9
+ NodeFlags,
10
+ type Plugin,
11
+ type Statement,
12
+ type TransformationContext,
13
+ } from "typescript-to-lua";
14
+
15
+ const FACTORY_MODULE = "@defold-typescript/types";
16
+ const FACTORY_NAMES = new Set(["defineScript", "defineGuiScript", "defineRenderScript"]);
17
+
18
+ function resolvesToFactoryExport(callee: ts.Expression, checker: ts.TypeChecker): boolean {
19
+ let symbol = checker.getSymbolAtLocation(callee);
20
+ if (symbol === undefined) {
21
+ return false;
22
+ }
23
+ if (symbol.flags & ts.SymbolFlags.Alias) {
24
+ symbol = checker.getAliasedSymbol(symbol);
25
+ }
26
+ if (!FACTORY_NAMES.has(symbol.getName())) {
27
+ return false;
28
+ }
29
+ const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
30
+ if (declaration === undefined) {
31
+ return false;
32
+ }
33
+ return declaration.getSourceFile().fileName.includes(FACTORY_MODULE);
34
+ }
35
+
36
+ function isThisParameter(param: ts.ParameterDeclaration): boolean {
37
+ return (
38
+ ts.isIdentifier(param.name) &&
39
+ ts.identifierToKeywordKind(param.name) === ts.SyntaxKind.ThisKeyword
40
+ );
41
+ }
42
+
43
+ function hookName(property: ts.ObjectLiteralElementLike): string | undefined {
44
+ const name = property.name;
45
+ if (name === undefined) {
46
+ return undefined;
47
+ }
48
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) {
49
+ return name.text;
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ type HookFunction = (ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction) & {
55
+ body: ts.ConciseBody;
56
+ };
57
+
58
+ function hookFunction(property: ts.ObjectLiteralElementLike): HookFunction | undefined {
59
+ if (ts.isMethodDeclaration(property) && property.body !== undefined) {
60
+ return property as HookFunction;
61
+ }
62
+ if (ts.isPropertyAssignment(property)) {
63
+ const initializer = property.initializer;
64
+ if (ts.isFunctionExpression(initializer) || ts.isArrowFunction(initializer)) {
65
+ return initializer as HookFunction;
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function transformHookBody(fn: HookFunction, context: TransformationContext): Statement[] {
72
+ if (ts.isBlock(fn.body)) {
73
+ return context.transformStatements(fn.body.statements);
74
+ }
75
+ return [createExpressionStatement(context.transformExpression(fn.body))];
76
+ }
77
+
78
+ function eraseFactoryCall(
79
+ expression: ts.Expression,
80
+ context: TransformationContext,
81
+ ): Statement[] | undefined {
82
+ if (!ts.isCallExpression(expression)) {
83
+ return undefined;
84
+ }
85
+ if (!resolvesToFactoryExport(expression.expression, context.checker)) {
86
+ return undefined;
87
+ }
88
+ const hooks = expression.arguments[0];
89
+ if (hooks === undefined || !ts.isObjectLiteralExpression(hooks)) {
90
+ return undefined;
91
+ }
92
+ const statements: Statement[] = [];
93
+ for (const property of hooks.properties) {
94
+ const name = hookName(property);
95
+ const fn = hookFunction(property);
96
+ if (name === undefined || fn === undefined) {
97
+ continue;
98
+ }
99
+ const params: Identifier[] = fn.parameters
100
+ .filter((param) => !isThisParameter(param) && ts.isIdentifier(param.name))
101
+ .map((param) => createIdentifier((param.name as ts.Identifier).text, param));
102
+ const fnExpression = createFunctionExpression(
103
+ createBlock(transformHookBody(fn, context)),
104
+ params,
105
+ undefined,
106
+ NodeFlags.Declaration,
107
+ fn,
108
+ );
109
+ statements.push(createAssignmentStatement(createIdentifier(name), fnExpression, property));
110
+ }
111
+ return statements;
112
+ }
113
+
114
+ function isFactoryOnlyImport(node: ts.ImportDeclaration): boolean {
115
+ if (!ts.isStringLiteral(node.moduleSpecifier) || node.moduleSpecifier.text !== FACTORY_MODULE) {
116
+ return false;
117
+ }
118
+ const clause = node.importClause;
119
+ if (clause === undefined || clause.name !== undefined) {
120
+ return false;
121
+ }
122
+ const bindings = clause.namedBindings;
123
+ if (bindings === undefined || !ts.isNamedImports(bindings)) {
124
+ return false;
125
+ }
126
+ return bindings.elements.every((element) =>
127
+ FACTORY_NAMES.has((element.propertyName ?? element.name).text),
128
+ );
129
+ }
130
+
131
+ export const lifecycleErasurePlugin: Plugin = {
132
+ visitors: {
133
+ [ts.SyntaxKind.ExpressionStatement]: (node, context) =>
134
+ eraseFactoryCall(node.expression, context) ?? context.superTransformStatements(node),
135
+ [ts.SyntaxKind.ExportAssignment]: (node, context) => {
136
+ if (!node.isExportEquals) {
137
+ const erased = eraseFactoryCall(node.expression, context);
138
+ if (erased !== undefined) {
139
+ return erased;
140
+ }
141
+ }
142
+ return context.superTransformStatements(node);
143
+ },
144
+ [ts.SyntaxKind.ImportDeclaration]: (node, context) =>
145
+ isFactoryOnlyImport(node) ? [] : context.superTransformStatements(node),
146
+ },
147
+ };
package/src/session.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import * as path from "node:path";
4
+ import * as ts from "typescript";
5
+ import * as tstl from "typescript-to-lua";
6
+ import { lifecycleErasurePlugin } from "./lifecycle-erasure";
7
+ import { AMBIENT_FILES, collectOutputs, type TranspileProjectResult } from "./transpile";
8
+
9
+ export interface TranspileSession {
10
+ update(changes: Readonly<Record<string, string | null>>): TranspileProjectResult;
11
+ getProgram(): ts.Program | undefined;
12
+ }
13
+
14
+ const requireFromHere = createRequire(import.meta.url);
15
+
16
+ const COMPILER_OPTIONS: tstl.CompilerOptions = {
17
+ luaTarget: tstl.LuaTarget.Lua54,
18
+ sourceMap: true,
19
+ luaPlugins: [{ plugin: lifecycleErasurePlugin }],
20
+ };
21
+
22
+ function normalizeSlashes(p: string): string {
23
+ return p.replace(/\\/g, "/");
24
+ }
25
+
26
+ interface CollectorFile {
27
+ outPath: string;
28
+ sourceFiles: ts.SourceFile[];
29
+ lua?: string;
30
+ luaSourceMap?: string;
31
+ }
32
+
33
+ function createOutputCollector(): { writeFile: ts.WriteFileCallback; files: CollectorFile[] } {
34
+ const files: CollectorFile[] = [];
35
+ const writeFile: ts.WriteFileCallback = (fileName, data, _bom, _onError, sourceFiles = []) => {
36
+ let file = files.find((f) => f.sourceFiles.some((s) => sourceFiles.includes(s)));
37
+ if (!file) {
38
+ file = { outPath: fileName, sourceFiles: [...sourceFiles] };
39
+ files.push(file);
40
+ } else {
41
+ for (const s of sourceFiles) {
42
+ if (!file.sourceFiles.includes(s)) {
43
+ file.sourceFiles.push(s);
44
+ }
45
+ }
46
+ }
47
+ if (fileName.endsWith(".lua")) {
48
+ file.lua = data;
49
+ } else if (fileName.endsWith(".lua.map")) {
50
+ file.luaSourceMap = data;
51
+ }
52
+ };
53
+ return { writeFile, files };
54
+ }
55
+
56
+ export function createTranspileSession(): TranspileSession {
57
+ const userFiles = new Map<string, string>();
58
+ const sourceFileCache = new Map<string, ts.SourceFile>();
59
+ const libCache = new Map<string, ts.SourceFile>();
60
+ let program: ts.Program | undefined;
61
+
62
+ const getSourceFile: ts.CompilerHost["getSourceFile"] = (fileName) => {
63
+ const normalized = normalizeSlashes(fileName);
64
+ const content = mergedContent(normalized);
65
+ if (content !== undefined) {
66
+ const cached = sourceFileCache.get(normalized);
67
+ if (cached) {
68
+ return cached;
69
+ }
70
+ const created = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, false);
71
+ sourceFileCache.set(normalized, created);
72
+ return created;
73
+ }
74
+
75
+ let filePath: string | undefined;
76
+ if (fileName.startsWith("lib.")) {
77
+ const typeScriptDir = path.dirname(requireFromHere.resolve("typescript"));
78
+ filePath = path.join(typeScriptDir, fileName);
79
+ }
80
+ if (fileName.includes("language-extensions")) {
81
+ const dtsName = fileName.replace(/(\.d)?(\.ts)$/, ".d.ts");
82
+ filePath = path.resolve(dtsName);
83
+ }
84
+ if (filePath !== undefined) {
85
+ const cached = libCache.get(fileName);
86
+ if (cached) {
87
+ return cached;
88
+ }
89
+ const fileContent = readFileSync(filePath, "utf8");
90
+ const created = ts.createSourceFile(filePath, fileContent, ts.ScriptTarget.Latest, false);
91
+ libCache.set(fileName, created);
92
+ return created;
93
+ }
94
+ return undefined;
95
+ };
96
+
97
+ function mergedContent(normalized: string): string | undefined {
98
+ if (userFiles.has(normalized)) {
99
+ return userFiles.get(normalized);
100
+ }
101
+ return AMBIENT_FILES[normalized];
102
+ }
103
+
104
+ const host: ts.CompilerHost = {
105
+ fileExists: (fileName) =>
106
+ mergedContent(normalizeSlashes(fileName)) !== undefined || ts.sys.fileExists(fileName),
107
+ getCanonicalFileName: (fileName) => fileName,
108
+ getCurrentDirectory: () => "",
109
+ getDefaultLibFileName: ts.getDefaultLibFileName,
110
+ readFile: () => "",
111
+ getNewLine: () => "\n",
112
+ useCaseSensitiveFileNames: () => false,
113
+ writeFile() {},
114
+ getSourceFile,
115
+ };
116
+
117
+ function update(changes: Readonly<Record<string, string | null>>): TranspileProjectResult {
118
+ for (const [name, value] of Object.entries(changes)) {
119
+ const normalized = normalizeSlashes(name);
120
+ if (value === null) {
121
+ userFiles.delete(normalized);
122
+ sourceFileCache.delete(normalized);
123
+ } else {
124
+ userFiles.set(normalized, value);
125
+ sourceFileCache.delete(normalized);
126
+ }
127
+ }
128
+
129
+ const userKeys = new Set(userFiles.keys());
130
+ const rootNames = [...Object.keys(AMBIENT_FILES).map(normalizeSlashes), ...userKeys];
131
+
132
+ program = ts.createProgram(rootNames, COMPILER_OPTIONS, host, program);
133
+
134
+ const preEmitDiagnostics = ts.getPreEmitDiagnostics(program);
135
+ const collector = createOutputCollector();
136
+ const { diagnostics: transpileDiagnostics } = new tstl.Transpiler().emit({
137
+ program,
138
+ writeFile: collector.writeFile,
139
+ });
140
+ const diagnostics = [
141
+ ...ts.sortAndDeduplicateDiagnostics([...preEmitDiagnostics, ...transpileDiagnostics]),
142
+ ];
143
+
144
+ return collectOutputs(collector.files, diagnostics, userKeys);
145
+ }
146
+
147
+ return {
148
+ update,
149
+ getProgram: () => program,
150
+ };
151
+ }
@@ -0,0 +1,124 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import * as path from "node:path";
4
+ import type * as ts from "typescript";
5
+ import * as tstl from "typescript-to-lua";
6
+ import { lifecycleErasurePlugin } from "./lifecycle-erasure";
7
+
8
+ export interface TranspileResult {
9
+ readonly lua: string;
10
+ readonly sourceMap: string;
11
+ readonly diagnostics: readonly string[];
12
+ }
13
+
14
+ export interface TranspileDiagnostic {
15
+ readonly file?: string;
16
+ readonly message: string;
17
+ }
18
+
19
+ export interface TranspileProjectInput {
20
+ readonly files: Readonly<Record<string, string>>;
21
+ }
22
+
23
+ export interface TranspileProjectResult {
24
+ readonly lua: Readonly<Record<string, string>>;
25
+ readonly sourceMaps: Readonly<Record<string, string>>;
26
+ readonly diagnostics: readonly TranspileDiagnostic[];
27
+ }
28
+
29
+ function flattenDiagnosticMessage(
30
+ text: string | { messageText: string | { messageText: string } },
31
+ ): string {
32
+ if (typeof text === "string") {
33
+ return text;
34
+ }
35
+ return flattenDiagnosticMessage(text.messageText);
36
+ }
37
+
38
+ const requireFromHere = createRequire(import.meta.url);
39
+ const TYPES_PKG_ROOT = path.dirname(
40
+ requireFromHere.resolve("@defold-typescript/types/package.json"),
41
+ );
42
+ const TSTL_LANG_EXT_ROOT = path.dirname(
43
+ requireFromHere.resolve("@typescript-to-lua/language-extensions/package.json"),
44
+ );
45
+
46
+ function readAmbient(rel: string): string {
47
+ return readFileSync(path.join(TYPES_PKG_ROOT, rel), "utf8");
48
+ }
49
+
50
+ export const AMBIENT_FILES: Readonly<Record<string, string>> = {
51
+ "node_modules/@typescript-to-lua/language-extensions/index.d.ts": readFileSync(
52
+ path.join(TSTL_LANG_EXT_ROOT, "index.d.ts"),
53
+ "utf8",
54
+ ),
55
+ "node_modules/@defold-typescript/types/generated/vmath.d.ts": readAmbient("generated/vmath.d.ts"),
56
+ "node_modules/@defold-typescript/types/generated/msg.d.ts": readAmbient("generated/msg.d.ts"),
57
+ "node_modules/@defold-typescript/types/generated/go.d.ts": readAmbient("generated/go.d.ts"),
58
+ "node_modules/@defold-typescript/types/generated/builtin-messages.d.ts": readAmbient(
59
+ "generated/builtin-messages.d.ts",
60
+ ),
61
+ "node_modules/@defold-typescript/types/src/core-types.ts": readAmbient("src/core-types.ts"),
62
+ "node_modules/@defold-typescript/types/src/msg-overloads.d.ts":
63
+ readAmbient("src/msg-overloads.d.ts"),
64
+ "node_modules/@defold-typescript/types/src/lifecycle.ts": readAmbient("src/lifecycle.ts"),
65
+ "node_modules/@defold-typescript/types/index.ts":
66
+ 'export { defineGuiScript, defineRenderScript, defineScript } from "./src/lifecycle";\n',
67
+ };
68
+
69
+ interface CollectableFile {
70
+ readonly sourceFiles: readonly ts.SourceFile[];
71
+ readonly lua?: string;
72
+ readonly luaSourceMap?: string;
73
+ }
74
+
75
+ export function collectOutputs(
76
+ transpiledFiles: readonly CollectableFile[],
77
+ diagnostics: readonly ts.Diagnostic[],
78
+ userKeys: ReadonlySet<string>,
79
+ ): TranspileProjectResult {
80
+ const lua: Record<string, string> = {};
81
+ const sourceMaps: Record<string, string> = {};
82
+ for (const file of transpiledFiles) {
83
+ const userSource = file.sourceFiles.find((s) => userKeys.has(s.fileName));
84
+ if (!userSource || typeof file.lua !== "string") {
85
+ continue;
86
+ }
87
+ lua[userSource.fileName] = file.lua;
88
+ if (typeof file.luaSourceMap === "string") {
89
+ sourceMaps[userSource.fileName] = file.luaSourceMap;
90
+ }
91
+ }
92
+
93
+ const collectedDiagnostics: TranspileDiagnostic[] = diagnostics.map((d) => {
94
+ const fileName = d.file?.fileName;
95
+ const message = flattenDiagnosticMessage(d.messageText);
96
+ return fileName !== undefined && userKeys.has(fileName)
97
+ ? { file: fileName, message }
98
+ : { message };
99
+ });
100
+
101
+ return { lua, sourceMaps, diagnostics: collectedDiagnostics };
102
+ }
103
+
104
+ export function transpileProject(input: TranspileProjectInput): TranspileProjectResult {
105
+ const userKeys = new Set(Object.keys(input.files));
106
+ const merged: Record<string, string> = { ...AMBIENT_FILES, ...input.files };
107
+
108
+ const result = tstl.transpileVirtualProject(merged, {
109
+ luaTarget: tstl.LuaTarget.Lua54,
110
+ sourceMap: true,
111
+ luaPlugins: [{ plugin: lifecycleErasurePlugin }],
112
+ });
113
+
114
+ return collectOutputs(result.transpiledFiles, result.diagnostics, userKeys);
115
+ }
116
+
117
+ export function transpile(source: string): TranspileResult {
118
+ const project = transpileProject({ files: { "main.ts": source } });
119
+ return {
120
+ lua: project.lua["main.ts"] ?? "",
121
+ sourceMap: project.sourceMaps["main.ts"] ?? "",
122
+ diagnostics: project.diagnostics.map((d) => d.message),
123
+ };
124
+ }