UNPKG

vite-plugin-deadfile

Version:

This plugin helps to find unused source file(dead files) in Vite projects.

463 lines (455 loc) 13.7 kB
'use strict'; 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;