qleaner 1.0.22 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/command.js CHANGED
@@ -4,6 +4,7 @@ const Table = require("cli-table3");
4
4
  const parser = require("@babel/parser");
5
5
  const traverse = require("@babel/traverse").default;
6
6
  const path = require("path");
7
+ const cliProgress = require("cli-progress");
7
8
  const { createResolver } = require("./utils/resolver");
8
9
  const {
9
10
  loadCache,
@@ -14,6 +15,14 @@ const {
14
15
  const { isExcludedFile, compareFiles } = require("./utils/utils");
15
16
 
16
17
  async function getFiles(directory = "src", options, chalk) {
18
+ const bars = new cliProgress.MultiBar({
19
+ clearOnComplete: false,
20
+ hideCursor: true,
21
+ format: chalk.cyan('{name}') +
22
+ ' |' + '{bar}' + '| ' +
23
+ '{value}/{total} files || {status}'
24
+ }, cliProgress.Presets.shades_grey);
25
+
17
26
  const contentPaths = [`${directory}/**/*.{tsx,ts,js,jsx}`];
18
27
  if (options.excludeDir && options.excludeDir.length > 0) {
19
28
  options.excludeDir.forEach((dir) => {
@@ -28,6 +37,12 @@ async function getFiles(directory = "src", options, chalk) {
28
37
 
29
38
  const files = await fg(contentPaths);
30
39
  const imports = [];
40
+
41
+ const scanBar = bars.create(files.length, 0, {
42
+ name: 'Scanning Files',
43
+ status: 'Scanning...',
44
+ });
45
+
31
46
  for (const file of files) {
32
47
  const code = fs.readFileSync(file, "utf8");
33
48
  const ast = parser.parse(code, {
@@ -56,7 +71,10 @@ async function getFiles(directory = "src", options, chalk) {
56
71
  // }
57
72
  // },
58
73
  // });
74
+ scanBar.increment();
59
75
  }
76
+
77
+ bars.stop();
60
78
 
61
79
  if (options.table) {
62
80
  const tableImports = new Table({
@@ -107,6 +125,14 @@ async function getFiles(directory = "src", options, chalk) {
107
125
  }
108
126
 
109
127
  async function unUsedFiles(chalk, directory = "src", options) {
128
+ const bars = new cliProgress.MultiBar({
129
+ clearOnComplete: false,
130
+ hideCursor: true,
131
+ format: chalk.cyan('{name}') +
132
+ ' |' + '{bar}' + '| ' +
133
+ '{value}/{total} files || {status}'
134
+ }, cliProgress.Presets.shades_grey);
135
+
110
136
  const resolver = createResolver(directory);
111
137
  const cache = loadCache(process.cwd());
112
138
  const contentPaths = [`${directory}/**/*.{tsx,ts,js,jsx}`];
@@ -125,9 +151,14 @@ async function unUsedFiles(chalk, directory = "src", options) {
125
151
  let imports = [];
126
152
  const unusedFiles = [];
127
153
 
154
+ const scanBar = bars.create(files.length, 0, {
155
+ name: 'Scanning Files',
156
+ status: 'Scanning...',
157
+ });
158
+
128
159
  // debug log
129
160
  // let debugCount = 0;
130
- // console.log('total files---', files.length);
161
+ let i = 0;
131
162
  for (const file of files) {
132
163
  const code = fs.readFileSync(file, "utf8");
133
164
  if (needsRebuild(file, code, cache)) {
@@ -162,6 +193,9 @@ async function unUsedFiles(chalk, directory = "src", options) {
162
193
  // debugCount++;
163
194
  // console.log('cache hit', debugCount);
164
195
  }
196
+ scanBar.update(++i, {
197
+ status: 'Scanning...',
198
+ });
165
199
  }
166
200
 
167
201
  // imports beings empty shows all the files were cached
@@ -169,7 +203,13 @@ async function unUsedFiles(chalk, directory = "src", options) {
169
203
  imports = cache[directory]? cache[directory].imports: [];
170
204
  }
171
205
 
206
+ const checkBar = bars.create(files.length, 0, {
207
+ name: 'Checking Usage',
208
+ status: 'Checking...',
209
+ });
210
+
172
211
  // debugCount = 0;
212
+ i = 0;
173
213
  for (const file of files) {
174
214
  const code = fs.readFileSync(file, "utf8");
175
215
  if (!cache[file].isImported || needsRebuild(file, code, cache)) {
@@ -200,7 +240,12 @@ async function unUsedFiles(chalk, directory = "src", options) {
200
240
  // debugCount++;
201
241
  // console.log("debug hit", debugCount);
202
242
  }
243
+ checkBar.update(++i, {
244
+ status: 'Checking...',
245
+ });
203
246
  }
247
+
248
+ bars.stop();
204
249
  cache[directory] = {
205
250
  imports: imports,
206
251
  }
@@ -1,190 +1,316 @@
1
1
  const fg = require("fast-glob");
2
2
  const fs = require("fs");
3
+ const path = require("path");
3
4
  const parser = require("@babel/parser");
4
5
  const traverse = require("@babel/traverse").default;
6
+ const Table = require("cli-table3");
5
7
  const { getCssImages } = require("../utils/cssImages");
6
- const path = require("path");
7
- const { createResolver } = require("../utils/resolver");
8
- const { compareFiles, isExcludedFile, askDeleteFiles } = require("../utils/utils");
9
- const Table = require('cli-table3');
8
+ const { askDeleteFiles } = require("../utils/utils");
9
+ const cliProgress = require("cli-progress");
10
10
 
11
- async function getUnusedImages(chalk, imageDirectory, codeDirectory, options) {
12
- const usedImages = [];
13
- const unusedImages = [];
14
- const imageRegex = /\.(png|jpg|jpeg|svg|gif|webp)$/i;
15
- const contentPaths = [`${imageDirectory}/**/*.{png,jpg,jpeg,svg,gif,webp}`];
16
- if (options.excludeDirAssets && options.excludeDirAssets.length > 0) {
17
- options.excludeDirAssets.forEach((dir) => {
18
- contentPaths.push(`!${dir}/**`);
19
- });
20
- }
21
- if (options.excludeFileAsset && options.excludeFileAsset.length > 0) {
22
- options.excludeFileAsset.forEach((file) => {
23
- contentPaths.push(`!${imageDirectory}/**/${file}`);
24
- });
25
- }
11
+ // Regex for matching url("x.png") or url('x.png') or bg-[url('x.png')]
12
+ const URL_EXTRACT_REGEX = /url\((['"]?)([^"')]+)\1\)/gi;
26
13
 
27
- const rootPaths = [`${codeDirectory}/**/*.{js,jsx,ts,tsx}`];
28
- if (options.excludeDirCode && options.excludeDirCode.length > 0) {
29
- options.excludeDirCode.forEach((dir) => {
30
- rootPaths.push(`!${dir}/**`);
31
- });
14
+ // Normal image file regex
15
+ const IMAGE_REGEX = /\.(png|jpe?g|svg|gif|webp)$/i;
16
+
17
+ /**
18
+ * Normalize image paths for consistent matching.
19
+ */
20
+ function normalize(value, imageDirectory) {
21
+ if (!value) return null;
22
+
23
+ // Extract url() paths
24
+ const match = [...value.matchAll(URL_EXTRACT_REGEX)];
25
+ if (match.length > 0) {
26
+ value = match[0][2];
32
27
  }
33
- if (options.excludeFileCode && options.excludeFileCode.length > 0) {
34
- options.excludeFileCode.forEach((file) => {
35
- rootPaths.push(`!${codeDirectory}/**/${file}`);
36
- });
28
+
29
+ // Remove query params
30
+ value = value.split("?")[0];
31
+
32
+ // Convert leading "/" to relative project path
33
+ if (value.startsWith("/")) {
34
+ return path.resolve(path.join(imageDirectory, value.slice(1)));
37
35
  }
38
36
 
39
- const files = await fg(contentPaths);
40
- const codeFiles = await fg(rootPaths);
37
+ // Resolve relative paths
38
+ return path.resolve(path.join(imageDirectory, value));
39
+ }
41
40
 
42
- for (const file of codeFiles) {
41
+ /**
42
+ * Extracts all strings in a tagged template literal (styled-components, css``)
43
+ */
44
+ function extractFromTemplateLiteral(quasis) {
45
+ const results = [];
46
+ quasis.forEach((q) => {
47
+ const matches = [...q.value.raw.matchAll(URL_EXTRACT_REGEX)];
48
+ for (const m of matches) {
49
+ results.push(m[2]);
50
+ }
51
+ if (IMAGE_REGEX.test(q.value.raw)) {
52
+ results.push(q.value.raw);
53
+ }
54
+ });
55
+ return results;
56
+ }
57
+
58
+ /**
59
+ * Extract images from JSX style object: style={{ backgroundImage: "url('/img/a.png')" }}
60
+ */
61
+ function extractFromJSXStyle(node, file, collected) {
62
+ if (!node || node.type !== "JSXExpressionContainer") return;
63
+
64
+ const expr = node.expression;
65
+ if (!expr || expr.type !== "ObjectExpression") return;
66
+
67
+ expr.properties.forEach((prop) => {
68
+ if (
69
+ prop.type !== "ObjectProperty" ||
70
+ !prop.key ||
71
+ !prop.value
72
+ ) return;
73
+
74
+ const keyName = prop.key.name || prop.key.value;
75
+
76
+ if (!keyName) return;
77
+
78
+ const isImageField =
79
+ keyName.toLowerCase().includes("background") ||
80
+ keyName.toLowerCase().includes("image") ||
81
+ keyName.toLowerCase().includes("mask");
82
+
83
+ if (!isImageField) return;
84
+
85
+ // String literal
86
+ if (prop.value.type === "StringLiteral") {
87
+ const raw = prop.value.value;
88
+ const matches = [...raw.matchAll(URL_EXTRACT_REGEX)];
89
+ if (matches.length > 0) {
90
+ matches.forEach((m) =>
91
+ collected.add(JSON.stringify({ path: m[2], file }))
92
+ );
93
+ } else if (IMAGE_REGEX.test(raw)) {
94
+ collected.add(JSON.stringify({ path: raw, file }));
95
+ }
96
+ }
97
+
98
+ // Template literal
99
+ if (prop.value.type === "TemplateLiteral") {
100
+ extractFromTemplateLiteral(prop.value.quasis).forEach((v) => {
101
+ collected.add(JSON.stringify({ path: v, file }));
102
+ });
103
+ }
104
+ });
105
+ }
106
+
107
+ /**
108
+ * MAIN FUNCTION
109
+ */
110
+ async function getUnusedImages(chalk, imageDirectory, codeDirectory, options) {
111
+ const bars = new cliProgress.MultiBar({
112
+ clearOnComplete: false,
113
+ hideCursor: true,
114
+ format: chalk.cyan('{name}') +
115
+ ' |' + '{bar}' + '| ' +
116
+ '{value}/{total} files || {status}'
117
+ }, cliProgress.Presets.shades_grey)
118
+
119
+ const used = new Set();
120
+ const unusedImages = [];
121
+
122
+ // ---- Collect image files in asset directory ----
123
+ const imageFiles = await fg([`${imageDirectory}/**/*.{png,jpg,jpeg,svg,gif,webp}`]);
43
124
 
125
+ // ---- Scan Code Files ----
126
+ const codeFiles = await fg([`${codeDirectory}/**/*.{js,jsx,ts,tsx}`]);
127
+ const codeBar = bars.create(codeFiles.length, 0, {
128
+ name: 'Code Files',
129
+ status: 'Scanning...',
130
+ });
131
+
132
+
133
+ for (const file of codeFiles) {
44
134
  const code = fs.readFileSync(file, "utf8");
135
+
45
136
  const ast = parser.parse(code, {
46
137
  sourceType: "module",
47
138
  plugins: ["jsx", "typescript"],
48
139
  });
49
140
 
50
141
  traverse(ast, {
51
- // <img src="...">
52
- JSXAttribute(path) {
53
- if (path.node.name.name !== "src") return;
54
-
55
- const value = path.node.value;
56
-
57
- // String literals: `img/a.png`
58
- if (value.type === "StringLiteral") {
59
- if (imageRegex.test(value.value)) {
60
- usedImages.push({
61
- value: value.value,
62
- file,
63
- });
142
+ /**
143
+ * import logo from './img/a.png'
144
+ */
145
+ ImportDeclaration(pathNode) {
146
+ const val = pathNode.node.source.value;
147
+ if (IMAGE_REGEX.test(val)) {
148
+ used.add(JSON.stringify({ path: val, file }));
149
+ }
150
+ },
151
+
152
+ /**
153
+ * require('./img/a.png')
154
+ */
155
+ CallExpression(pathNode) {
156
+ const callee = pathNode.node.callee;
157
+ const args = pathNode.node.arguments;
158
+
159
+ if (
160
+ callee.type === "Identifier" &&
161
+ callee.name === "require" &&
162
+ args.length &&
163
+ args[0].type === "StringLiteral"
164
+ ) {
165
+ const val = args[0].value;
166
+ if (IMAGE_REGEX.test(val)) {
167
+ used.add(JSON.stringify({ path: val, file }));
168
+ }
169
+ }
170
+
171
+ // dynamic import("./a.png")
172
+ if (
173
+ callee.type === "Import" &&
174
+ args.length &&
175
+ args[0].type === "StringLiteral"
176
+ ) {
177
+ const val = args[0].value;
178
+ if (IMAGE_REGEX.test(val)) {
179
+ used.add(JSON.stringify({ path: val, file }));
64
180
  }
65
181
  }
182
+ },
66
183
 
67
- if (value.type === "JSXExpressionContainer") {
68
- const expr = value.expression;
69
- // String literals: `img/a.png`
70
- if (expr.type === "StringLiteral" && imageRegex.test(expr.value)) {
71
- usedImages.push({
72
- value: expr.value,
73
- file,
74
- });
184
+ /**
185
+ * <img src="...">
186
+ */
187
+ JSXAttribute(attr) {
188
+ if (attr.node.name.name === "src") {
189
+ const v = attr.node.value;
190
+ if (!v) return;
191
+
192
+ if (v.type === "StringLiteral") {
193
+ if (IMAGE_REGEX.test(v.value)) {
194
+ used.add(JSON.stringify({ path: v.value, file }));
195
+ }
75
196
  }
76
197
 
77
- // Template literals: `/images/${name}.png`
78
- if (expr.type === "TemplateLiteral") {
79
- expr.quasis.forEach((q) => {
80
- if (imageRegex.test(q.value.raw)) {
81
- usedImages.push({
82
- value: q.value.raw,
83
- file,
84
- });
198
+ if (v.type === "JSXExpressionContainer") {
199
+ const expr = v.expression;
200
+
201
+ if (expr.type === "StringLiteral") {
202
+ if (IMAGE_REGEX.test(expr.value)) {
203
+ used.add(JSON.stringify({ path: expr.value, file }));
85
204
  }
86
- });
205
+ }
206
+
207
+ if (expr.type === "TemplateLiteral") {
208
+ extractFromTemplateLiteral(expr.quasis).forEach((v) => {
209
+ used.add(JSON.stringify({ path: v, file }));
210
+ });
211
+ }
87
212
  }
88
213
  }
89
- },
90
214
 
91
- // String literals anywhere in code
92
- StringLiteral(path) {
93
- if (imageRegex.test(path.node.value)) {
94
- usedImages.push({
95
- value: path.node.value,
96
- file,
97
- });
215
+ // Handle style={{ backgroundImage: "url(...)" }}
216
+ if (attr.node.name.name === "style") {
217
+ extractFromJSXStyle(attr.node.value, file, used);
98
218
  }
99
219
  },
100
220
 
101
- // Template literals: `/images/${name}.png`
102
- TemplateLiteral(path) {
103
- path.node.quasis.forEach((quasi) => {
104
- if (imageRegex.test(quasi.value.raw)) {
105
- usedImages.push({
106
- value: quasi.value.raw,
107
- file,
108
- });
109
- }
110
- });
221
+ /**
222
+ * String Literals anywhere
223
+ */
224
+ StringLiteral(p) {
225
+ const val = p.node.value;
226
+
227
+ if (IMAGE_REGEX.test(val)) {
228
+ used.add(JSON.stringify({ path: val, file }));
229
+ }
230
+
231
+ // detect url("...") inside strings (e.g., Tailwind)
232
+ const matches = [...val.matchAll(URL_EXTRACT_REGEX)];
233
+ matches.forEach((m) =>
234
+ used.add(JSON.stringify({ path: m[2], file }))
235
+ );
111
236
  },
112
237
 
113
- // Arrays: ["img/a.png", "img/b.png"]
114
- ArrayExpression(path) {
115
- path.node.elements.forEach((el) => {
116
- if (el?.type === "StringLiteral" && imageRegex.test(el.value)) {
117
- usedImages.push({
118
- value: el.value,
119
- file,
120
- });
121
- }
238
+ /**
239
+ * Template Literals anywhere
240
+ */
241
+ TemplateLiteral(p) {
242
+ extractFromTemplateLiteral(p.node.quasis).forEach((v) => {
243
+ used.add(JSON.stringify({ path: v, file }));
122
244
  });
123
245
  },
124
246
 
125
- // Objects: { image: "img/a.png" }
126
- ObjectProperty(path) {
127
- const val = path.node.value;
128
- if (val.type === "StringLiteral" && imageRegex.test(val.value)) {
129
- usedImages.push({
130
- value: val.value,
131
- file,
132
- });
133
- }
247
+ /**
248
+ * styled-components & css`` blocks
249
+ */
250
+ TaggedTemplateExpression(p) {
251
+ const quasi = p.node.quasi;
252
+ const extracted = extractFromTemplateLiteral(quasi.quasis);
253
+ extracted.forEach((v) => used.add(JSON.stringify({ path: v, file })));
134
254
  },
135
255
  });
256
+ codeBar.increment();
136
257
  }
137
258
 
138
- const cssImages = await getCssImages(codeDirectory);
139
- const combinedImages = [...usedImages, ...cssImages];
140
-
141
-
142
- const resolver = createResolver(imageDirectory);
143
-
144
- for (const file of files) {
145
- let i = 0
146
- let isFound = false;
147
- while(!isFound && i < combinedImages.length) {
148
- const imageFilePath = await resolver(imageDirectory, combinedImages[i].value);
149
- const filePath = path.resolve(file);
150
- console.log(chalk.yellow(filePath), chalk.blue(imageFilePath));
151
- if(compareFiles(filePath, imageFilePath)) {
152
- isFound = true;
153
- break;
154
- }else if(i === combinedImages.length - 1) {
155
- if(options.excludeFilePrint && options.excludeFilePrint.length > 0) {
156
- if(!isExcludedFile(filePath, options.excludeFilePrint)) {
157
- unusedImages.push(filePath);
158
- }
159
- }else{
160
- unusedImages.push(filePath);
161
- }
162
- }
163
- i++;
164
- }
259
+ // Add CSS images
260
+ const cssImages = await getCssImages(codeDirectory, bars);
261
+ cssImages.forEach((img) =>
262
+ used.add(JSON.stringify({ path: img.value, file: img.file }))
263
+ );
264
+ const normalizeBar = bars.create(used.size, 0, {
265
+ name: 'Normalizing Images',
266
+ status: 'Normalizing...',
267
+ });
268
+
269
+ // Normalize all used images
270
+ const normalizedUsed = new Set();
271
+ for (const entry of used) {
272
+ normalizeBar.increment();
273
+ const { path: p } = JSON.parse(entry);
274
+ const normalized = normalize(p, imageDirectory);
275
+ if (normalized) normalizedUsed.add(normalized);
165
276
  }
166
277
 
167
- if(options.table) {
278
+ const unusedBar = bars.create(imageFiles.length, 0, {
279
+ name: 'Determining Unused Images',
280
+ status: 'Determining...',
281
+ });
282
+ // ---- Determine unused ----
283
+ for (const img of imageFiles) {
284
+ unusedBar.increment();
285
+ const full = path.resolve(img);
286
+ if (!normalizedUsed.has(full)) {
287
+ unusedImages.push(full);
288
+ }
289
+ }
290
+ bars.stop();
291
+ // ---- Output table or list ----
292
+ if (options.table) {
168
293
  const table = new Table({
169
- head: options.dryRun ? ['Unused Images (Would Delete)'] : ['Unused Images'],
170
- colWidths: [100]
171
- });
172
- unusedImages.forEach(image => {
173
- table.push([image]);
294
+ head: options.dryRun ? ["Unused Images (Would Delete)"] : ["Unused Images"],
295
+ colWidths: [100],
174
296
  });
297
+ unusedImages.forEach((img) => table.push([img]));
175
298
  console.log(table.toString());
176
- }else{
177
- unusedImages.forEach(image => {
178
- console.log(chalk.red(image));
179
- });
299
+ } else {
300
+ unusedImages.forEach((img) => console.log(chalk.red(img)));
180
301
  }
181
302
 
182
- if(options.dryRun && unusedImages.length > 0) {
183
- console.log(chalk.cyan(`\n[DRY RUN] Would delete ${unusedImages.length} file(s)`));
184
- console.log(chalk.cyan('Run without --dry-run to actually delete files\n'));
185
- } else if (!options.dryRun && unusedImages.length > 0) {
303
+ // ---- deletion logic ----
304
+ if (options.dryRun) {
305
+ console.log(
306
+ chalk.cyan(
307
+ `\n[DRY RUN] Would delete ${unusedImages.length} file(s)`
308
+ )
309
+ );
310
+ } else if (unusedImages.length > 0) {
186
311
  askDeleteFiles(unusedImages);
187
312
  }
313
+
188
314
  return unusedImages;
189
315
  }
190
316
 
@@ -1,6 +1,6 @@
1
1
  const { getFiles, unUsedFiles } = require("../command");
2
2
  const Table = require('cli-table3');
3
- const askDeleteFiles = require("../utils/utils");
3
+ const { askDeleteFiles } = require("../utils/utils");
4
4
 
5
5
 
6
6
  async function list(chalk, path, options) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qleaner",
3
3
  "packageManager": "yarn@4.6.0",
4
- "version": "1.0.22",
4
+ "version": "1.0.23",
5
5
  "main": "command.js",
6
6
  "bin": "./bin/cli.js",
7
7
  "scripts": {
@@ -16,6 +16,7 @@
16
16
  "@babel/parser": "^7.28.5",
17
17
  "@babel/traverse": "^7.28.5",
18
18
  "chalk": "^5.6.2",
19
+ "cli-progress": "^3.12.0",
19
20
  "cli-table3": "^0.6.5",
20
21
  "commander": "^14.0.2",
21
22
  "enhanced-resolve": "^5.18.3",
@@ -20,12 +20,17 @@ function extractCssImages(cssContent, images, file) {
20
20
  return images
21
21
  }
22
22
 
23
- async function getCssImages(directory = "src") {
23
+ async function getCssImages(directory = "src", bars) {
24
24
  const cssFiles = await fg([
25
25
  `${directory}/**/*.{css,scss}`,
26
26
  ]);
27
27
  let images = [];
28
+ const cssBar = bars.create(cssFiles.length, 0, {
29
+ name: 'CSS Files',
30
+ status: 'Scanning...',
31
+ });
28
32
  for (const file of cssFiles) {
33
+ cssBar.increment();
29
34
  const css = fs.readFileSync(file, "utf-8");
30
35
  images = extractCssImages(css, images, file);
31
36
  }
package/utils/resolver.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const path = require('path');
2
+ const fs = require('fs');
2
3
  const {create} = require('enhanced-resolve');
3
4
 
4
5
 
@@ -27,6 +28,9 @@ const resolver = create({
27
28
  }
28
29
  }
29
30
 
31
+
32
+
33
+
30
34
  module.exports = {
31
- createResolver
35
+ createResolver,
32
36
  };