Files
console-dev/scripts/reconstruct.mjs
T

674 lines
23 KiB
JavaScript

/** One-time source reconstruction. Input is explicit; generated app has no runtime link to it. */
/**
* 一次性重建留档,不参与日常开发、构建或发布。
* 输入必须是可信的自有 Nuxt 发布目录:脚本会 import 其中的元数据,因此不是安全的第三方解包器。
* 只在空工程执行;已有 app/pages 时直接拒绝,避免覆盖后续维护的源码和注释。
*/
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import { parse } from "@babel/parser";
import generator from "@babel/generator";
import traverser from "@babel/traverse";
import * as t from "@babel/types";
import { format } from "prettier";
const generate = generator.default;
const traverse = traverser.default;
const input = path.resolve(process.argv[2] || "MISSING_INPUT");
// 所有输出定位到本工程;不向输入发布目录回写,也不改变生产发布软链接。
const root = path.resolve(import.meta.dirname, "..");
if (!fs.existsSync(path.join(input, "server/chunks/virtual/precomputed.mjs")))
throw Error("Pass the active .output directory");
if (fs.existsSync(path.join(root, "app/pages")))
throw Error(
"Refusing to overwrite editable source; this importer is only for a new empty project",
);
// 这一步会执行输入模块,调用方必须先验证输入可信性。
const pc = (
await import(
pathToFileURL(path.join(input, "server/chunks/virtual/precomputed.mjs"))
)
).default;
const chunkDir = path.join(input, "public/_nuxt");
const asts = new Map();
// 先解析元数据声明的脚本,再按导入图选择业务模块,不直接复制旧框架 bundle。
const files = [
...new Set(
Object.values(pc.modules)
.filter((v) => v.resourceType === "script")
.map((v) => v.file),
),
];
for (const file of files)
asts.set(
file,
parse(fs.readFileSync(path.join(chunkDir, file), "utf8"), {
sourceType: "module",
}),
);
const key = (file, name) => `${file}:${name}`;
const textValue = (n) =>
t.isTemplateLiteral(n) && n.expressions.length === 0
? n.quasis[0].value.cooked
: n?.value;
function resolve(file, exported, seen = new Set()) {
// 顺着重导出定位真正的定义;seen 检测循环,缺失导出则终止而非猜测模块归属。
if (seen.has(key(file, exported)))
throw Error(`Cyclic export ${file}:${exported}`);
seen.add(key(file, exported));
const ast = asts.get(file);
if (!ast) throw Error(`Missing ${file}`);
const imports = new Map();
for (const n of ast.program.body)
if (t.isImportDeclaration(n))
for (const s of n.specifiers)
imports.set(s.local.name, {
file: path.basename(n.source.value),
name: t.isImportDefaultSpecifier(s)
? "default"
: s.imported?.name || s.imported?.value,
});
for (const n of ast.program.body) {
if (t.isExportDefaultDeclaration(n) && exported === "default")
return {
file,
name: exported,
local: t.isIdentifier(n.declaration) ? n.declaration.name : null,
};
if (t.isExportNamedDeclaration(n))
for (const s of n.specifiers)
if ((s.exported.name || s.exported.value) === exported) {
const imp = n.source
? { file: path.basename(n.source.value), name: s.local.name }
: imports.get(s.local.name);
if (imp?.file.endsWith(".js"))
return resolve(imp.file, imp.name, seen);
return { file, name: exported, local: s.local.name };
}
}
throw Error(`No export ${file}:${exported}`);
}
const replacements = new Map();
const replace = (file, alias, source, imported) =>
replacements.set(key(file, alias), { source, imported });
// 以下短名映射仅适用于本次可信发布版本,不是跨版本通用的编译器符号表。
const vue = {
$: "watchSyncEffect",
A: "nextTick",
B: "provide",
Bt: "toDisplayString",
C: "guardReactiveProps",
D: "isVNode",
E: "inject",
F: "onErrorCaptured",
G: "resolveDirective",
H: "renderList",
I: "onMounted",
J: "useId",
K: "resolveDynamicComponent",
L: "onUnmounted",
Lt: "normalizeClass",
M: "onBeforeMount",
N: "onBeforeUnmount",
O: "mergeModels",
P: "onDeactivated",
Q: "watchEffect",
R: "onUpdated",
Rt: "normalizeProps",
S: "getCurrentInstance",
T: "hasInjectionContext",
U: "renderSlot",
V: "queuePostFlushCb",
W: "resolveComponent",
X: "warn",
Y: "useModel",
Z: "watch",
_: "createStaticVNode",
_t: "toValue",
a: "Suspense",
at: "isReadonly",
b: "defineAsyncComponent",
c: "cloneVNode",
ct: "onScopeDispose",
d: "createBlock",
dt: "ref",
et: "withCtx",
f: "createCommentVNode",
ft: "shallowReactive",
g: "createSlots",
gt: "toRefs",
h: "createRenderer",
ht: "toRef",
i: "KeepAlive",
it: "isReactive",
j: "onActivated",
k: "mergeProps",
l: "computed",
lt: "reactive",
m: "createHydrationRenderer",
mt: "toRaw",
n: "BaseTransitionPropsValidators",
nt: "effectScope",
o: "Teleport",
ot: "isRef",
p: "createElementBlock",
pt: "shallowRef",
q: "toHandlers",
r: "Fragment",
rt: "getCurrentScope",
s: "callWithAsyncErrorHandling",
st: "markRaw",
t: "BaseTransition",
tt: "withDirectives",
u: "createElementVNode",
ut: "readonly",
v: "createTextVNode",
vt: "unref",
w: "h",
x: "defineComponent",
y: "createVNode",
z: "openBlock",
zt: "normalizeStyle",
};
const shared = {
At: "isPlainObject",
Ct: "includeBooleanAttr",
Dt: "isModelListener",
Et: "isFunction",
Ft: "looseEqual",
It: "looseToNumber",
Mt: "isSpecialBooleanAttr",
Nt: "isString",
Ot: "isObject",
Pt: "isSymbol",
St: "hyphenate",
Tt: "isArray",
Vt: "toNumber",
bt: "capitalize",
jt: "isPromise",
kt: "isOn",
wt: "invokeArrayFns",
xt: "extend",
yt: "camelize",
};
for (const [a, n] of Object.entries(vue)) replace("CaDPEW5H.js", a, "vue", n);
for (const [a, n] of Object.entries(shared))
replace("CaDPEW5H.js", a, "@vue/shared", n);
for (const [a, n] of Object.entries({
a: "vModelText",
c: "withModifiers",
i: "vModelRadio",
n: "createApp",
o: "vShow",
r: "createSSRApp",
s: "withKeys",
t: "Transition",
}))
replace("CHOHVZjb.js", a, "vue", n);
for (const [file, map] of Object.entries({
"BXrXAUMD.js": { a: "useNuxtApp", o: "useRuntimeConfig" },
"DNcWRfqH.js": { f: "useRoute", p: "useRouter", u: "navigateTo" },
"BJ9CQb77.js": { n: "useHead" },
"ClFYPW8t.js": { t: "useState" },
}))
for (const [a, n] of Object.entries(map)) replace(file, a, "#app", n);
replace("Cq-ZALh2.js", "D", "vue-router", "onBeforeRouteLeave");
replace("Gudm9e0k.js", "t", "#components", "NuxtLink");
replace("BnCkphz4.js", "t", "#components", "Icon");
replace("uBIymjUX.js", "t", "~/utils/load-module.js", "loadModule");
replace("BLvPXbVl.js", "_", "pinia", "createPinia");
replace("BLvPXbVl.js", "v", "pinia", "setActivePinia");
replace("BLvPXbVl.js", "y", "pinia", "shouldHydrate");
replace("BLvPXbVl.js", "s", "~/utils/crypto.js", "getCrypto");
replace("BSkUL2SB.js", "n", "primevue/usetoast", "useToast");
replace("iGbquyGC.js", "t", "primevue/tooltip", "default");
replace("DEP0rg1v.js", "t", "~/utils/brand.js", "logoUrl");
replace("ecKN0m8a.js", "r", "#internal/nuxt/paths", "publicAssetsURL");
const owners = new Map();
// 页面、布局和业务组件一一对应源码路径,重复归属会报错,避免静默覆盖。
const own = Object.entries(pc.modules).filter(([p]) =>
/^(components|layouts|pages)\//.test(p),
);
for (const [source, mod] of own) {
const r = resolve(mod.file, "default");
if (owners.has(r.file) && owners.get(r.file).source !== source)
throw Error(`Multiple owners ${r.file}`);
owners.set(r.file, { source, exported: r.name, local: r.local });
replacements.set(key(r.file, r.name), {
source: `~/${source}`,
imported: "default",
label: path.basename(source, ".vue"),
});
}
// Official libraries replace bundled copies. Resolve tiny re-export wrappers first.
for (const [source, mod] of Object.entries(pc.modules)) {
if (!source.includes("/node_modules/") || mod.resourceType !== "script")
continue;
let npm = source.slice(source.lastIndexOf("/node_modules/") + 14);
if (npm.startsWith("nuxt/") || npm.startsWith("@nuxt/icon")) continue;
npm = npm.replace(/\/index\.mjs$/, "");
if (npm === "@novnc/novnc/core/rfb.js") npm = "@novnc/novnc";
const r = resolve(mod.file, "default");
if (!owners.has(r.file))
replacements.set(key(r.file, r.name), {
source: npm,
imported: "default",
label: npm.split("/").at(-1),
});
}
// The existing chart component is recovered separately; ECharts stays a library dependency.
const chartOwner = [...owners.entries()].find(
([, v]) => v.source === "components/ChartBox.vue",
);
if (chartOwner)
replacements.set(key(chartOwner[0], chartOwner[1].exported), {
source: "~/components/ChartBox.vue",
imported: "default",
label: "ChartBox",
});
const names = new Map();
for (const d of Object.values(pc.dependencies))
for (const v of Object.values(d.preload || {}))
if (v.name && v.file?.endsWith(".js") && !names.has(v.file))
names.set(v.file, v.name);
const paths = new Map();
const taken = new Set();
for (const file of files) {
let output = owners.has(file)
? `app/${owners.get(file).source}`
: `app/lib/${(names.get(file) || "module").replace(/[^\w-]/g, "-")}.js`;
let i = 2;
const base = output;
while (taken.has(output)) output = base.replace(/\.js$/, `-${i++}.js`);
paths.set(file, output);
taken.add(output);
}
const authExports = {
// 为已核实的认证公共接口恢复业务名;未知模块导出仍保留原有绑定。
a: "loginErrorMessage",
c: "ApiError",
d: "apiCacheKey",
f: "apiCacheVersion",
g: "sanitizeMessage",
h: "operationMessage",
i: "securityMethods",
l: "useApi",
m: "writeApiCache",
n: "assertSecurityComplete",
o: "isValidJwt",
p: "readApiCache",
r: "securityChallenge",
t: "useAuth",
u: "clearApiCache",
};
const exportNames = new Map(
Object.entries(authExports).map(([a, n]) => [key("BLvPXbVl.js", a), n]),
);
function lookup(file, exported) {
// 优先使用官方包/手工适配映射,其余依赖落到当前 app/lib,而不是历史发布路径。
const direct = replacements.get(key(file, exported));
if (direct) return direct;
const r = resolve(file, exported);
return (
replacements.get(key(r.file, r.name)) || {
source: "~/" + paths.get(r.file).replace(/^app\//, ""),
imported: exportNames.get(key(r.file, r.name)) || r.name,
file: r.file,
label: names.get(r.file),
}
);
}
const emitted = new Set();
const pending = [...owners.keys()].filter((f) => f !== chartOwner?.[0]);
const patchFiles = [];
async function add(file, code, parser) {
// 先收集格式化内容,所有代码文件最终通过 apply_patch 新增,失败即中止。
const formatted = await format(code, {
parser:
parser ||
(file.endsWith(".vue")
? "vue"
: file.endsWith(".json")
? "json"
: "babel"),
printWidth: 110,
});
patchFiles.push({ file, code: formatted });
}
// Save route metadata without compiled preload machinery; Nuxt owns the new module graph.
const entry = asts.get("CNMaXYQo.js");
let routes;
traverse(entry, {
VariableDeclarator(p) {
if (p.node.id.name === "Qi") routes = t.cloneNode(p.node.init, true);
},
});
for (const route of routes.elements) {
let file;
for (const prop of route.properties)
if (prop.key.name === "component")
traverse(
t.file(
t.program([t.expressionStatement(t.cloneNode(prop.value, true))]),
),
{
CallExpression(p) {
if (p.node.callee.type === "Import")
file = path.basename(textValue(p.node.arguments[0]));
},
},
);
const owner = owners.get(resolve(file, "default").file);
const metaProps =
route.properties.find((p) => p.key.name === "meta")?.value.properties || [];
const alias = route.properties.find((p) => p.key.name === "alias");
if (alias)
metaProps.push(t.objectProperty(t.identifier("alias"), alias.value));
const redirect = route.properties.find((p) => p.key.name === "redirect");
if (redirect)
metaProps.push(t.objectProperty(t.identifier("redirect"), redirect.value));
route._source = owner.source;
route._meta = metaProps;
}
while (pending.length) {
// 从业务入口遍历依赖闭包;emitted 去重,以免循环依赖反复生成同一文件。
const file = pending.shift();
if (emitted.has(file)) continue;
emitted.add(file);
if (!asts.has(file)) throw Error(`No AST ${file}`);
let ast = t.cloneNode(asts.get(file), true);
if (file === "BDbvURLy.js") {
ast.program.body = ast.program.body.filter(
(n) =>
!(t.isFunctionDeclaration(n) && n.id.name === "y") &&
!(t.isImportDeclaration(n) && n.source.value === "./j97ZVn8z.js"),
);
ast.program.body.unshift(
t.importDeclaration(
[t.importSpecifier(t.identifier("y"), t.identifier("reloadNuxtApp"))],
t.stringLiteral("#app"),
),
);
}
if (file === "BLvPXbVl.js") {
// 针对此发布包剥离内嵌的 Pinia/密码库实现,保留业务逻辑并接回正式 npm 依赖。
// Business logic only; Pinia, file downloads and cryptography are npm dependencies.
let keep = false;
const body = [];
for (const n of ast.program.body) {
if (t.isImportDeclaration(n)) {
body.push(n);
continue;
}
if (t.isVariableDeclaration(n)) {
for (const d of n.declarations) {
if (d.id.name === "ce") keep = true;
if (d.id.name === "Oe") keep = false;
if (d.id.name === "ot") keep = true;
if (keep) body.push(t.variableDeclaration(n.kind, [d]));
}
} else if (t.isExportNamedDeclaration(n)) {
n.specifiers = n.specifiers.filter(
(s) => !["_", "v", "y", "s"].includes(s.exported.name),
);
body.push(n);
} else if (keep) body.push(n);
}
body.unshift(
t.importDeclaration(
[t.importDefaultSpecifier(t.identifier("CryptoJS"))],
t.stringLiteral("crypto-js"),
),
t.importDeclaration(
[t.importSpecifier(t.identifier("se"), t.identifier("defineStore"))],
t.stringLiteral("pinia"),
),
);
ast.program.body = body;
traverse(ast, {
MemberExpression(p) {
if (
t.isIdentifier(p.node.object, { name: "at" }) &&
t.isIdentifier(p.node.property, { name: "default" })
)
p.replaceWith(t.identifier("CryptoJS"));
},
});
}
// Discard old dependency indexes: Vite reconstructs preload URLs from new imports.
traverse(ast, {
CallExpression(p) {
if (t.isIdentifier(p.node.callee, { name: "__vite__mapDeps" }))
p.replaceWith(t.arrayExpression([]));
},
VariableDeclarator(p) {
if (p.node.id.name === "__vite__mapDeps") p.remove();
},
});
const owner = owners.get(file);
const dependencies = [];
const bindingNames = new Set();
traverse(ast, {
BindingIdentifier(p) {
bindingNames.add(p.node.name);
},
});
traverse(ast, {
Program(p) {
// 只重写被引用的导入绑定,同步改名所有引用以避免与当前作用域已有变量冲突。
p.scope.crawl();
for (const stmt of [...p.get("body")]) {
if (!stmt.isImportDeclaration()) continue;
const original = stmt.node.source.value;
if (!original.startsWith(".")) continue;
const dep = path.basename(original);
const imports = [];
for (const spec of [...stmt.node.specifiers]) {
if (!p.scope.getBinding(spec.local.name)?.referenced) continue;
const alias = t.isImportDefaultSpecifier(spec)
? "default"
: spec.imported?.name || spec.imported?.value;
const target = lookup(dep, alias);
if (target.file) dependencies.push(target.file);
let name =
target.imported === "default"
? target.label || path.basename(target.source) || "Component"
: target.imported;
if (name.length < 3 && target.label) name = target.label + "_" + name;
name = name.replace(/[^\w$]/g, "_");
if (!/^[A-Za-z_$]/.test(name)) name = "module_" + name;
if (name !== spec.local.name) {
while (bindingNames.has(name) || p.scope.hasGlobal(name))
name += "Imported";
p.scope.rename(spec.local.name, name);
bindingNames.add(name);
}
const local = t.identifier(spec.local.name);
imports.push(
t.importDeclaration(
[
target.imported === "default"
? t.importDefaultSpecifier(local)
: t.importSpecifier(local, t.identifier(target.imported)),
],
t.stringLiteral(target.source),
),
);
}
if (!stmt.node.specifiers.length) {
if (file === "BY4y5R3Q.js" && dep === "DNcWRfqH.js") {
stmt.remove();
continue;
}
throw Error(`Side effect import ${file} ${original}`);
}
stmt.replaceWithMultiple(imports);
}
// Semantic exported API names are stable throughout the reconstructed project.
for (const s of p.node.body)
if (t.isExportNamedDeclaration(s))
for (const spec of s.specifiers) {
const n = exportNames.get(key(file, spec.exported.name));
if (n) spec.exported = t.identifier(n);
}
},
CallExpression(p) {
if (p.node.callee.type !== "Import") return;
const value = textValue(p.node.arguments[0]);
if (!value?.startsWith("./")) return;
const dep = path.basename(value);
const target = lookup(dep, "default");
if (target.file) dependencies.push(target.file);
p.node.arguments[0] = t.stringLiteral(target.source);
},
});
for (const dep of dependencies) if (!emitted.has(dep)) pending.push(dep);
if (owner) {
// 业务组件包装为可编辑 SFC,恢复路由元数据并显式导入原有作用域 CSS。
const defaultExport = ast.program.body.find((n) =>
t.isExportDefaultDeclaration(n),
);
if (!defaultExport) {
const exportNode = ast.program.body.find(
(n) =>
t.isExportNamedDeclaration(n) &&
n.specifiers.some((s) => s.exported.name === owner.exported),
);
const spec = exportNode?.specifiers.find(
(s) => s.exported.name === owner.exported,
);
if (!spec) throw Error(`Missing own export ${owner.source}`);
exportNode.specifiers = exportNode.specifiers.filter((s) => s !== spec);
ast.program.body.push(
t.exportDefaultDeclaration(t.identifier(spec.local.name)),
);
}
const route = routes.elements.find((r) => r._source === owner.source);
if (route?._meta.length)
ast.program.body.unshift(
t.expressionStatement(
t.callExpression(t.identifier("definePageMeta"), [
t.objectExpression(route._meta),
]),
),
);
const styles = Object.values(
pc.dependencies[owner.source]?.styles || {},
).map((v) => v.file);
for (const css of styles)
ast.program.body.unshift(
t.importDeclaration([], t.stringLiteral("~/assets/styles/" + css)),
);
await add(
paths.get(file),
`<script>\n${generate(ast, { comments: true }).code}\n</script>\n`,
);
} else await add(paths.get(file), generate(ast, { comments: true }).code);
}
await add(
"app/utils/load-module.js",
"export function loadModule(load) { return load(); }",
);
await add(
"app/utils/crypto.js",
"import CryptoJS from 'crypto-js'; export function getCrypto() { return CryptoJS.SHA256; }",
);
await add(
"app/utils/brand.js",
"export const logoUrl = '/images/brand/logo.png';",
);
await add(
"docs/module-inventory.json",
// 此清单是本地重建记录;docs 已被 Git 忽略,不应作为产品运行依赖或强制加入版本库。
JSON.stringify(
{
pages: own.filter(([p]) => p.startsWith("pages/")).map(([p]) => p),
components: own
.filter(([p]) => p.startsWith("components/"))
.map(([p]) => p),
layouts: own.filter(([p]) => p.startsWith("layouts/")).map(([p]) => p),
shared: [...emitted]
.filter((f) => !owners.has(f))
.map((f) => paths.get(f)),
routes: routes.elements.map((r) => ({
source: r._source,
path: textValue(r.properties.find((p) => p.key.name === "path").value),
})),
},
null,
2,
),
);
for (const { file, code } of patchFiles) {
// apply_patch 只新增目标;不使用整目录替换,也不清理已有文件。
const target = path.join(root, file);
fs.mkdirSync(path.dirname(target), { recursive: true });
const patch = `*** Begin Patch\n*** Add File: ${target}\n${code
.split("\n")
.map((l) => "+" + l)
.join("\n")}\n*** End Patch\n`;
const result = spawnSync("apply_patch", [], {
input: patch,
encoding: "utf8",
});
if (result.status !== 0) throw Error(result.stdout + result.stderr);
}
// Static binary assets retain their deployed paths; no JS bundle is copied.
const cssFiles = [
// 样式保留发布版 scope 选择器,避免在模板已丢失时重新分配作用域导致外观失配。
...new Set(
Object.values(pc.modules)
.filter((v) => v.resourceType === "style")
.map((v) => v.file),
),
];
fs.mkdirSync(path.join(root, "app/assets/styles"), { recursive: true });
for (const file of cssFiles) {
let css = fs.readFileSync(path.join(chunkDir, file), "utf8");
css = css.replace(
// 图标字体改用 public/icons 的稳定地址,脱离旧 _nuxt 输出目录。
/url\(([^)]*primeicons[^)]*)\)/g,
(_, v) =>
`url(${v.replace(/^["']?|["']?$/g, "").replace(/^\.\//, "/icons/")})`,
);
const out = path.join(root, "app/assets/styles", file);
const patch = `*** Begin Patch\n*** Add File: ${out}\n+${css.replace(/\n/g, "\n+")}\n*** End Patch\n`;
const result = spawnSync("apply_patch", [], {
input: patch,
encoding: "utf8",
});
if (result.status !== 0) throw Error(result.stdout + result.stderr);
}
for (const name of fs.readdirSync(path.join(input, "public")))
// public 的图片和字体保留原访问路径;不复制 _nuxt bundle 或构建版本目录。
if (
name !== "_nuxt" &&
!name.startsWith("._") &&
!["builds"].includes(name)
) {
const src = path.join(input, "public", name);
if (fs.lstatSync(src).isDirectory() || !name.endsWith(".js"))
fs.cpSync(src, path.join(root, "public", name), {
recursive: true,
filter: (p) => !path.basename(p).startsWith("._"),
});
}
fs.mkdirSync(path.join(root, "public/icons"), { recursive: true });
// PrimeIcons 字体单独归档,配合前面的 CSS URL 重写使用。
for (const name of fs.readdirSync(chunkDir))
if (name.startsWith("primeicons.") && !name.endsWith(".js"))
fs.copyFileSync(
path.join(chunkDir, name),
path.join(root, "public/icons", name),
);
console.log(
JSON.stringify({
sourceFiles: patchFiles.length,
businessModules: emitted.size,
css: cssFiles.length,
}),
);