vite-plugin-deadfile
Version:
This plugin helps to find unused source file(dead files) in Vite projects.
463 lines (455 loc) • 13.7 kB
JavaScript
;
const node_path = require('node:path');
const core = require('@swc/core');
const fsExtra = require('fs-extra');
const vite = require('vite');
const node_fs = require('node:fs');
const Visitor_js = require('@swc/core/Visitor.js');
function isLegalSource(fileName) {
if (fileName === "." || fileName === "..") {
return false;
}
if (fileName === "node_modules") {
return false;
}
return true;
}
async function readSourceFiles(root, filter) {
let result = [];
const level1Sources = await node_fs.promises.readdir(root);
const readAll = level1Sources.filter(isLegalSource).map(async (fileName) => {
const subFilePath = node_path.resolve(root, fileName);
const fileStat = await node_fs.promises.stat(subFilePath);
if (fileStat.isDirectory()) {
const subResult = await readSourceFiles(subFilePath, filter);
result = [...result, ...subResult];
} else if (fileStat.isFile()) {
if (filter(subFilePath)) {
result.push(subFilePath);
}
}
});
await Promise.all(readAll);
return result;
}
class FileMarker {
touchedFiles = /* @__PURE__ */ new Set();
sourceFiles = /* @__PURE__ */ new Set();
deadFiles = /* @__PURE__ */ new Set();
viteDynamicImports = /* @__PURE__ */ new Set();
errorFiles = /* @__PURE__ */ new Map();
async init(root, filter) {
this.touchedFiles = /* @__PURE__ */ new Set();
this.viteDynamicImports = /* @__PURE__ */ new Set();
this.sourceFiles = new Set(await readSourceFiles(root, filter));
this.deadFiles = new Set(this.sourceFiles);
}
revive(id) {
if (id.indexOf("node_modules") === -1) {
if (this.sourceFiles.has(id)) {
this.touchedFiles.add(id);
this.deadFiles.delete(id);
}
}
}
kill(id) {
if (id.indexOf("node_modules") === -1) {
if (this.sourceFiles.has(id)) {
this.deadFiles.add(id);
this.touchedFiles.delete(id);
}
}
}
markError(importer, err) {
this.errorFiles.set(importer, err);
}
}
const PLUGIN_NAME = "vite-plugin-deadfile";
const innerLogger = (...contents) => {
console.log(...contents);
};
async function delay(timeout) {
return new Promise((resolve) => setTimeout(resolve, timeout));
}
function log(...contents) {
innerLogger(`
[${PLUGIN_NAME}]: `, ...contents);
}
async function outputLog(contents, outputFile) {
const formattedContents = contents.reduce(
(last, current) => {
last.push(` ${current}`);
return last;
},
[`[${PLUGIN_NAME}]:`]
);
if (outputFile) {
await node_fs.promises.writeFile(outputFile, formattedContents.join("\n"));
innerLogger(
`[${PLUGIN_NAME}]: `,
`Unused source file entries write to: ${node_path.relative(
process.cwd(),
outputFile
)}`
);
} else {
await delay(1);
for (const line of formattedContents) {
innerLogger(line);
}
}
}
const REG_POSTFIX = /[?#].*$/s;
const REG_SAFE_FILE_NAME = /^[a-zA-Z0-9._-]+$/;
const REG_SAFE_POSIX_PATH = /^(\.\/|\/)?([a-zA-Z0-9._-]+\/)+$/;
const REG_SAFE_WIN_PATH = /^(\.\\)?([a-zA-Z0-9._-]+\\)+$/;
function cleanUrl(url) {
return url.replace(REG_POSTFIX, "");
}
function isSafeFileName(name) {
return name.match(REG_SAFE_FILE_NAME);
}
function withTrailingSlash(path) {
if (path[path.length - 1] !== node_path.sep) {
return `${path}${node_path.sep}`;
}
return path;
}
function isSafePath(name) {
return withTrailingSlash(name).match(
node_path.sep === "/" ? REG_SAFE_POSIX_PATH : REG_SAFE_WIN_PATH
);
}
function isParentDir(parent, file) {
return withTrailingSlash(file).startsWith(withTrailingSlash(parent));
}
class ImportVisitor extends Visitor_js.Visitor {
imports = [];
init() {
this.imports = [];
}
visitTsType(n) {
return n;
}
visitImportDeclaration(n) {
const result = super.visitImportDeclaration(n);
this.imports.push(result.source.value);
return result;
}
getImports() {
return this.imports;
}
}
class DynamicImportVisitor extends Visitor_js.Visitor {
collectViteDynamicImport = false;
viteDynamicImports = /* @__PURE__ */ new Set();
init() {
this.viteDynamicImports = /* @__PURE__ */ new Set();
}
visitTsType(n) {
return n;
}
visitCallExpression(n) {
let isHelper = false;
if (n.callee.type === "Identifier") {
if (n.callee.value === "__variableDynamicImportRuntimeHelper") {
isHelper = true;
this.collectViteDynamicImport = true;
}
} else if (n.callee.type === "Import" && this.collectViteDynamicImport) {
const { expression } = n.arguments[0];
if (expression.type === "StringLiteral") {
this.viteDynamicImports.add(expression.value);
}
}
const result = super.visitCallExpression(n);
if (isHelper) {
this.collectViteDynamicImport = false;
}
return result;
}
getViteDynamicImports() {
return [...this.viteDynamicImports];
}
}
const REG_VALID_EXTENSION = /\.\w+$/;
const REG_NODE_MODULES = /node_modules\//;
const REG_HIDDEN_FILES = /\/\.[^/]+$/;
const REG_MISSING_SPECIFIER = /Missing .* specifier in .* package/;
const astSupportedFileExtensions = ["js", "jsx", "ts", "tsx"];
const tsSupportedFileExtensions = ["ts", "tsx"];
function getExt(importer) {
return node_path.extname(
REG_VALID_EXTENSION.test(importer) ? importer : cleanUrl(importer)
).slice(1);
}
function getOutputPath(absRoot, outputDir) {
if (!isSafePath(outputDir)) {
throw `Unsafe outputDir: ${outputDir}`;
}
const absOutputDir = node_path.isAbsolute(outputDir) ? outputDir : node_path.resolve(absRoot, outputDir);
if (!isParentDir(absRoot, absOutputDir)) {
throw `outputDir must be inside: ${absRoot}, but got: ${absOutputDir}`;
}
return absOutputDir;
}
async function ensureOutputFilePath(absRoot, outputDir, output) {
const dir = getOutputPath(absRoot, outputDir);
if (!isSafeFileName(output)) {
throw `Unsafe output file name: ${output}`;
}
await fsExtra.ensureDir(dir);
return node_path.resolve(dir, output);
}
function createFileFilter(root, include, rawExclude, includeHidden) {
const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [rawExclude].filter((o) => o !== null);
exclude.push(REG_NODE_MODULES);
if (!includeHidden) {
exclude.push(REG_HIDDEN_FILES);
}
return vite.createFilter(include, exclude, {
resolve: root
});
}
function isLegalTransformTarget(importer, onlyScanTypeRef = false) {
if (!importer.startsWith("/") || importer.includes("node_modules")) {
return false;
}
const ext = getExt(importer);
const legalExts = onlyScanTypeRef ? tsSupportedFileExtensions : astSupportedFileExtensions;
if (!legalExts.includes(ext)) {
return false;
}
return true;
}
function markDynamicImportFiles(fileMarker, root, isDynamicModuleLive) {
const dynImport = fileMarker.viteDynamicImports;
if (dynImport.size > 0) {
if (isDynamicModuleLive) {
for (const file of dynImport) {
const rel = node_path.relative(root, file);
if (!isDynamicModuleLive(rel)) {
fileMarker.kill(file);
}
}
}
}
}
function shouldThrow(throwWhenFound, fileMarker) {
if (throwWhenFound !== false) {
if (throwWhenFound === true && fileMarker.deadFiles.size > 0 || typeof throwWhenFound === "number" && fileMarker.deadFiles.size >= throwWhenFound) {
return true;
}
}
return false;
}
function getPrePlugin(fileMarker, {
root = ".",
include = [],
exclude = [],
includeHiddenFiles = false
}) {
const absoluteRoot = node_path.resolve(root);
let visitor;
return {
name: "vite-plugin-deadfile-pre",
enforce: "pre",
apply: "build",
async configResolved() {
const fileFilter = createFileFilter(
root,
include,
exclude,
includeHiddenFiles
);
await fileMarker.init(absoluteRoot, fileFilter);
visitor = new ImportVisitor();
},
load(id) {
const clearId = cleanUrl(id);
const realId = clearId.startsWith("\0vite:asset:") ? node_path.resolve(absoluteRoot, clearId.substring(12)) : clearId;
fileMarker.revive(realId);
},
/**
* this hook use swc to scan and mark imports of typescript files
* this must be done in pre phase because 'type only reference' imports
* will be removed after ts is transformed into js
*/
async transform(source, importer) {
if (!isLegalTransformTarget(importer, true)) return;
let mod = void 0;
try {
mod = await core.parse(source, {
syntax: "typescript",
tsx: true,
target: "es2022"
});
} catch (e) {
log("parse error: ", importer, e);
}
if (mod) {
visitor.init();
visitor.visitProgram(mod);
const rawImports = visitor.getImports();
const resolvedImports = await Promise.all(
rawImports.map((rawImport) => {
const resolved = this.resolve(rawImport, importer);
resolved.catch((reason) => {
if (REG_MISSING_SPECIFIER.test(reason.message)) {
fileMarker.markError(
importer,
`Error when "${importer}" import "${rawImport}": ${reason.message}`
);
} else {
this.error(reason);
}
});
return resolved;
})
);
const resolvedIds = resolvedImports.map((r) => r?.id);
for (const id of resolvedIds) {
if (id) {
fileMarker.revive(id);
}
}
}
}
};
}
function getPostPlugin(fileMarker, {
root = ".",
outputDir = ".",
throwWhenFound = false,
isDynamicModuleLive,
output
}) {
let visitor;
const absoluteRoot = node_path.resolve(root);
return {
name: "vite-plugin-deadfile-post",
enforce: "post",
apply: "build",
configResolved() {
visitor = new DynamicImportVisitor();
},
load(id) {
fileMarker.revive(id);
},
/**
* this is used to scan and mark dynamic batch imports
* generated by vite such as 'import(`./modules/${name}.ts`)'
* this has to be done in the post phase
*/
async transform(source, importer) {
if (!isLegalTransformTarget(importer)) return;
let mod = void 0;
try {
mod = await core.parse(source, {
syntax: "ecmascript",
dynamicImport: true,
target: "es2022"
});
} catch (e) {
log("parse error: ", importer, e);
}
if (mod) {
visitor.init();
visitor.visitProgram(mod);
const rawImports = visitor.getViteDynamicImports();
if (rawImports.length > 0) {
const resolvedImports = await Promise.all(
rawImports.map((rawImport) => {
const resolved = this.resolve(rawImport, importer);
return resolved;
})
);
for (const resolvedId of resolvedImports) {
if (resolvedId) {
fileMarker.viteDynamicImports.add(resolvedId.id);
}
}
}
}
},
async buildEnd(errors) {
if (errors !== void 0) return;
const dynImport = fileMarker.viteDynamicImports;
markDynamicImportFiles(fileMarker, absoluteRoot, isDynamicModuleLive);
if (fileMarker.errorFiles.size > 0) {
const messages = [];
for (const err of fileMarker.errorFiles) {
messages.push(err[1]);
}
this.error(`[vite-plugin-deadfile]: ${messages.join("\n")}`);
}
let result = [
`All source files: ${fileMarker.sourceFiles.size}`,
`Used source files: ${fileMarker.touchedFiles.size}`,
`Unused source files: ${fileMarker.deadFiles.size}`,
...[...fileMarker.deadFiles].map(
(fullPath) => ` ./${node_path.relative(absoluteRoot, fullPath)}`
)
];
if (dynImport.size > 0 && !isDynamicModuleLive) {
result = [
...result,
`You may need to config 'isDynamicModuleLive' to check if the following ${dynImport.size} dynamically glob-import file${dynImport.size > 1 ? "s are" : " is"} needed, more info https://github.com/stauren/vite-plugin-deadfile?tab=readme-ov-file#isdynamicmodulelive`,
...[...dynImport].map(
(fullPath) => ` .${fullPath.substring(absoluteRoot.length)}`
)
];
}
if (output) {
const outputFile = await ensureOutputFilePath(
absoluteRoot,
outputDir,
output
).catch((err) => {
this.error(err);
});
if (outputFile) {
outputLog(result, outputFile);
}
} else {
outputLog(result);
}
if (shouldThrow(throwWhenFound, fileMarker)) {
this.error(
`[vite-plugin-deadfile]: Found ${fileMarker.deadFiles.size} unused source file${fileMarker.deadFiles.size > 1 ? "s" : ""}.
${[
...fileMarker.deadFiles
].map((fullPath) => ` ./${node_path.relative(absoluteRoot, fullPath)}`).join("\n")}`
);
}
}
};
}
function vitePluginDeadFile({
root = ".",
include = [],
exclude = [],
includeHiddenFiles = false,
outputDir = ".",
throwWhenFound = false,
isDynamicModuleLive,
output
}) {
const fileMarker = new FileMarker();
return [
getPrePlugin(fileMarker, {
root,
include,
exclude,
includeHiddenFiles
}),
getPostPlugin(fileMarker, {
root,
outputDir,
throwWhenFound,
output,
isDynamicModuleLive
})
];
}
module.exports = vitePluginDeadFile;