everything-dev
Version:
A consolidated product package for building Module Federation apps with oRPC APIs.
220 lines (218 loc) • 7.92 kB
JavaScript
const require_sync = require('./sync.cjs');
let node_fs = require("node:fs");
let node_path = require("node:path");
//#region src/cli/prune.ts
const EXTENSIONS = [
"",
".ts",
".tsx",
".css",
".json",
".png",
".gif",
".svg",
".jpg",
".webp",
".ico",
".mjs"
];
function splitNamedImports(clause) {
let wholeModule = false;
const names = [];
const withoutNamed = clause.replace(/\{[^}]*\}/g, " ").replace(/^\s*(?:import|export)\b/, "");
for (const piece of withoutNamed.split(",")) {
const token = piece.trim();
if (!token) continue;
if (token.includes("*")) {
wholeModule = true;
continue;
}
if (/^[A-Za-z_$][\w$]*$/.test(token)) wholeModule = true;
}
for (const named of clause.matchAll(/\{([^}]*)\}/g)) for (const piece of named[1].split(",")) {
const name = piece.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
if (name && name !== "default") names.push(name);
}
return {
names,
wholeModule
};
}
function collectImportRefs(content) {
const refs = [];
for (const match of content.matchAll(/(?:import|export)\s+[\s\S]*?from\s*["']([^"']+)["']/g)) {
const { names, wholeModule } = splitNamedImports(match[0].slice(0, match[0].lastIndexOf("from")));
refs.push({
names: wholeModule ? null : names,
source: match[1]
});
}
for (const match of content.matchAll(/import\s*["']([^"']+)["']/g)) refs.push({
names: null,
source: match[1]
});
for (const match of content.matchAll(/import\(\s*["']([^"']+)["']\s*\)/g)) refs.push({
names: null,
source: match[1]
});
return refs;
}
function resolveSpecifier(specifier, fromFile, uiDir) {
let base = null;
if (specifier.startsWith("@/")) base = (0, node_path.join)(uiDir, specifier.slice(2));
else if (specifier.startsWith("./") || specifier.startsWith("../")) base = (0, node_path.resolve)((0, node_path.dirname)(fromFile), specifier);
else return null;
const candidates = [];
for (const ext of EXTENSIONS) candidates.push(`${base}${ext}`);
for (const ext of EXTENSIONS) candidates.push((0, node_path.join)(base, `index${ext}`));
for (const candidate of candidates) if ((0, node_fs.existsSync)(candidate) && (0, node_fs.statSync)(candidate).isFile()) return candidate;
return null;
}
function parseBarrelExports(content, barrelPath, srcDir) {
const exports = [];
for (const match of content.matchAll(/export\s+\{([^}]*)\}\s*from\s*["']([^"']+)["']/g)) for (const piece of match[1].split(",")) {
const name = piece.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
if (!name || name === "default") continue;
exports.push({
name,
source: resolveSpecifier(match[2], barrelPath, srcDir)
});
}
return exports;
}
function listFilesRecursive(dir, out = []) {
if (!(0, node_fs.existsSync)(dir)) return out;
for (const entry of (0, node_fs.readdirSync)(dir)) {
const full = (0, node_path.join)(dir, entry);
if ((0, node_fs.statSync)(full).isDirectory()) listFilesRecursive(full, out);
else out.push(full);
}
return out;
}
function isProtected(relPath, srcDir) {
const withinSrc = (0, node_path.relative)(srcDir, relPath);
if (withinSrc.startsWith("..") || (0, node_path.isAbsolute)(withinSrc)) return true;
if (!withinSrc.includes("/")) return true;
if (withinSrc.startsWith("routes/")) return true;
if (/\.gen\.(ts|tsx|json)$/.test(withinSrc)) return true;
if (require_sync.isFrameworkOwnedSyncFile((0, node_path.join)("ui/src", withinSrc))) return true;
return false;
}
function subjectOfTestFile(file) {
const match = file.match(/^(?:(?<dir>.+)\/)?(?<name>.+)\.test\.tsx?$/);
if (!match?.groups) return null;
const base = match.groups.dir ? `${match.groups.dir}/${match.groups.name}` : match.groups.name;
for (const ext of [".tsx", ".ts"]) if ((0, node_fs.existsSync)(`${base}${ext}`)) return `${base}${ext}`;
return null;
}
/**
* Prune copied ui source files that no copied route or framework-owned file
* can reach via static imports. Named imports through the components barrel
* keep only the named exports' files alive; a namespace/default import of the
* barrel keeps everything it exports. Framework-owned files, generated types,
* and route files are never pruned; a test file lives or dies with the file
* it tests.
*/
function pruneUnusedUiFiles(targetDir, options = {}) {
const srcDir = (0, node_path.join)(targetDir, "ui", "src");
if (!(0, node_fs.existsSync)(srcDir)) return {
pruned: [],
reachable: 0
};
const allFiles = listFilesRecursive(srcDir);
const barrelPath = (0, node_path.join)(srcDir, "components", "index.ts");
const barrelExports = (0, node_fs.existsSync)(barrelPath) ? parseBarrelExports((0, node_fs.readFileSync)(barrelPath, "utf-8"), barrelPath, srcDir) : [];
let barrelSaturated = false;
const reachable = /* @__PURE__ */ new Set();
const queue = [];
const saturateBarrel = () => {
for (const exp of barrelExports) if (exp.source) visit(exp.source, null);
};
function visit(file, wantedNames) {
if (file === barrelPath) {
if (wantedNames === null) barrelSaturated = true;
if (reachable.has(file)) {
if (wantedNames === null) saturateBarrel();
return;
}
reachable.add(file);
if (wantedNames === null) saturateBarrel();
else for (const exp of barrelExports) if (exp.source && wantedNames.includes(exp.name)) visit(exp.source, null);
return;
}
if (reachable.has(file)) return;
reachable.add(file);
queue.push(file);
}
for (const file of allFiles) if (isProtected(file, srcDir)) visit(file, null);
while (queue.length > 0) {
const file = queue.shift();
if (!(0, node_fs.existsSync)(file)) continue;
if (file === barrelPath) continue;
const content = (0, node_fs.readFileSync)(file, "utf-8");
for (const ref of collectImportRefs(content)) {
const resolved = resolveSpecifier(ref.source, file, srcDir);
if (!resolved) continue;
visit(resolved, ref.names);
}
}
if (barrelSaturated) saturateBarrel();
const removed = [];
for (const file of allFiles) {
if (reachable.has(file)) continue;
if (isProtected(file, srcDir)) continue;
const subject = subjectOfTestFile(file);
if (subject && (reachable.has(subject) || isProtected(subject, srcDir))) continue;
(0, node_fs.rmSync)(file);
removed.push((0, node_path.relative)(targetDir, file));
}
if ((0, node_fs.existsSync)(barrelPath) && removed.length > 0) {
const content = (0, node_fs.readFileSync)(barrelPath, "utf-8");
const spans = [];
for (const match of content.matchAll(/export\s*\{[^}]*\}\s*from\s*["'][^"']+["'];?/g)) {
const fromMatch = match[0].match(/from\s*["']([^"']+)["']/);
if (!fromMatch) continue;
const target = resolveSpecifier(fromMatch[1], barrelPath, srcDir);
if (!target || !(0, node_fs.existsSync)(target)) spans.push([match.index, match.index + match[0].length]);
}
if (spans.length > 0) {
let next = "";
let cursor = 0;
for (const [start, end] of spans) {
next += content.slice(cursor, start);
cursor = end;
}
next += content.slice(cursor);
next = next.replace(/\n{3,}/g, "\n\n");
(0, node_fs.writeFileSync)(barrelPath, next);
}
}
for (const candidate of [
"components",
"lib",
"hooks",
"providers",
"integrations"
]) removeIfEmpty((0, node_path.join)(srcDir, candidate));
if (removed.length > 0) options.log?.(`pruned ${removed.length} unreferenced ui file(s)`);
return {
pruned: removed,
reachable: reachable.size
};
}
function removeIfEmpty(dir) {
if (!(0, node_fs.existsSync)(dir)) return;
const entries = (0, node_fs.readdirSync)(dir);
if (entries.length === 0) {
(0, node_fs.rmSync)(dir, { recursive: true });
return;
}
for (const entry of entries) {
const full = (0, node_path.join)(dir, entry);
if ((0, node_fs.statSync)(full).isDirectory()) removeIfEmpty(full);
}
if ((0, node_fs.readdirSync)(dir).length === 0) (0, node_fs.rmSync)(dir, { recursive: true });
}
//#endregion
exports.pruneUnusedUiFiles = pruneUnusedUiFiles;
//# sourceMappingURL=prune.cjs.map