LosaDev Tools: Lost Saga modding tools in the browser
This commit is contained in:
commit
c5e04472fb
105 files changed
+38541
No files matched your search
@@ -0,0 +1,98 @@
|
||||
// Copies the desktop renderer into public/legacy/ for the web host, without editing the scripts:
|
||||
// js/<file>.js renderer scripts, byte for byte except the fixes in tools/legacy-patches.mjs
|
||||
// (app.js and monitor.js are left out)
|
||||
// views/<view>.html each <section id="view-<view>" class="view"> of index.html
|
||||
// chrome.html shared overlays: #menu, #modal, #toast, #task
|
||||
// legacy.css app.css scoped under .lsx (CSS nesting), @keyframes hoisted out,
|
||||
// :root tokens moved onto .lsx so the web theme can override them
|
||||
// usage: node tools/extract-legacy.mjs
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { applyPatches, PATCHES } from "./legacy-patches.mjs";
|
||||
|
||||
const REF = path.resolve("../reference/lstools-desktop/src/renderer");
|
||||
const OUT = path.resolve("public/legacy");
|
||||
const SKIP = new Set(["app.js", "monitor.js"]);
|
||||
const DROP_VIEWS = new Set(["iop", "pm", "mon", "pw"]); // IOP Manager UI comes back later as its own page
|
||||
|
||||
fs.rmSync(OUT, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(OUT, "js"), { recursive: true });
|
||||
fs.mkdirSync(path.join(OUT, "views"), { recursive: true });
|
||||
|
||||
// ---------- scripts ----------
|
||||
const scripts = fs.readdirSync(REF).filter((f) => f.endsWith(".js") && !SKIP.has(f));
|
||||
for (const f of scripts) {
|
||||
if (!PATCHES.some((p) => p.file === f)) {
|
||||
fs.copyFileSync(path.join(REF, f), path.join(OUT, "js", f));
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT, "js", f), applyPatches(f, fs.readFileSync(path.join(REF, f), "utf8")));
|
||||
console.log(`patched ${f}: ${PATCHES.filter((p) => p.file === f).length}`);
|
||||
}
|
||||
|
||||
// ---------- markup ----------
|
||||
const html = fs.readFileSync(path.join(REF, "index.html"), "utf8");
|
||||
|
||||
/** The element starting at `start` (an opening tag) up to its matching close tag. */
|
||||
function element(src, start) {
|
||||
const tag = src.slice(start + 1).match(/^[\w-]+/)[0];
|
||||
const re = new RegExp(`<(/?)${tag}\\b[^>]*>`, "g");
|
||||
re.lastIndex = start;
|
||||
let depth = 0;
|
||||
for (let m; (m = re.exec(src)); ) {
|
||||
if (m[0].endsWith("/>")) continue;
|
||||
depth += m[1] ? -1 : 1;
|
||||
if (depth === 0) return src.slice(start, re.lastIndex);
|
||||
}
|
||||
throw new Error(`unclosed <${tag}> at ${start}`);
|
||||
}
|
||||
|
||||
const views = [];
|
||||
for (const m of html.matchAll(/<section id="view-([\w-]+)" class="view[^"]*">/g)) {
|
||||
const name = m[1];
|
||||
if (DROP_VIEWS.has(name)) continue;
|
||||
fs.writeFileSync(path.join(OUT, "views", `${name}.html`), element(html, m.index));
|
||||
views.push(name);
|
||||
}
|
||||
|
||||
const chrome = [];
|
||||
for (const id of ["menu", "modal", "toast", "task"]) {
|
||||
const m = html.match(new RegExp(`<[\\w-]+[^>]*\\bid="${id}"[^>]*>`));
|
||||
if (m) chrome.push(element(html, m.index));
|
||||
else console.warn(`no #${id} in index.html`);
|
||||
}
|
||||
fs.writeFileSync(path.join(OUT, "chrome.html"), chrome.join("\n"));
|
||||
|
||||
// ---------- css ----------
|
||||
let css = fs.readFileSync(path.join(REF, "app.css"), "utf8");
|
||||
const keyframes = [];
|
||||
css = css.replace(/@keyframes\s+[\w-]+\s*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, (k) => (keyframes.push(k), ""));
|
||||
css = css.replace(/:root\s*\{/, "& {");
|
||||
fs.writeFileSync(path.join(OUT, "legacy.css"), `/* Generated by tools/extract-legacy.mjs from the desktop app.css. Do not edit. */\n${keyframes.join("\n")}\n.lsx {\n${css}\n}\n`);
|
||||
|
||||
// ---------- host.js: the shared helpers of app.js, copied line for line ----------
|
||||
// Line ranges of the reference app.js (1-based, inclusive). Only helpers the tool scripts use;
|
||||
// IOP Manager / Patch Manager / Monitor code stays out.
|
||||
const APP_RANGES = [
|
||||
[6, 103], // $, bridge, call, el, size, hex, fmtDos, MAX_ENTRIES, toast, modal, confirmBox, prompt, runTask
|
||||
[162, 162], // baseName
|
||||
[494, 558], // ICONS, icon()
|
||||
[564, 593], // showMenu, hideMenu, cmd
|
||||
[818, 823], // replay
|
||||
[1051, 1063], // unsavedWork (profileSource / profileSourceOption: web versions in host-web-after.js)
|
||||
];
|
||||
const appLines = fs.readFileSync(path.join(REF, "app.js"), "utf8").split(/\r?\n/);
|
||||
const copied = APP_RANGES.map(([a, b]) => `// ---- app.js ${a}-${b} ----\n` + appLines.slice(a - 1, b).join("\n")).join("\n\n");
|
||||
const webPart = fs.readFileSync(path.resolve("src/legacy/host-web.js"), "utf8");
|
||||
const webAfter = fs.readFileSync(path.resolve("src/legacy/host-web-after.js"), "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(OUT, "host.js"),
|
||||
`"use strict";\n// Generated by tools/extract-legacy.mjs: helpers copied from the desktop app.js + web additions.\n\n${webPart}\n\n${copied}\n\n${webAfter}\n`,
|
||||
);
|
||||
|
||||
// Load order = the desktop index.html order (scripts depend on earlier ones' globals)
|
||||
const order = [...html.matchAll(/<script src="([\w.-]+\.js)"><\/script>/g)].map((m) => m[1]).filter((f) => !SKIP.has(f));
|
||||
const missing = scripts.filter((s) => !order.includes(s));
|
||||
if (missing.length) console.warn("scripts not in index.html (not loaded):", missing.join(", "));
|
||||
fs.writeFileSync(path.join(OUT, "manifest.json"), JSON.stringify({ order, views }, null, 2));
|
||||
console.log(`scripts ${scripts.length}, views ${views.join(", ")}, keyframes ${keyframes.length}, chrome ${chrome.length}`);
|
||||
@@ -0,0 +1,78 @@
|
||||
// Generates src/worker/lib/main/handlers.js from the desktop main/index.js: the statements inside
|
||||
// registerIpc() are copied as source text (no logic changes), except
|
||||
// - handle()/task() registrations of channels the web version doesn't have (dropped prefixes),
|
||||
// - every require("x") becomes an identifier imported once at the top of the module.
|
||||
// usage: node tools/gen-handlers.mjs
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as acorn from "acorn";
|
||||
|
||||
const SRC = path.resolve("../reference/lstools-desktop/src/main/index.js");
|
||||
const OUT = path.resolve("src/worker/lib/main/handlers.js");
|
||||
// Answered by the browser (bridge.js) or not part of the web version.
|
||||
const DROP = ["dialog:", "shell:", "store:", "ws:", "app:", "pm:", "cz:", "lr:", "mon:", "iop:", "snd:"];
|
||||
// Handlers that need a module the web version does not ship are left out too.
|
||||
const MAIN_DIR = path.dirname(OUT);
|
||||
// Kept channels whose body opens an Electron dialog: answered in the browser (bridge.js) instead.
|
||||
const DIALOG_CHANNELS = ["fxlab:openJson", "fxlab:saveJson", "fx:openFile", "dat:pickFile"];
|
||||
// Non-registration statements that wire up removed parts (IOP Manager notifier, Monitor, Patch Manager).
|
||||
const DROP_STATEMENT = /\b(iop\.setNotifier|monitor\.|pm\.|patchman|launcherpub|clientzip)\b/;
|
||||
|
||||
const text = fs.readFileSync(SRC, "utf8");
|
||||
const ast = acorn.parse(text, { ecmaVersion: "latest", sourceType: "script", locations: true });
|
||||
const reg = ast.body.find((n) => n.type === "FunctionDeclaration" && n.id.name === "registerIpc");
|
||||
if (!reg) throw new Error("registerIpc not found");
|
||||
|
||||
const kept = [];
|
||||
const dropped = [];
|
||||
for (const st of reg.body.body) {
|
||||
const call = st.type === "ExpressionStatement" && st.expression.type === "CallExpression" ? st.expression : null;
|
||||
const isReg = call && call.callee.type === "Identifier" && ["handle", "task"].includes(call.callee.name) && call.arguments[0]?.type === "Literal";
|
||||
if (isReg) {
|
||||
const ch = call.arguments[0].value;
|
||||
if (DROP.some((p) => ch.startsWith(p)) || DIALOG_CHANNELS.includes(ch)) {
|
||||
dropped.push(ch);
|
||||
continue;
|
||||
}
|
||||
} else if (DROP_STATEMENT.test(text.slice(st.start, st.end))) {
|
||||
dropped.push(`(statement line ${st.loc.start.line})`);
|
||||
continue;
|
||||
}
|
||||
const needs = [...text.slice(st.start, st.end).matchAll(/require\("(\.\/[^"]+)"\)/g)].map((m) => (m[1].endsWith(".js") || m[1].endsWith(".json") ? m[1] : m[1] + ".js"));
|
||||
if (needs.some((s) => !fs.existsSync(path.join(MAIN_DIR, s)))) {
|
||||
dropped.push("(unshipped)");
|
||||
continue;
|
||||
}
|
||||
// keep comments right above the statement
|
||||
kept.push(text.slice(st.start, st.end));
|
||||
}
|
||||
|
||||
// requires -> hoisted imports
|
||||
const imports = new Map(); // spec -> identifier
|
||||
const idFor = (spec) => {
|
||||
if (!imports.has(spec)) imports.set(spec, `__req_${spec.replace(/^node:/, "node_").replace(/[^\w]/g, "_")}`);
|
||||
return imports.get(spec);
|
||||
};
|
||||
let body = kept.join("\n\n").replace(/require\("([^"]+)"\)/g, (_, spec) => idFor(spec));
|
||||
// names index.js defines outside registerIpc that the handlers use
|
||||
const fixSpec = (s) => (s.startsWith(".") && !s.endsWith(".js") && !s.endsWith(".json") ? s + ".js" : s);
|
||||
const head = [...imports].map(([spec, id]) => `import ${id} from "${fixSpec(spec)}";`);
|
||||
head.push(`import path from "node:path";`, `import { app } from "electron";`, `import store from "./store.js";`, `import iop from "./iopman.js";`);
|
||||
|
||||
const out = `// Generated by tools/gen-handlers.mjs from the desktop main/index.js registerIpc(). Do not edit.
|
||||
// Dropped channels: ${dropped.length}
|
||||
${head.join("\n")}
|
||||
|
||||
const tasks = new Map();
|
||||
const win = null;
|
||||
|
||||
export function register(handle, task) {
|
||||
${body.replace(/^/gm, "\t")}
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(OUT, out);
|
||||
|
||||
const left = [...out.matchAll(/\brequire\(/g)].length;
|
||||
const regs = [...out.matchAll(/\b(handle|task)\("([\w:.-]+)"/g)].map((m) => m[2]);
|
||||
console.log(`kept ${regs.length} channels, dropped ${dropped.length}, imports ${imports.size}, require() left ${left}`);
|
||||
console.log("imports: " + [...imports.keys()].join(", "));
|
||||
@@ -0,0 +1,173 @@
|
||||
// Fixes applied to the copied desktop renderer scripts by tools/extract-legacy.mjs (the copies in
|
||||
// public/legacy are regenerated, so a fix lives here). Each patch must match exactly once, or the
|
||||
// extraction stops — a changed desktop source then gets looked at instead of silently skipped.
|
||||
export const PATCHES = [
|
||||
// ---------- Neutral wording in a copied comment ----------
|
||||
{
|
||||
file: "lsformats.js",
|
||||
why: "comment names other servers",
|
||||
find: / \/\/ Index width: 16-bit in every \S+ client file;[^\r\n]*/,
|
||||
replace: " // Index width: 16-bit in the client files we checked; some newer engines read 32-bit.",
|
||||
},
|
||||
// ---------- Heroes: a helmet changes the default hair like the game ----------
|
||||
// LSClient ioHelmetItem / ioEquipSlot: hair_change_all0_hm (man) / _hw (woman) of the helmet:
|
||||
// 0 = half hair ("half" + hair mesh, the default), 1 = no hair, 2 = "bind" + hair, 3 = full hair.
|
||||
// The desktop preview always kept the full default hair, so it stuck out of helmets.
|
||||
{
|
||||
file: "model.js",
|
||||
why: "hero preview: helmet hair rule",
|
||||
find: "async function previewHero(src, meshes, animation, { embedded = false, host = null } = {}) {",
|
||||
replace: "async function previewHero(src, meshes, animation, { embedded = false, host = null, hair = null } = {}) {",
|
||||
},
|
||||
{
|
||||
file: "model.js",
|
||||
why: "hero preview: helmet hair rule",
|
||||
find: " const missing = [];\n for (const name of [...new Set(meshes)].filter(Boolean)) {",
|
||||
replace: [
|
||||
" // web fix: the helmet's hair rule (0 half, 1 none, 2 bind, 3 full)",
|
||||
" if (hair !== null && hair !== undefined && Number(hair) !== 3) {",
|
||||
" const baseHair = MDL.scene.meshes.find((m) => /^hair/i.test(m.name));",
|
||||
" if (baseHair) {",
|
||||
" removeMesh(baseHair);",
|
||||
" const alt = Number(hair) === 0 ? `half${baseHair.name}` : Number(hair) === 2 ? `bind${baseHair.name}` : \"\";",
|
||||
" const h = alt ? await readNamed(`${alt}.msh`, \"mesh\") : null;",
|
||||
" if (h) await addMesh(h, alt);",
|
||||
" }",
|
||||
" }",
|
||||
" const missing = [];",
|
||||
" for (const name of [...new Set(meshes)].filter(Boolean)) {",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
file: "hero.js",
|
||||
why: "hero preview: pass the helmet hair rule",
|
||||
find: " await MDL.previewHero(HERO.src, meshes, animation, { embedded: true });",
|
||||
replace: " await MDL.previewHero(HERO.src, meshes, animation, { embedded: true, hair: helmetHair() });",
|
||||
},
|
||||
{
|
||||
file: "hero.js",
|
||||
why: "hero preview: pass the helmet hair rule (animation previews)",
|
||||
find: " await MDL.previewHero(HERO.src, gearMeshes(), name);",
|
||||
replace: " await MDL.previewHero(HERO.src, gearMeshes(), name, { hair: helmetHair() });",
|
||||
},
|
||||
{
|
||||
file: "hero.js",
|
||||
why: "hero preview: helmet hair rule + STR(n) names from the text table",
|
||||
find: " const gearMeshes = () =>",
|
||||
replace: [
|
||||
" // web fix: the helmet section (item_type HELMET) and its hair rule for the man body (default 0 = half hair)",
|
||||
" function helmetHair() {",
|
||||
" const s = itemSections().find((x) => /^helmet$/i.test(value(\"item\", x.name, \"item_type\")) && value(\"item\", x.name, \"equip_mesh0\"));",
|
||||
" if (!s) return null;",
|
||||
" const v = value(\"item\", s.name, \"hair_change_all0_hm\");",
|
||||
" return v === \"\" ? 0 : Number(v);",
|
||||
" }",
|
||||
" const gearMeshes = () =>",
|
||||
].join("\n"),
|
||||
},
|
||||
// ---------- Heroes: STR(n) names read from the game's text table ----------
|
||||
{
|
||||
file: "hero.js",
|
||||
why: "STR(n) names",
|
||||
find: " const value = (kind, section, key) => { const d = doc(kind); return d ? HeroIni.get(d, section, key) : \"\"; };",
|
||||
replace: [
|
||||
" const value = (kind, section, key) => { const d = doc(kind); return d ? HeroIni.get(d, section, key) : \"\"; };",
|
||||
" // web fix: STR(n) → the text the game shows (text table key ini_<file stem>::<section>_<n>)",
|
||||
" const shown = (kind, section, v) => {",
|
||||
" const m = /^\\s*STR\\(\\s*(\\d+)/i.exec(v || \"\");",
|
||||
" return m ? HERO.strs?.[`ini_${HERO.id}_${kind}::${section}_${m[1]}`.toLowerCase()] || v : v;",
|
||||
" };",
|
||||
" const strText = (kind, section, key) => shown(kind, section, value(kind, section, key));",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
file: "hero.js",
|
||||
why: "STR(n) names: load the hero's strings with its configs",
|
||||
find: " if (!docs.item) throw new Error(`No ${id}_item.ini in this source`);",
|
||||
replace: [
|
||||
" if (!docs.item) throw new Error(`No ${id}_item.ini in this source`);",
|
||||
" try { HERO.strs = await call(\"text:forStems\", HERO.src, Object.keys(docs).map((k) => `${id}_${k}`)); } catch { HERO.strs = {}; }",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
file: "hero.js",
|
||||
why: "STR(n) names: moveset cards",
|
||||
find: 'cards.push(card(`${value("item", s.name, "item_type") || s.name} · ${value("item", s.name, "name")}`',
|
||||
replace: 'cards.push(card(`${value("item", s.name, "item_type") || s.name} · ${strText("item", s.name, "name")}`',
|
||||
},
|
||||
{
|
||||
file: "hero.js",
|
||||
why: "STR(n) names: skill cards",
|
||||
find: 'return card(`${s.name} · ${value("skill", s.name, "desc_name") || value("skill", s.name, "name")}`',
|
||||
replace: 'return card(`${s.name} · ${strText("skill", s.name, "desc_name") || strText("skill", s.name, "name")}`',
|
||||
},
|
||||
{
|
||||
file: "hero.js",
|
||||
why: "STR(n) names: skill buttons",
|
||||
find: ' const title = value("skill", section, "desc_name") || value("skill", section, "name");',
|
||||
replace: ' const title = strText("skill", section, "desc_name") || strText("skill", section, "name");',
|
||||
},
|
||||
{
|
||||
file: "hero.js",
|
||||
why: "STR(n) names: gear cards",
|
||||
find: ' const name = value("item", s.name, "name");',
|
||||
replace: ' const name = strText("item", s.name, "name");',
|
||||
},
|
||||
];
|
||||
|
||||
PATCHES.push(
|
||||
// ---------- Textures: the list renders in chunks (a game folder has 10 000+ textures) ----------
|
||||
// Search still filters the whole list (an array in memory); only the rows drawn are limited:
|
||||
// the first 300, then 300 more each time the list is scrolled near its end.
|
||||
{
|
||||
file: "texture.js",
|
||||
why: "textures list in chunks",
|
||||
find: [
|
||||
" $(\"tex-count\").textContent = rows.length ? String(rows.length) : \"\";",
|
||||
" $(\"tex-list\").replaceChildren(",
|
||||
" ...rows.map((f) =>",
|
||||
].join("\n"),
|
||||
replace: [
|
||||
" $(\"tex-count\").textContent = rows.length ? String(rows.length) : \"\";",
|
||||
" // web fix: draw 300 rows at a time, more while scrolling (10 000+ textures froze the page)",
|
||||
" const box = $(\"tex-list\");",
|
||||
" const CHUNK = 300;",
|
||||
" let shown = Math.min(rows.length, Math.max(CHUNK, TEX._shown || 0));",
|
||||
" const rowEl = (f) =>",
|
||||
" el(\"div\", { class: `tex-row${TEX.file?.name === f.name && TEX.file?.dir === TEX.dir ? \" on\" : \"\"}`, title: `${f.name} — ${size(f.size)}`, onclick: () => openFile(join(TEX.dir, f.name)) }, [",
|
||||
" el(\"span\", { class: `tex-ext ${extOf(f.name)}`, text: extOf(f.name) }),",
|
||||
" el(\"span\", { class: \"grow\", text: f.name }),",
|
||||
" el(\"span\", { class: \"dim\", text: size(f.size) }),",
|
||||
" ]);",
|
||||
" box.onscroll = () => {",
|
||||
" if (shown >= rows.length || box.scrollTop + box.clientHeight < box.scrollHeight - 400) return;",
|
||||
" box.append(...rows.slice(shown, shown + CHUNK).map(rowEl));",
|
||||
" shown = Math.min(rows.length, shown + CHUNK);",
|
||||
" TEX._shown = shown;",
|
||||
" };",
|
||||
" if (TEX._q !== q) (TEX._q = q), (shown = Math.min(rows.length, CHUNK)), (box.scrollTop = 0);",
|
||||
" TEX._shown = shown;",
|
||||
" $(\"tex-list\").replaceChildren(",
|
||||
" ...rows.slice(0, shown).map((f) =>",
|
||||
].join("\n"),
|
||||
},
|
||||
);
|
||||
|
||||
/** text → patched text; throws when a patch doesn't match exactly once. */
|
||||
export function applyPatches(file, text) {
|
||||
const crlf = text.includes("\r\n");
|
||||
const eol = (s) => (crlf ? s.replace(/\r?\n/g, "\r\n") : s);
|
||||
for (const p of PATCHES.filter((x) => x.file === file)) {
|
||||
if (p.find instanceof RegExp) {
|
||||
const n = (text.match(new RegExp(p.find.source, p.find.flags.replace("g", "") + "g")) || []).length;
|
||||
if (n !== 1) throw new Error(`legacy patch "${p.why}" matches ${n}× in ${file}`);
|
||||
text = text.replace(p.find, () => eol(p.replace));
|
||||
continue;
|
||||
}
|
||||
const find = eol(p.find);
|
||||
const n = text.split(find).length - 1;
|
||||
if (n !== 1) throw new Error(`legacy patch "${p.why}" matches ${n}× in ${file}`);
|
||||
text = text.replace(find, () => eol(p.replace));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Mechanical CommonJS -> ESM conversion of desktop LS Tools modules (no logic changes).
|
||||
// usage: node tools/port-module.mjs <reference file> <output file>
|
||||
// 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);
|
||||
Reference in new issue
Block a user