// Mechanical CommonJS -> ESM conversion of desktop LS Tools modules (no logic changes). // usage: node tools/port-module.mjs // const x = require("y"); -> import x from "y"; // const { a, b: c } = require("y"); -> import { a, b as c } from "y"; // module.exports = { A, B: C }; -> export { A, C as B }; // module.exports = X; -> export default X; // Relative requires get ".js" added. Anything else that is not this simple is reported, so it // gets ported by hand instead of guessed. import fs from "node:fs"; import * as acorn from "acorn"; const [src, dst] = process.argv.slice(2); let text = fs.readFileSync(src, "utf8"); const problems = []; const fixSpec = (s) => (s.startsWith(".") && !s.endsWith(".js") && !s.endsWith(".json") ? s + ".js" : s); text = text.replace(/^const (\w+) = require\("([^"]+)"\);?$/gm, (_, name, spec) => `import ${name} from "${fixSpec(spec)}";`); text = text.replace(/^const \{([^}]+)\} = require\("([^"]+)"\);?$/gm, (_, names, spec) => { const list = names.split(",").map((n) => n.trim()).filter(Boolean).map((n) => n.replace(/^(\w+)\s*:\s*(\w+)$/, "$1 as $2")); return `import { ${list.join(", ")} } from "${fixSpec(spec)}";`; }); // module.exports = { ... } is parsed (acorn), not split on commas: values can be whole arrow // functions or methods with commas inside. { const ast = acorn.parse(text, { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true, allowAwaitOutsideFunction: true }); const st = ast.body.find( (n) => n.type === "ExpressionStatement" && n.expression.type === "AssignmentExpression" && n.expression.left.type === "MemberExpression" && n.expression.left.object.name === "module" && n.expression.left.property.name === "exports" && n.expression.right.type === "ObjectExpression", ); if (st) { const consts = []; const list = []; for (const p of st.expression.right.properties) { if (p.type !== "Property" || p.computed) { problems.push(`module.exports: unsupported property ${text.slice(p.start, p.end).slice(0, 60)}`); continue; } const key = p.key.type === "Identifier" ? p.key.name : String(p.key.value); if (p.shorthand) list.push([key, key]); else if (!p.method && p.value.type === "Identifier") list.push([p.value.name, key]); else if (p.method || p.kind !== "init") { // method shorthand / getter: keep the exact source inside a one-property object consts.push(`const __export_${key} = ({ ${text.slice(p.start, p.end)} }).${key};`); list.push([`__export_${key}`, key]); } else { // key: expression (e.g. texture: assets.texture, save: async (...) => {...}) consts.push(`const __export_${key} = ${text.slice(p.value.start, p.value.end)};`); list.push([`__export_${key}`, key]); } } const named = list.map(([local, key]) => (local === key ? key : `${local} as ${key}`)); // default export too: other modules do `const x = require("./y")` -> `import x from "./y.js"` const defaults = list.map(([local, key]) => (local === key ? key : `${key}: ${local}`)); const out = `${consts.join("\n")}${consts.length ? "\n" : ""}export { ${named.join(", ")} };\nexport default { ${defaults.join(", ")} };\n`; text = text.slice(0, st.start) + out + text.slice(st.end); } } text = text.replace(/^module\.exports = (\w+);?\s*$/m, "export default $1;\n"); for (const [i, line] of text.split("\n").entries()) { if (/\brequire\(/.test(line)) problems.push(`${i + 1}: require left: ${line.trim()}`); if (/\bmodule\.exports\b|\bexports\.\w+\s*=/.test(line)) problems.push(`${i + 1}: exports left: ${line.trim()}`); if (/\b__dirname\b|\b__filename\b|process\.(env|platform|argv)/.test(line)) problems.push(`${i + 1}: node global: ${line.trim()}`); } fs.mkdirSync(dst.replace(/[\\/][^\\/]+$/, ""), { recursive: true }); fs.writeFileSync(dst, `// Ported from LS Tools desktop: ${src.replace(/\\/g, "/").split("/src/").pop()} (mechanical CJS -> ESM)\n` + text); console.log(`${dst}: ${problems.length ? problems.length + " to check" : "ok"}`); for (const p of problems) console.log(" " + p);