import * as ts from "typescript"; import * as tstl from "typescript-to-lua"; import * as path from "node:path"; import { readFileSync, existsSync, ensureDirSync, mkdtempSync } from "fs-extra"; import * as os from "node:os"; require('dotenv').config({ path: 'ets.env' }); type RequiredDefintion = { module: string, variable: string } const requires: Map = new Map(); const resolvedModules: string[] = []; /** * Identifies the require symbol in code and parses into module and path. */ function requireSymbol(code: string): string[] | null { let matcher = code.match(/____(.+) = require\(\"(.+)\"\)/); if(matcher) { return new Array(matcher[1], matcher[2]); } else { return null; } } /** * This creates a consistent key for use in the source map that can identify values * in both beforeEmit and afterPrint hooks */ function keyifyFile(file: string, program: ts.Program): string { const newpath = file.replace(tstl.getProjectRoot(program), '').replace(path.normalize('/dist'), ''); return newpath.replace(/\.ts|\.lua/g, ''); } /** * Builds a source map of modules to their code. */ function buildSourceMap(files: tstl.EmitFile[], program: ts.Program) { const sourceMap: Map = new Map(); for(const file of files) { const outDir = program.getCompilerOptions().outDir || ''; let sourceKey = file.outputPath.replace(outDir + '/', ''); sourceKey = sourceKey.replace('.lua', ''); sourceMap.set(sourceKey, file.code); } return sourceMap; } /** * This recursively (I hope) resolves module names to their respective code in the sourceMap * that is built from files during the before Emit process. * It only resolves modules once to avoid collistions in AddOn Namespace. */ function resolveRequire(modulepath: string, sourceMap: Map, code?: string): string { // de-lua-ify the module name to a path. let filepath = modulepath.replace(/\./g, "/"); let output: string = code || ''; console.log("client files modules being resolved: ", filepath); // skip resolved modules only want to include them once to avoid namespace issues. if(resolvedModules.includes(modulepath)) { console.log('found already resolved module: ', modulepath); // return ''; } // have to check if the file we are resolving also has requires that need to be resolved. let filecode = sourceMap.get(filepath) ?? ''; if(requireSymbol(filecode)) { for(const line of filecode.split("\n")) { if(line.includes("lualib")) { filecode = filecode.replace(line+"\n", ''); continue; } const [variable, module] = requireSymbol(line) ?? ["", ""]; if(module) { output += resolveRequire(module, sourceMap, output); } } } output += filecode; resolvedModules.push(modulepath); return output; } /** * Removes tstl code that is added expecting the code to be exported only used for resolving module code. */ function resolveExports(code: string, name: string) { return code .replace(/local ____exports = \{\}/g,'') .replace(/return ____exports/g,'') .replace(/____exports/g, `____${name}`); } function transpileClientFile(file: string) { const tmpPath = path.join(os.tmpdir(), 'ets-compile') + '/' + new Date().getTime().toString(); const result = tstl.transpileFiles([file],{ outDir: tmpPath, luaLibImport: tstl.LuaLibImportKind.Inline, luaTarget: tstl.LuaTarget.Lua52, strict: false, target: ts.ScriptTarget.ESNext, skipLibCheck: true, noHeader: true, lib: [ 'lib.esnext.d.ts', 'lib.dom.d.ts' ], types: [ 'lua-types/5.2', '@typescript-to-lua/language-extensions', 'wow-eluna-ts-module', '@araxiaonline/wow-wotlk-declarations', 'node' ], }); return result; } /** * The default mechanism for transpiling files is "require" the common Polyfill bundle code. This is actually great * for server side code, however breaks client side code. So this hook transpiles the client code with "inline" * setting so any used Polyfills from TS are added inline into the source code removing the require to lualib modules * tstl puts in. * * This also handles building a map of the requires that will later be resolved in the beforeEmit handler. */ function afterPrint( program: ts.Program, options: tstl.CompilerOptions, emitHost: tstl.EmitHost, result: tstl.ProcessedFile[] ) { for (const file of result) { const mapKey = keyifyFile(file.fileName, program); if (file.fileName.includes(".client.ts")) { const sourceCode = readFileSync(file.fileName, "utf-8"); const tmpPath = path.join(os.tmpdir(), 'ets-compile', new Date().getTime().toString()); const result = tstl.transpileFiles([file.fileName],{ outDir: tmpPath, luaLibImport: tstl.LuaLibImportKind.Inline, luaTarget: tstl.LuaTarget.Lua52, strict: false, target: ts.ScriptTarget.ESNext, skipLibCheck: true, noHeader: true, lib: [ 'lib.esnext.d.ts', 'lib.dom.d.ts' ], types: [ 'lua-types/5.2', '@typescript-to-lua/language-extensions', 'wow-eluna-ts-module', '@araxiaonline/wow-wotlk-declarations', 'node' ], }); result.diagnostics.forEach((d) => { console.error(`\x1b[31mTRANSPILE ERROR: ${d.messageText}\x1b[0m`); }); const luaPath = file.fileName.replace(tstl.getSourceDir(program), '').replace('.ts', '.lua'); const fallback = path.join(tmpPath, path.basename(file.fileName.replace('.ts', '.lua'))); let transpiled:string | null = null; if(existsSync(path.join(tmpPath,luaPath))) { transpiled = readFileSync(path.join(tmpPath,luaPath), 'utf-8'); } else if(existsSync(fallback)) { transpiled = readFileSync(fallback, 'utf-8'); } else { console.error(`\x1b[31mTRANSPILE ERROR: ${fallback} not found in ${tmpPath}\x1b[0m`); } file.code = transpiled ?? file.code; // not effective way of doing this walking each line parsing for requires using regex :D for (const line of file.code.split("\n")) { let [variable, module] = requireSymbol(line) ?? ["", ""]; if (module && module !== "AIO") { // handle relative links let relModule = module; if(module.indexOf(".") === -1) { relModule = path.join(path.dirname(luaPath), module); module = relModule.replace(/^\//, '').replace(/\//g, '.'); } console.log(`Client Module Dependencies: variable: ${variable}, module: ${module}`); file.code = file.code.replace(line, `local ____${variable} = {}\n-- INLINE(${module})`); const currentRequires = requires.get(mapKey) ?? []; requires.set(mapKey, [...currentRequires, { module, variable }]); } } } } } /** * This plugin will add AIO to the transpile process from TS assuming * there is a Global installation of AIO installed in the directory, which is * that follows Rochet2's instructions. * * Jan 31, 2024 Update * The WoW Api Client does not support requires in Lua, instead it uses .toc files / xml which are not * currently supported in AIO. * * When TypescriptToLua handles imports it replaces them with lua require statements. This creates a problem * in runtime as that method is not present. This plugin now handles that by resolving the AIO client files * by resolving the code and placing at the top of the transpiled file. This allows imports * to effectively function as they would in TS land for client code. * * Caveats: * - Your code must use the pattern if(!AIO.AddAddOn()) I realize there is otherways to use this but this is how the code identifies client files currently * - filenames have to be *client.ts making it reserved now * - the functions methods are all in the same namespace which could cause conflicts, I believe I handeled them in the background * but have not done thorough user testing to verify * - Don't go crazy my methodology here is pretty simple and it is going to work best for sharing common functions / constants / enums etcs that * are often repeated when building UI components. * * @author @ben-of-codecraft * @since 2024-01-31 */ const plugin: tstl.Plugin = { afterPrint, beforeEmit( program: ts.Program, options: tstl.CompilerOptions, emitHost: tstl.EmitHost, result: tstl.EmitFile[], ) { // build a source map first for resolving requires const sourceMap = buildSourceMap(result, program); for (const file of result) { const mapKey = keyifyFile(file.outputPath, program); if(file.code.includes("aio = {}")) { // Handle necessary AIO replaces post transpile file.code = file.code.replace("-- @ts-expect-error", ""); file.code = file.code.replace("aio = {}", "local AIO = AIO or require(\"AIO\")"); file.code = file.code.replace(/aio[\.\:]/g, "AIO."); // Is targetted for AIO Client. if(file.code.includes("if not AIO.AddAddon() then")) { requires.forEach( (requiredModules: RequiredDefintion[], caller) => { if(mapKey !== caller) { return; } requiredModules.forEach((requiredModule) => { const moduleCode = resolveExports(resolveRequire(requiredModule.module, sourceMap), requiredModule.variable); if(!moduleCode) { console.error(`Module ${requiredModule.module} code not found in source map failed to resolve`); return; } file.code = file.code.replace(`-- INLINE(${requiredModule.module})\n`, `-- INLINE(${requiredModule.module})\n` + moduleCode); }); }); } /* Weirdness with import transpilation "local local"*/ file.code = file.code.replace("local local AIO = AIO or require(\"AIO\")", "local AIO = AIO or require(\"AIO\")"); } } } }; export default plugin;