1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs' ;
4
+ import path from 'path' ;
5
+ import { fileURLToPath } from 'url' ;
6
+
7
+ const __filename = fileURLToPath ( import . meta. url ) ;
8
+ const __dirname = path . dirname ( __filename ) ;
9
+
10
+ // Configuration
11
+ const CONFIG = {
12
+ imagesDir : 'static/images' ,
13
+ docsDir : 'docs' ,
14
+ rootFiles : [ '.' ] , // Check root markdown files too
15
+ imageExtensions : [ '.gif' , '.png' , '.jpg' , '.jpeg' , '.webp' , '.svg' ] ,
16
+ markupExtensions : [ '.md' , '.mdx' , '.js' , '.ts' , '.tsx' , '.jsx' ] , // Include JS/TS for potential imports
17
+ verbose : false
18
+ } ;
19
+
20
+ class UnusedImageFinder {
21
+ constructor ( config ) {
22
+ this . config = config ;
23
+ this . stats = {
24
+ totalImages : 0 ,
25
+ referencedImages : 0 ,
26
+ unusedImages : 0 ,
27
+ totalMarkupFiles : 0
28
+ } ;
29
+ this . imageReferences = new Set ( ) ; // Track which images are referenced
30
+ }
31
+
32
+ log ( message , level = 'info' ) {
33
+ if ( ! this . config . verbose && level === 'debug' ) return ;
34
+ const prefix = level === 'error' ? '❌' : level === 'warn' ? '⚠️' : level === 'debug' ? '🔍' : '' ;
35
+ if ( prefix ) {
36
+ console . error ( `${ prefix } ${ message } ` ) ;
37
+ } else {
38
+ console . error ( message ) ; // Use stderr for logging, stdout for results
39
+ }
40
+ }
41
+
42
+ // Recursively find all files with given extensions
43
+ findFiles ( dir , extensions ) {
44
+ const files = [ ] ;
45
+
46
+ const walk = ( currentDir ) => {
47
+ try {
48
+ const items = fs . readdirSync ( currentDir ) ;
49
+ for ( const item of items ) {
50
+ const fullPath = path . join ( currentDir , item ) ;
51
+ let stat ;
52
+ try {
53
+ stat = fs . statSync ( fullPath ) ;
54
+ } catch ( error ) {
55
+ continue ; // Skip inaccessible files
56
+ }
57
+
58
+ if ( stat . isDirectory ( ) ) {
59
+ // Skip node_modules and other common ignore patterns
60
+ if ( ! item . startsWith ( '.' ) && item !== 'node_modules' ) {
61
+ walk ( fullPath ) ;
62
+ }
63
+ } else if ( extensions . some ( ext => item . toLowerCase ( ) . endsWith ( ext . toLowerCase ( ) ) ) ) {
64
+ files . push ( fullPath ) ;
65
+ }
66
+ }
67
+ } catch ( error ) {
68
+ this . log ( `Cannot read directory ${ currentDir } : ${ error . message } ` , 'warn' ) ;
69
+ }
70
+ } ;
71
+
72
+ walk ( dir ) ;
73
+ return files ;
74
+ }
75
+
76
+ // Get all images in the repository
77
+ getAllImages ( ) {
78
+ const imageFiles = this . findFiles ( this . config . imagesDir , this . config . imageExtensions ) ;
79
+ this . stats . totalImages = imageFiles . length ;
80
+ this . log ( `Found ${ imageFiles . length } images` , 'debug' ) ;
81
+ return imageFiles ;
82
+ }
83
+
84
+ // Get all markup files that could reference images
85
+ getAllMarkupFiles ( ) {
86
+ const markupFiles = [ ] ;
87
+
88
+ // Check docs directory
89
+ if ( fs . existsSync ( this . config . docsDir ) ) {
90
+ markupFiles . push ( ...this . findFiles ( this . config . docsDir , this . config . markupExtensions ) ) ;
91
+ }
92
+
93
+ // Check root files
94
+ const rootMarkupFiles = this . findFiles ( '.' , this . config . markupExtensions )
95
+ . filter ( file => ! file . includes ( 'node_modules' ) && ! file . includes ( this . config . docsDir ) ) ;
96
+ markupFiles . push ( ...rootMarkupFiles ) ;
97
+
98
+ this . stats . totalMarkupFiles = markupFiles . length ;
99
+ this . log ( `Found ${ markupFiles . length } markup files` , 'debug' ) ;
100
+ return markupFiles ;
101
+ }
102
+
103
+ // Extract image references from a file's content
104
+ extractImageReferences ( filePath , content ) {
105
+ const references = new Set ( ) ;
106
+
107
+ // Pattern 1: Markdown image syntax 
108
+ const markdownImagePattern = / ! \[ [ ^ \] ] * \] \( ( [ ^ ) ] + ) \) / g;
109
+ let match ;
110
+ while ( ( match = markdownImagePattern . exec ( content ) ) !== null ) {
111
+ references . add ( this . normalizeImagePath ( match [ 1 ] , filePath ) ) ;
112
+ }
113
+
114
+ // Pattern 2: HTML img src
115
+ const htmlImagePattern = / < i m g [ ^ > ] + s r c = [ " ' ] ( [ ^ " ' ] + ) [ " ' ] [ ^ > ] * > / gi;
116
+ while ( ( match = htmlImagePattern . exec ( content ) ) !== null ) {
117
+ references . add ( this . normalizeImagePath ( match [ 1 ] , filePath ) ) ;
118
+ }
119
+
120
+ // Pattern 3: Import statements (for JS/TS files)
121
+ const importPattern = / i m p o r t \s + [ ^ ' " ] * [ ' " ] ( [ ^ ' " ] * \. (?: p n g | j p g | j p e g | g i f | w e b p | s v g ) ) [ ' " ] ; ? / gi;
122
+ while ( ( match = importPattern . exec ( content ) ) !== null ) {
123
+ references . add ( this . normalizeImagePath ( match [ 1 ] , filePath ) ) ;
124
+ }
125
+
126
+ // Pattern 4: require() calls for images
127
+ const requirePattern = / r e q u i r e \s * \( \s * [ ' " ] ( [ ^ ' " ] * \. (?: p n g | j p g | j p e g | g i f | w e b p | s v g ) ) [ ' " ] ? \s * \) / gi;
128
+ while ( ( match = requirePattern . exec ( content ) ) !== null ) {
129
+ references . add ( this . normalizeImagePath ( match [ 1 ] , filePath ) ) ;
130
+ }
131
+
132
+ // Pattern 5: URL() in CSS-like content
133
+ const urlPattern = / u r l \s * \( \s * [ ' " ] ? ( [ ^ ' " ] * \. (?: p n g | j p g | j p e g | g i f | w e b p | s v g ) ) [ ' " ] ? \s * \) / gi;
134
+ while ( ( match = urlPattern . exec ( content ) ) !== null ) {
135
+ references . add ( this . normalizeImagePath ( match [ 1 ] , filePath ) ) ;
136
+ }
137
+
138
+ return Array . from ( references ) ;
139
+ }
140
+
141
+ // Normalize image paths to match actual file paths
142
+ normalizeImagePath ( imagePath , referencingFile ) {
143
+ // Remove query parameters and fragments
144
+ imagePath = imagePath . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] ;
145
+
146
+ // Skip external URLs
147
+ if ( imagePath . startsWith ( 'http://' ) || imagePath . startsWith ( 'https://' ) || imagePath . startsWith ( '//' ) ) {
148
+ return null ;
149
+ }
150
+
151
+ let normalizedPath ;
152
+
153
+ if ( imagePath . startsWith ( 'static/images/' ) ) {
154
+ // Absolute reference from project root
155
+ normalizedPath = imagePath ;
156
+ } else if ( imagePath . startsWith ( 'images/' ) ) {
157
+ // Relative to static/ directory
158
+ normalizedPath = `static/${ imagePath } ` ;
159
+ } else if ( imagePath . startsWith ( './images/' ) || imagePath . startsWith ( '../' ) ) {
160
+ // Relative path - need to resolve based on referencing file location
161
+ const referencingDir = path . dirname ( referencingFile ) ;
162
+ const resolved = path . resolve ( referencingDir , imagePath ) ;
163
+ const relative = path . relative ( '.' , resolved ) ;
164
+ normalizedPath = relative . replace ( / \\ / g, '/' ) ; // Normalize path separators
165
+ } else if ( imagePath . startsWith ( '/' ) ) {
166
+ // Absolute path from web root - assume it's in static/
167
+ normalizedPath = `static${ imagePath } ` ;
168
+ } else {
169
+ // Relative path without explicit prefix
170
+ if ( imagePath . includes ( '/' ) ) {
171
+ // Has directory structure, likely relative to static/images
172
+ normalizedPath = `static/images/${ imagePath } ` ;
173
+ } else {
174
+ // Just a filename, could be in various locations
175
+ normalizedPath = imagePath ;
176
+ }
177
+ }
178
+
179
+ return normalizedPath ;
180
+ }
181
+
182
+ // Scan all markup files for image references
183
+ scanForImageReferences ( ) {
184
+ const markupFiles = this . getAllMarkupFiles ( ) ;
185
+
186
+ for ( const filePath of markupFiles ) {
187
+ try {
188
+ const content = fs . readFileSync ( filePath , 'utf8' ) ;
189
+ const references = this . extractImageReferences ( filePath , content ) ;
190
+
191
+ for ( const ref of references ) {
192
+ if ( ref ) {
193
+ this . imageReferences . add ( ref ) ;
194
+ this . log ( `${ filePath } references: ${ ref } ` , 'debug' ) ;
195
+ }
196
+ }
197
+ } catch ( error ) {
198
+ this . log ( `Could not read ${ filePath } : ${ error . message } ` , 'warn' ) ;
199
+ }
200
+ }
201
+
202
+ this . log ( `Found ${ this . imageReferences . size } unique image references` , 'debug' ) ;
203
+ }
204
+
205
+ // Check if an image file is referenced
206
+ isImageReferenced ( imagePath ) {
207
+ // Try exact match first
208
+ if ( this . imageReferences . has ( imagePath ) ) {
209
+ return true ;
210
+ }
211
+
212
+ // Try various normalizations
213
+ const relativePath = path . relative ( '.' , imagePath ) . replace ( / \\ / g, '/' ) ;
214
+ if ( this . imageReferences . has ( relativePath ) ) {
215
+ return true ;
216
+ }
217
+
218
+ // Check if any reference ends with this file's path
219
+ const fileName = path . basename ( imagePath ) ;
220
+ for ( const ref of this . imageReferences ) {
221
+ if ( ref . endsWith ( imagePath ) || ref . endsWith ( relativePath ) || ref . endsWith ( fileName ) ) {
222
+ return true ;
223
+ }
224
+ }
225
+
226
+ // Check path variations
227
+ const variations = [
228
+ imagePath . replace ( 'static/images/' , 'images/' ) ,
229
+ imagePath . replace ( 'static/' , '' ) ,
230
+ `/${ imagePath } ` ,
231
+ `/${ relativePath } `
232
+ ] ;
233
+
234
+ for ( const variation of variations ) {
235
+ if ( this . imageReferences . has ( variation ) ) {
236
+ return true ;
237
+ }
238
+ }
239
+
240
+ return false ;
241
+ }
242
+
243
+ // Main function to find unused images
244
+ findUnusedImages ( ) {
245
+ this . log ( '🔍 Finding unused images in Discord API docs...' ) ;
246
+
247
+ // Get all images and references
248
+ const allImages = this . getAllImages ( ) ;
249
+ this . scanForImageReferences ( ) ;
250
+
251
+ // Find unused images
252
+ const unusedImages = [ ] ;
253
+
254
+ for ( const imagePath of allImages ) {
255
+ if ( ! this . isImageReferenced ( imagePath ) ) {
256
+ unusedImages . push ( imagePath ) ;
257
+ this . stats . unusedImages ++ ;
258
+ } else {
259
+ this . stats . referencedImages ++ ;
260
+ }
261
+ }
262
+
263
+ // Output results
264
+ for ( const unusedImage of unusedImages ) {
265
+ console . log ( unusedImage ) ; // Output to stdout for piping
266
+ }
267
+
268
+ // Log summary to stderr
269
+ this . log ( `\n📊 Summary:` ) ;
270
+ this . log ( ` Total images: ${ this . stats . totalImages } ` ) ;
271
+ this . log ( ` Referenced images: ${ this . stats . referencedImages } ` ) ;
272
+ this . log ( ` Unused images: ${ this . stats . unusedImages } ` ) ;
273
+ this . log ( ` Markup files scanned: ${ this . stats . totalMarkupFiles } ` ) ;
274
+
275
+ if ( this . stats . unusedImages > 0 ) {
276
+ this . log ( `\n💡 To delete unused images: node find-unused-images.js | xargs rm` ) ;
277
+ this . log ( ` Or to see sizes: node find-unused-images.js | xargs ls -lh` ) ;
278
+ }
279
+
280
+ return unusedImages ;
281
+ }
282
+ }
283
+
284
+ // CLI handling
285
+ if ( import . meta. url === `file://${ process . argv [ 1 ] } ` ) {
286
+ const args = process . argv . slice ( 2 ) ;
287
+ const verbose = args . includes ( '--verbose' ) || args . includes ( '-v' ) ;
288
+
289
+ if ( args . includes ( '--help' ) || args . includes ( '-h' ) ) {
290
+ console . log ( `
291
+ Discord API Docs - Find Unused Images
292
+
293
+ Usage: node find-unused-images.js [options]
294
+
295
+ Options:
296
+ -v, --verbose Show detailed logging
297
+ -h, --help Show this help message
298
+
299
+ Output:
300
+ Prints unused image paths to stdout (one per line)
301
+ Logs summary and progress to stderr
302
+
303
+ Examples:
304
+ node find-unused-images.js # Find unused images
305
+ node find-unused-images.js | wc -l # Count unused images
306
+ node find-unused-images.js | xargs ls -lh # Show sizes of unused images
307
+ node find-unused-images.js | xargs rm # Delete unused images (careful!)
308
+ ` ) ;
309
+ process . exit ( 0 ) ;
310
+ }
311
+
312
+ const config = { ...CONFIG , verbose } ;
313
+ const finder = new UnusedImageFinder ( config ) ;
314
+
315
+ try {
316
+ finder . findUnusedImages ( ) ;
317
+ } catch ( error ) {
318
+ console . error ( '❌ Failed to find unused images:' , error . message ) ;
319
+ if ( verbose ) {
320
+ console . error ( error . stack ) ;
321
+ }
322
+ process . exit ( 1 ) ;
323
+ }
324
+ }
325
+
326
+ export { UnusedImageFinder } ;
0 commit comments