vite-plugin-asset-manager 0.0.1

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/dist/index.js ADDED
@@ -0,0 +1,1796 @@
1
+ // src/plugin.ts
2
+ import colors from "picocolors";
3
+
4
+ // src/server/index.ts
5
+ import sirv from "sirv";
6
+ import path3 from "path";
7
+ import fs2 from "fs";
8
+ import { fileURLToPath } from "url";
9
+
10
+ // src/server/api.ts
11
+ import { parse as parseUrl } from "url";
12
+ import path2 from "path";
13
+ import fs from "fs";
14
+ import archiver from "archiver";
15
+
16
+ // src/server/editor-launcher.ts
17
+ import launch from "launch-editor";
18
+ function launchEditor(absolutePath, line, column, editor) {
19
+ return new Promise((resolve, reject) => {
20
+ const fileSpec = `${absolutePath}:${line}:${column}`;
21
+ launch(fileSpec, editor, (_fileName, errorMsg) => {
22
+ if (errorMsg) {
23
+ reject(new Error(errorMsg));
24
+ } else {
25
+ resolve();
26
+ }
27
+ });
28
+ });
29
+ }
30
+
31
+ // src/server/file-revealer.ts
32
+ import { spawn } from "child_process";
33
+ import path from "path";
34
+ async function revealInFileExplorer(absolutePath) {
35
+ return new Promise((resolve, reject) => {
36
+ const platform = process.platform;
37
+ let command;
38
+ let args;
39
+ switch (platform) {
40
+ case "darwin":
41
+ command = "open";
42
+ args = ["-R", absolutePath];
43
+ break;
44
+ case "win32":
45
+ command = "explorer";
46
+ args = ["/select,", absolutePath];
47
+ break;
48
+ case "linux": {
49
+ const directory = path.dirname(absolutePath);
50
+ command = "xdg-open";
51
+ args = [directory];
52
+ break;
53
+ }
54
+ default:
55
+ return reject(new Error(`Unsupported platform: ${platform}`));
56
+ }
57
+ const child = spawn(command, args);
58
+ child.on("error", (error) => {
59
+ reject(new Error(`Failed to reveal file: ${error.message}`));
60
+ });
61
+ child.on("close", (code) => {
62
+ if (code === 0) {
63
+ resolve();
64
+ } else {
65
+ reject(new Error(`Reveal command exited with code ${code}`));
66
+ }
67
+ });
68
+ });
69
+ }
70
+
71
+ // src/server/api.ts
72
+ var sseClients = /* @__PURE__ */ new Set();
73
+ var MIME_TYPES = {
74
+ /**
75
+ * Images:
76
+ */
77
+ ".png": "image/png",
78
+ ".jpg": "image/jpeg",
79
+ ".jpeg": "image/jpeg",
80
+ ".gif": "image/gif",
81
+ ".svg": "image/svg+xml",
82
+ ".webp": "image/webp",
83
+ ".avif": "image/avif",
84
+ ".ico": "image/x-icon",
85
+ ".bmp": "image/bmp",
86
+ ".tiff": "image/tiff",
87
+ ".tif": "image/tiff",
88
+ ".heic": "image/heic",
89
+ ".heif": "image/heif",
90
+ /**
91
+ * Videos:
92
+ */
93
+ ".mp4": "video/mp4",
94
+ ".webm": "video/webm",
95
+ ".ogg": "video/ogg",
96
+ ".mov": "video/quicktime",
97
+ /**
98
+ * Audio:
99
+ */
100
+ ".mp3": "audio/mpeg",
101
+ ".wav": "audio/wav",
102
+ /**
103
+ * Documents:
104
+ */
105
+ ".pdf": "application/pdf",
106
+ ".json": "application/json",
107
+ ".md": "text/markdown",
108
+ ".txt": "text/plain",
109
+ ".csv": "text/csv",
110
+ /**
111
+ * Config files:
112
+ */
113
+ ".yml": "text/yaml",
114
+ ".yaml": "text/yaml",
115
+ ".toml": "application/toml",
116
+ ".xml": "application/xml",
117
+ /**
118
+ * Fonts:
119
+ */
120
+ ".woff": "font/woff",
121
+ ".woff2": "font/woff2",
122
+ ".ttf": "font/ttf",
123
+ ".otf": "font/otf",
124
+ ".eot": "application/vnd.ms-fontobject"
125
+ };
126
+ function createApiRouter(scanner, importerScanner, duplicateScanner, thumbnailService, root, basePath, editor) {
127
+ return async (req, res, next) => {
128
+ const { pathname, query } = parseUrl(req.url || "", true);
129
+ const apiPath = pathname?.replace(`${basePath}/api`, "") || "";
130
+ try {
131
+ switch (apiPath) {
132
+ case "/assets":
133
+ return handleGetAssets(res, scanner, query);
134
+ case "/assets/grouped":
135
+ return handleGetGroupedAssets(res, scanner, query);
136
+ case "/search":
137
+ return handleSearch(res, scanner, query);
138
+ case "/thumbnail":
139
+ return handleThumbnail(res, thumbnailService, root, query);
140
+ case "/file":
141
+ return handleServeFile(res, root, query);
142
+ case "/stats":
143
+ return handleGetStats(res, scanner, duplicateScanner);
144
+ case "/duplicates":
145
+ return handleGetDuplicates(res, scanner, duplicateScanner, query);
146
+ case "/importers":
147
+ return handleGetImporters(res, importerScanner, query);
148
+ case "/open-in-editor":
149
+ return handleOpenInEditor(req, res, root, editor, query);
150
+ case "/reveal-in-finder":
151
+ return handleRevealInFinder(req, res, root, query);
152
+ case "/bulk-download":
153
+ return handleBulkDownload(req, res, root);
154
+ case "/bulk-delete":
155
+ return handleBulkDelete(req, res, root);
156
+ case "/events":
157
+ return handleSSE(res);
158
+ default:
159
+ next();
160
+ }
161
+ } catch (error) {
162
+ console.error("[asset-manager] API error:", error);
163
+ res.statusCode = 500;
164
+ res.setHeader("Content-Type", "application/json");
165
+ res.end(JSON.stringify({ error: "Internal server error" }));
166
+ }
167
+ };
168
+ }
169
+ async function handleGetAssets(res, scanner, query) {
170
+ const assets = scanner.getAssets();
171
+ let filtered = assets;
172
+ const directory = query.directory;
173
+ if (directory) {
174
+ filtered = filtered.filter(
175
+ (a) => a.directory === directory || a.directory.startsWith(directory + "/")
176
+ );
177
+ }
178
+ const type = query.type;
179
+ if (type) {
180
+ filtered = filtered.filter((a) => a.type === type);
181
+ }
182
+ sendJson(res, { assets: filtered, total: filtered.length });
183
+ }
184
+ async function handleGetGroupedAssets(res, scanner, query) {
185
+ let groups = scanner.getGroupedAssets();
186
+ const type = query.type;
187
+ if (type) {
188
+ groups = groups.map((group) => ({
189
+ ...group,
190
+ assets: group.assets.filter((a) => a.type === type),
191
+ count: group.assets.filter((a) => a.type === type).length
192
+ })).filter((group) => group.count > 0);
193
+ }
194
+ const unused = query.unused === "true";
195
+ if (unused) {
196
+ groups = groups.map((group) => ({
197
+ ...group,
198
+ assets: group.assets.filter((a) => a.importersCount === 0),
199
+ count: group.assets.filter((a) => a.importersCount === 0).length
200
+ })).filter((group) => group.count > 0);
201
+ }
202
+ const duplicates = query.duplicates === "true";
203
+ if (duplicates) {
204
+ groups = groups.map((group) => ({
205
+ ...group,
206
+ assets: group.assets.filter((a) => (a.duplicatesCount ?? 0) > 0),
207
+ count: group.assets.filter((a) => (a.duplicatesCount ?? 0) > 0).length
208
+ })).filter((group) => group.count > 0);
209
+ }
210
+ const total = groups.reduce((sum, g) => sum + g.count, 0);
211
+ sendJson(res, { groups, total });
212
+ }
213
+ async function handleSearch(res, scanner, query) {
214
+ const q = query.q || "";
215
+ const results = scanner.search(q);
216
+ sendJson(res, { assets: results, total: results.length, query: q });
217
+ }
218
+ async function handleThumbnail(res, thumbnailService, root, query) {
219
+ const relativePath = query.path;
220
+ if (!relativePath) {
221
+ res.statusCode = 400;
222
+ res.end("Missing path parameter");
223
+ return;
224
+ }
225
+ const absolutePath = path2.resolve(root, relativePath);
226
+ if (!absolutePath.startsWith(root)) {
227
+ res.statusCode = 403;
228
+ res.end("Forbidden");
229
+ return;
230
+ }
231
+ if (relativePath.endsWith(".svg")) {
232
+ res.setHeader("Content-Type", "image/svg+xml");
233
+ res.setHeader("Cache-Control", "public, max-age=31536000");
234
+ fs.createReadStream(absolutePath).pipe(res);
235
+ return;
236
+ }
237
+ const thumbnail = await thumbnailService.getThumbnail(absolutePath);
238
+ if (thumbnail) {
239
+ res.setHeader("Content-Type", "image/jpeg");
240
+ res.setHeader("Cache-Control", "public, max-age=31536000");
241
+ res.end(thumbnail);
242
+ } else {
243
+ const ext = path2.extname(relativePath).toLowerCase();
244
+ res.setHeader("Content-Type", MIME_TYPES[ext] || "application/octet-stream");
245
+ fs.createReadStream(absolutePath).pipe(res);
246
+ }
247
+ }
248
+ async function handleServeFile(res, root, query) {
249
+ const relativePath = query.path;
250
+ if (!relativePath) {
251
+ res.statusCode = 400;
252
+ res.end("Missing path parameter");
253
+ return;
254
+ }
255
+ const absolutePath = path2.resolve(root, relativePath);
256
+ if (!absolutePath.startsWith(root)) {
257
+ res.statusCode = 403;
258
+ res.end("Forbidden");
259
+ return;
260
+ }
261
+ try {
262
+ await fs.promises.access(absolutePath, fs.constants.R_OK);
263
+ } catch {
264
+ res.statusCode = 404;
265
+ res.end("File not found");
266
+ return;
267
+ }
268
+ const ext = path2.extname(relativePath).toLowerCase();
269
+ res.setHeader("Content-Type", MIME_TYPES[ext] || "application/octet-stream");
270
+ res.setHeader("Cache-Control", "public, max-age=3600");
271
+ fs.createReadStream(absolutePath).pipe(res);
272
+ }
273
+ async function handleGetStats(res, scanner, duplicateScanner) {
274
+ const assets = scanner.getAssets();
275
+ const dupStats = duplicateScanner.getStats();
276
+ const stats = {
277
+ total: assets.length,
278
+ byType: {
279
+ image: assets.filter((a) => a.type === "image").length,
280
+ video: assets.filter((a) => a.type === "video").length,
281
+ audio: assets.filter((a) => a.type === "audio").length,
282
+ document: assets.filter((a) => a.type === "document").length,
283
+ font: assets.filter((a) => a.type === "font").length,
284
+ data: assets.filter((a) => a.type === "data").length,
285
+ text: assets.filter((a) => a.type === "text").length,
286
+ other: assets.filter((a) => a.type === "other").length
287
+ },
288
+ totalSize: assets.reduce((sum, a) => sum + a.size, 0),
289
+ directories: [...new Set(assets.map((a) => a.directory))].length,
290
+ unused: assets.filter((a) => a.importersCount === 0).length,
291
+ duplicateGroups: dupStats.duplicateGroups,
292
+ duplicateFiles: dupStats.duplicateFiles
293
+ };
294
+ sendJson(res, stats);
295
+ }
296
+ async function handleGetDuplicates(res, scanner, duplicateScanner, query) {
297
+ const hash = query.hash;
298
+ if (hash) {
299
+ const paths = duplicateScanner.getDuplicatesByHash(hash);
300
+ const assets = scanner.getAssets().filter((a) => paths.includes(a.path));
301
+ sendJson(res, { duplicates: assets, total: assets.length, hash });
302
+ } else {
303
+ const assets = scanner.getAssets().filter((a) => (a.duplicatesCount ?? 0) > 0);
304
+ sendJson(res, { duplicates: assets, total: assets.length });
305
+ }
306
+ }
307
+ function sendJson(res, data) {
308
+ res.setHeader("Content-Type", "application/json");
309
+ res.end(JSON.stringify(data));
310
+ }
311
+ function handleSSE(res) {
312
+ res.writeHead(200, {
313
+ "Content-Type": "text/event-stream",
314
+ "Cache-Control": "no-cache",
315
+ Connection: "keep-alive",
316
+ "Access-Control-Allow-Origin": "*"
317
+ });
318
+ res.write(`data: ${JSON.stringify({ type: "connected" })}
319
+
320
+ `);
321
+ sseClients.add(res);
322
+ res.on("close", () => {
323
+ sseClients.delete(res);
324
+ });
325
+ }
326
+ function broadcastSSE(event, data) {
327
+ const message = JSON.stringify({ event, data });
328
+ for (const client of sseClients) {
329
+ client.write(`data: ${message}
330
+
331
+ `);
332
+ }
333
+ }
334
+ async function handleGetImporters(res, importerScanner, query) {
335
+ const assetPath = query.path;
336
+ if (!assetPath) {
337
+ res.statusCode = 400;
338
+ sendJson(res, { error: "Missing path parameter" });
339
+ return;
340
+ }
341
+ const importers = importerScanner.getImporters(assetPath);
342
+ sendJson(res, { importers, total: importers.length });
343
+ }
344
+ async function handleOpenInEditor(req, res, root, editor, query) {
345
+ if (req.method !== "POST") {
346
+ res.statusCode = 405;
347
+ sendJson(res, { error: "Method not allowed" });
348
+ return;
349
+ }
350
+ const filePath = query.file;
351
+ const line = parseInt(query.line) || 1;
352
+ const column = parseInt(query.column) || 1;
353
+ if (!filePath) {
354
+ res.statusCode = 400;
355
+ sendJson(res, { error: "Missing file parameter" });
356
+ return;
357
+ }
358
+ const absolutePath = path2.resolve(root, filePath);
359
+ if (!absolutePath.startsWith(root)) {
360
+ res.statusCode = 403;
361
+ sendJson(res, { error: "Forbidden" });
362
+ return;
363
+ }
364
+ try {
365
+ await fs.promises.access(absolutePath, fs.constants.R_OK);
366
+ } catch {
367
+ res.statusCode = 404;
368
+ sendJson(res, { error: "File not found" });
369
+ return;
370
+ }
371
+ try {
372
+ await launchEditor(absolutePath, line, column, editor);
373
+ sendJson(res, { success: true });
374
+ } catch (error) {
375
+ res.statusCode = 500;
376
+ sendJson(res, { error: error instanceof Error ? error.message : "Failed to open editor" });
377
+ }
378
+ }
379
+ async function handleRevealInFinder(req, res, root, query) {
380
+ if (req.method !== "POST") {
381
+ res.statusCode = 405;
382
+ sendJson(res, { error: "Method not allowed" });
383
+ return;
384
+ }
385
+ const filePath = query.path;
386
+ if (!filePath) {
387
+ res.statusCode = 400;
388
+ sendJson(res, { error: "Missing path parameter" });
389
+ return;
390
+ }
391
+ const absolutePath = path2.resolve(root, filePath);
392
+ if (!absolutePath.startsWith(root)) {
393
+ res.statusCode = 403;
394
+ sendJson(res, { error: "Invalid path" });
395
+ return;
396
+ }
397
+ try {
398
+ await fs.promises.access(absolutePath, fs.constants.R_OK);
399
+ } catch {
400
+ res.statusCode = 404;
401
+ sendJson(res, { error: "File not found" });
402
+ return;
403
+ }
404
+ try {
405
+ await revealInFileExplorer(absolutePath);
406
+ sendJson(res, { success: true });
407
+ } catch (error) {
408
+ res.statusCode = 500;
409
+ sendJson(res, {
410
+ error: error instanceof Error ? error.message : "Failed to reveal file"
411
+ });
412
+ }
413
+ }
414
+ async function parseJsonBody(req) {
415
+ return new Promise((resolve, reject) => {
416
+ let body = "";
417
+ req.on("data", (chunk) => {
418
+ body += chunk.toString();
419
+ });
420
+ req.on("end", () => {
421
+ try {
422
+ resolve(JSON.parse(body));
423
+ } catch {
424
+ reject(new Error("Invalid JSON"));
425
+ }
426
+ });
427
+ req.on("error", reject);
428
+ });
429
+ }
430
+ async function handleBulkDownload(req, res, root) {
431
+ if (req.method !== "POST") {
432
+ res.statusCode = 405;
433
+ sendJson(res, { error: "Method not allowed" });
434
+ return;
435
+ }
436
+ let body;
437
+ try {
438
+ body = await parseJsonBody(req);
439
+ } catch {
440
+ res.statusCode = 400;
441
+ sendJson(res, { error: "Invalid JSON body" });
442
+ return;
443
+ }
444
+ const paths = body.paths;
445
+ if (!Array.isArray(paths) || paths.length === 0) {
446
+ res.statusCode = 400;
447
+ sendJson(res, { error: "Missing or invalid paths array" });
448
+ return;
449
+ }
450
+ const validatedPaths = [];
451
+ for (const relativePath of paths) {
452
+ const absolutePath = path2.resolve(root, relativePath);
453
+ if (!absolutePath.startsWith(root)) {
454
+ res.statusCode = 403;
455
+ sendJson(res, { error: `Forbidden path: ${relativePath}` });
456
+ return;
457
+ }
458
+ try {
459
+ await fs.promises.access(absolutePath, fs.constants.R_OK);
460
+ validatedPaths.push({ relativePath, absolutePath });
461
+ } catch {
462
+ console.warn(`[asset-manager] Bulk download skipping missing file: ${relativePath}`);
463
+ }
464
+ }
465
+ if (validatedPaths.length === 0) {
466
+ res.statusCode = 404;
467
+ sendJson(res, { error: "No valid files found" });
468
+ return;
469
+ }
470
+ res.setHeader("Content-Type", "application/zip");
471
+ res.setHeader("Content-Disposition", `attachment; filename="assets-${Date.now()}.zip"`);
472
+ const archive = archiver("zip", { zlib: { level: 6 } });
473
+ archive.on("error", (err) => {
474
+ console.error("[asset-manager] ZIP creation error:", err);
475
+ if (!res.headersSent) {
476
+ res.statusCode = 500;
477
+ res.end("ZIP creation failed");
478
+ }
479
+ });
480
+ archive.pipe(res);
481
+ for (const { relativePath, absolutePath } of validatedPaths) {
482
+ archive.file(absolutePath, { name: relativePath });
483
+ }
484
+ await archive.finalize();
485
+ }
486
+ async function handleBulkDelete(req, res, root) {
487
+ if (req.method !== "POST") {
488
+ res.statusCode = 405;
489
+ sendJson(res, { error: "Method not allowed" });
490
+ return;
491
+ }
492
+ let body;
493
+ try {
494
+ body = await parseJsonBody(req);
495
+ } catch {
496
+ res.statusCode = 400;
497
+ sendJson(res, { error: "Invalid JSON body" });
498
+ return;
499
+ }
500
+ const paths = body.paths;
501
+ if (!Array.isArray(paths) || paths.length === 0) {
502
+ res.statusCode = 400;
503
+ sendJson(res, { error: "Missing or invalid paths array" });
504
+ return;
505
+ }
506
+ const results = {
507
+ deleted: 0,
508
+ failed: [],
509
+ errors: []
510
+ };
511
+ for (const relativePath of paths) {
512
+ const absolutePath = path2.resolve(root, relativePath);
513
+ if (!absolutePath.startsWith(root)) {
514
+ results.failed.push(relativePath);
515
+ results.errors.push(`Forbidden path: ${relativePath}`);
516
+ continue;
517
+ }
518
+ try {
519
+ await fs.promises.unlink(absolutePath);
520
+ results.deleted++;
521
+ } catch (error) {
522
+ results.failed.push(relativePath);
523
+ results.errors.push(error instanceof Error ? error.message : "Unknown error");
524
+ }
525
+ }
526
+ sendJson(res, {
527
+ deleted: results.deleted,
528
+ failed: results.failed.length,
529
+ errors: results.errors
530
+ });
531
+ }
532
+
533
+ // src/server/index.ts
534
+ var __dirname2 = path3.dirname(fileURLToPath(import.meta.url));
535
+ function findClientDir() {
536
+ const fromDist = path3.join(__dirname2, "client");
537
+ if (fs2.existsSync(fromDist)) {
538
+ return fromDist;
539
+ }
540
+ const fromSource = path3.resolve(__dirname2, "../../dist/client");
541
+ if (fs2.existsSync(fromSource)) {
542
+ return fromSource;
543
+ }
544
+ return fromDist;
545
+ }
546
+ function setupMiddleware(server, context) {
547
+ const { base, scanner, importerScanner, duplicateScanner, thumbnailService, root, launchEditor: launchEditor2 } = context;
548
+ const apiRouter = createApiRouter(
549
+ scanner,
550
+ importerScanner,
551
+ duplicateScanner,
552
+ thumbnailService,
553
+ root,
554
+ base,
555
+ launchEditor2
556
+ );
557
+ const clientDir = findClientDir();
558
+ server.middlewares.use((req, res, next) => {
559
+ const url = req.url || "";
560
+ if (url.startsWith(`${base}/api/`)) {
561
+ return apiRouter(req, res, next);
562
+ }
563
+ if (url === base || url.startsWith(`${base}/`)) {
564
+ const serve = sirv(clientDir, {
565
+ single: true,
566
+ dev: true
567
+ });
568
+ req.url = url.slice(base.length) || "/";
569
+ return serve(req, res, next);
570
+ }
571
+ next();
572
+ });
573
+ }
574
+
575
+ // src/server/scanner.ts
576
+ import { EventEmitter } from "events";
577
+ import fg from "fast-glob";
578
+ import path4 from "path";
579
+ import fs3 from "fs/promises";
580
+ import chokidar from "chokidar";
581
+ var AssetScanner = class extends EventEmitter {
582
+ root;
583
+ options;
584
+ cache = /* @__PURE__ */ new Map();
585
+ watcher;
586
+ scanPromise;
587
+ constructor(root, options) {
588
+ super();
589
+ this.root = root;
590
+ this.options = options;
591
+ }
592
+ async init() {
593
+ await this.scan();
594
+ if (this.options.watch) {
595
+ this.initWatcher();
596
+ }
597
+ }
598
+ async scan() {
599
+ if (this.scanPromise) {
600
+ await this.scanPromise;
601
+ return this.getAssets();
602
+ }
603
+ this.scanPromise = this.performScan();
604
+ await this.scanPromise;
605
+ this.scanPromise = void 0;
606
+ return this.getAssets();
607
+ }
608
+ async performScan() {
609
+ const extensionPattern = this.options.extensions.map((ext) => ext.replace(".", "")).join(",");
610
+ const patterns = this.options.include.map((dir) => `${dir}/**/*.{${extensionPattern}}`);
611
+ const entries = await fg(patterns, {
612
+ cwd: this.root,
613
+ ignore: this.options.exclude.map((p) => `**/${p}/**`),
614
+ absolute: false,
615
+ stats: true,
616
+ onlyFiles: true,
617
+ dot: false
618
+ });
619
+ this.cache.clear();
620
+ for (const entry of entries) {
621
+ const asset = this.createAsset(entry);
622
+ this.cache.set(asset.path, asset);
623
+ }
624
+ }
625
+ createAsset(entry) {
626
+ const relativePath = entry.path;
627
+ const absolutePath = path4.join(this.root, relativePath);
628
+ const extension = path4.extname(relativePath).toLowerCase();
629
+ const name = path4.basename(relativePath);
630
+ const directory = path4.dirname(relativePath);
631
+ return {
632
+ id: Buffer.from(relativePath).toString("base64url"),
633
+ name,
634
+ path: relativePath,
635
+ absolutePath,
636
+ extension,
637
+ type: this.getAssetType(extension),
638
+ size: entry.stats?.size || 0,
639
+ mtime: entry.stats?.mtimeMs || Date.now(),
640
+ directory: directory === "." ? "/" : directory
641
+ };
642
+ }
643
+ getAssetType(extension) {
644
+ const imageExts = [
645
+ ".png",
646
+ ".jpg",
647
+ ".jpeg",
648
+ ".gif",
649
+ ".svg",
650
+ ".webp",
651
+ ".avif",
652
+ ".ico",
653
+ ".bmp",
654
+ ".tiff",
655
+ ".tif",
656
+ ".heic",
657
+ ".heif"
658
+ ];
659
+ const videoExts = [".mp4", ".webm", ".ogg", ".mov", ".avi"];
660
+ const audioExts = [".mp3", ".wav", ".flac", ".aac"];
661
+ const docExts = [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
662
+ const fontExts = [".woff", ".woff2", ".ttf", ".otf", ".eot"];
663
+ const dataExts = [".json", ".csv", ".xml", ".yml", ".yaml", ".toml"];
664
+ const textExts = [".md", ".txt"];
665
+ if (imageExts.includes(extension)) return "image";
666
+ if (videoExts.includes(extension)) return "video";
667
+ if (audioExts.includes(extension)) return "audio";
668
+ if (docExts.includes(extension)) return "document";
669
+ if (fontExts.includes(extension)) return "font";
670
+ if (dataExts.includes(extension)) return "data";
671
+ if (textExts.includes(extension)) return "text";
672
+ return "other";
673
+ }
674
+ getAssets() {
675
+ return Array.from(this.cache.values());
676
+ }
677
+ getGroupedAssets() {
678
+ const groups = /* @__PURE__ */ new Map();
679
+ for (const asset of this.cache.values()) {
680
+ const dir = asset.directory;
681
+ if (!groups.has(dir)) {
682
+ groups.set(dir, []);
683
+ }
684
+ groups.get(dir).push(asset);
685
+ }
686
+ return Array.from(groups.entries()).map(([directory, assets]) => ({
687
+ directory,
688
+ assets: assets.sort((a, b) => a.name.localeCompare(b.name)),
689
+ count: assets.length
690
+ })).sort((a, b) => a.directory.localeCompare(b.directory));
691
+ }
692
+ search(query) {
693
+ const normalizedQuery = query.toLowerCase().trim();
694
+ if (!normalizedQuery) return this.getAssets();
695
+ return this.getAssets().filter(
696
+ (asset) => asset.name.toLowerCase().includes(normalizedQuery) || asset.path.toLowerCase().includes(normalizedQuery)
697
+ );
698
+ }
699
+ getAsset(relativePath) {
700
+ return this.cache.get(relativePath);
701
+ }
702
+ /**
703
+ * Enrich assets with importer count metadata.
704
+ * Should be called after scanning completes and when importers change.
705
+ */
706
+ enrichWithImporterCounts(importerScanner) {
707
+ for (const asset of this.cache.values()) {
708
+ const importers = importerScanner.getImporters(asset.path);
709
+ asset.importersCount = importers.length;
710
+ }
711
+ }
712
+ /**
713
+ * Enrich assets with duplicate detection metadata.
714
+ * Should be called after scanning completes and when file content changes.
715
+ */
716
+ enrichWithDuplicateInfo(duplicateScanner) {
717
+ for (const asset of this.cache.values()) {
718
+ const info = duplicateScanner.getDuplicateInfo(asset.path);
719
+ asset.contentHash = info.hash;
720
+ asset.duplicatesCount = info.duplicatesCount;
721
+ }
722
+ }
723
+ initWatcher() {
724
+ const watchPaths = this.options.include.map((dir) => path4.join(this.root, dir));
725
+ this.watcher = chokidar.watch(watchPaths, {
726
+ ignored: this.options.exclude.map((p) => `**/${p}/**`),
727
+ persistent: true,
728
+ ignoreInitial: true,
729
+ awaitWriteFinish: {
730
+ stabilityThreshold: 100,
731
+ pollInterval: 50
732
+ }
733
+ });
734
+ this.watcher.on("add", (filePath) => this.handleFileChange("add", filePath));
735
+ this.watcher.on("unlink", (filePath) => this.handleFileChange("unlink", filePath));
736
+ this.watcher.on("change", (filePath) => this.handleFileChange("change", filePath));
737
+ }
738
+ async handleFileChange(event, absolutePath) {
739
+ const relativePath = path4.relative(this.root, absolutePath);
740
+ const extension = path4.extname(relativePath).toLowerCase();
741
+ if (!this.options.extensions.includes(extension)) {
742
+ return;
743
+ }
744
+ if (event === "unlink") {
745
+ this.cache.delete(relativePath);
746
+ } else {
747
+ try {
748
+ const stats = await fs3.stat(absolutePath);
749
+ const asset = {
750
+ id: Buffer.from(relativePath).toString("base64url"),
751
+ name: path4.basename(relativePath),
752
+ path: relativePath,
753
+ absolutePath,
754
+ extension,
755
+ type: this.getAssetType(extension),
756
+ size: stats.size,
757
+ mtime: stats.mtimeMs,
758
+ directory: path4.dirname(relativePath)
759
+ };
760
+ this.cache.set(relativePath, asset);
761
+ } catch {
762
+ return;
763
+ }
764
+ }
765
+ this.emit("change", { event, path: relativePath });
766
+ }
767
+ destroy() {
768
+ this.watcher?.close();
769
+ }
770
+ };
771
+
772
+ // src/server/importer-scanner.ts
773
+ import { EventEmitter as EventEmitter2 } from "events";
774
+ import fg2 from "fast-glob";
775
+ import path5 from "path";
776
+ import fs4 from "fs/promises";
777
+ import chokidar2 from "chokidar";
778
+ var SOURCE_EXTENSIONS = ["js", "jsx", "ts", "tsx", "vue", "svelte", "css", "scss", "less", "html"];
779
+ var ASSET_EXTENSIONS = [
780
+ "png",
781
+ "jpg",
782
+ "jpeg",
783
+ "gif",
784
+ "svg",
785
+ "webp",
786
+ "avif",
787
+ "ico",
788
+ "bmp",
789
+ "tiff",
790
+ "tif",
791
+ "heic",
792
+ "heif",
793
+ "mp4",
794
+ "webm",
795
+ "ogg",
796
+ "mov",
797
+ "avi",
798
+ "mp3",
799
+ "wav",
800
+ "flac",
801
+ "aac",
802
+ "woff",
803
+ "woff2",
804
+ "ttf",
805
+ "otf",
806
+ "eot",
807
+ "pdf",
808
+ "json",
809
+ "md",
810
+ "txt",
811
+ "csv",
812
+ "xml",
813
+ "yml",
814
+ "yaml",
815
+ "toml"
816
+ ];
817
+ var ASSET_EXT_PATTERN = ASSET_EXTENSIONS.join("|");
818
+ var IMPORT_PATTERNS = [
819
+ {
820
+ type: "es-import",
821
+ pattern: new RegExp(
822
+ `import\\s+(?:[\\w\\s{},*]+\\s+from\\s+)?['"]([^'"]*\\.(?:${ASSET_EXT_PATTERN}))['"]`,
823
+ "gi"
824
+ )
825
+ },
826
+ {
827
+ type: "dynamic-import",
828
+ pattern: new RegExp(`import\\s*\\(\\s*['"]([^'"]*\\.(?:${ASSET_EXT_PATTERN}))['"]\\s*\\)`, "gi")
829
+ },
830
+ {
831
+ type: "require",
832
+ pattern: new RegExp(
833
+ `require\\s*\\(\\s*['"]([^'"]*\\.(?:${ASSET_EXT_PATTERN}))['"]\\s*\\)`,
834
+ "gi"
835
+ )
836
+ },
837
+ {
838
+ type: "css-url",
839
+ pattern: new RegExp(
840
+ `url\\s*\\(\\s*['"]?([^'")\\s]+\\.(?:${ASSET_EXT_PATTERN}))['"]?\\s*\\)`,
841
+ "gi"
842
+ )
843
+ },
844
+ {
845
+ type: "html-src",
846
+ pattern: new RegExp(`\\bsrc\\s*=\\s*['"]([^'"]*\\.(?:${ASSET_EXT_PATTERN}))['"]`, "gi")
847
+ },
848
+ {
849
+ type: "html-href",
850
+ pattern: new RegExp(`\\bhref\\s*=\\s*['"]([^'"]*\\.(?:${ASSET_EXT_PATTERN}))['"]`, "gi")
851
+ }
852
+ ];
853
+ var ImporterScanner = class extends EventEmitter2 {
854
+ root;
855
+ options;
856
+ /** Maps asset path -> array of importers */
857
+ cache = /* @__PURE__ */ new Map();
858
+ /** Reverse index: source file -> set of asset paths it imports */
859
+ reverseIndex = /* @__PURE__ */ new Map();
860
+ watcher;
861
+ scanPromise;
862
+ initialized = false;
863
+ constructor(root, options) {
864
+ super();
865
+ this.root = root;
866
+ this.options = options;
867
+ }
868
+ async init() {
869
+ if (this.initialized) return;
870
+ await this.scan();
871
+ if (this.options.watch) {
872
+ this.initWatcher();
873
+ }
874
+ this.initialized = true;
875
+ }
876
+ async scan() {
877
+ if (this.scanPromise) {
878
+ await this.scanPromise;
879
+ return;
880
+ }
881
+ this.scanPromise = this.performScan();
882
+ await this.scanPromise;
883
+ this.scanPromise = void 0;
884
+ }
885
+ async performScan() {
886
+ const patterns = this.options.include.map((dir) => `${dir}/**/*.{${SOURCE_EXTENSIONS.join(",")}}`);
887
+ const entries = await fg2(patterns, {
888
+ cwd: this.root,
889
+ ignore: this.options.exclude.map((p) => `**/${p}/**`),
890
+ absolute: false,
891
+ onlyFiles: true,
892
+ dot: false
893
+ });
894
+ this.cache.clear();
895
+ this.reverseIndex.clear();
896
+ const BATCH_SIZE = 50;
897
+ for (let i = 0; i < entries.length; i += BATCH_SIZE) {
898
+ const batch = entries.slice(i, i + BATCH_SIZE);
899
+ await Promise.all(batch.map((filePath) => this.scanFile(filePath)));
900
+ }
901
+ }
902
+ async scanFile(relativePath) {
903
+ const absolutePath = path5.join(this.root, relativePath);
904
+ try {
905
+ const content = await fs4.readFile(absolutePath, "utf-8");
906
+ const importers = this.findImportsInFile(content, relativePath, absolutePath);
907
+ const previousAssets = this.reverseIndex.get(relativePath);
908
+ if (previousAssets) {
909
+ for (const assetPath of previousAssets) {
910
+ const assetImporters = this.cache.get(assetPath);
911
+ if (assetImporters) {
912
+ const filtered = assetImporters.filter((i) => i.filePath !== relativePath);
913
+ if (filtered.length > 0) {
914
+ this.cache.set(assetPath, filtered);
915
+ } else {
916
+ this.cache.delete(assetPath);
917
+ }
918
+ }
919
+ }
920
+ }
921
+ const newAssets = /* @__PURE__ */ new Set();
922
+ for (const importer of importers) {
923
+ const assetPath = this.resolveAssetPath(importer.filePath, importer.snippet);
924
+ if (assetPath) {
925
+ newAssets.add(assetPath);
926
+ const existing = this.cache.get(assetPath) || [];
927
+ existing.push({ ...importer, filePath: relativePath, absolutePath });
928
+ this.cache.set(assetPath, existing);
929
+ }
930
+ }
931
+ this.reverseIndex.set(relativePath, newAssets);
932
+ } catch {
933
+ }
934
+ }
935
+ findImportsInFile(content, relativePath, absolutePath) {
936
+ const importers = [];
937
+ const lines = content.split("\n");
938
+ const fileDir = path5.dirname(relativePath);
939
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
940
+ const line = lines[lineIndex];
941
+ for (const { type, pattern } of IMPORT_PATTERNS) {
942
+ pattern.lastIndex = 0;
943
+ let match;
944
+ while ((match = pattern.exec(line)) !== null) {
945
+ const importPath = match[1];
946
+ const resolvedAssetPath = this.resolveImportPath(importPath, fileDir);
947
+ if (resolvedAssetPath) {
948
+ importers.push({
949
+ filePath: relativePath,
950
+ absolutePath,
951
+ line: lineIndex + 1,
952
+ column: match.index + 1,
953
+ importType: type,
954
+ snippet: line.trim().slice(0, 100)
955
+ });
956
+ }
957
+ }
958
+ }
959
+ }
960
+ return importers;
961
+ }
962
+ /**
963
+ * Resolves an import path relative to the file directory.
964
+ * Returns the normalized asset path relative to project root, or null if invalid.
965
+ */
966
+ resolveImportPath(importPath, fileDir) {
967
+ if (importPath.startsWith("http://") || importPath.startsWith("https://") || importPath.startsWith("//")) {
968
+ return null;
969
+ }
970
+ if (!importPath.startsWith(".") && !importPath.startsWith("/") && !importPath.startsWith("@/")) {
971
+ return null;
972
+ }
973
+ let resolvedPath;
974
+ if (importPath.startsWith("/")) {
975
+ resolvedPath = importPath.slice(1);
976
+ if (!resolvedPath.startsWith("public/")) {
977
+ resolvedPath = "public" + importPath;
978
+ }
979
+ } else if (importPath.startsWith("@/")) {
980
+ resolvedPath = "src/" + importPath.slice(2);
981
+ } else {
982
+ resolvedPath = path5.normalize(path5.join(fileDir, importPath));
983
+ }
984
+ resolvedPath = resolvedPath.split(path5.sep).join("/");
985
+ return resolvedPath;
986
+ }
987
+ /**
988
+ * Extract asset path from importer snippet (for reverse lookup)
989
+ */
990
+ resolveAssetPath(sourceFile, snippet) {
991
+ const fileDir = path5.dirname(sourceFile);
992
+ for (const { pattern } of IMPORT_PATTERNS) {
993
+ pattern.lastIndex = 0;
994
+ const match = pattern.exec(snippet);
995
+ if (match) {
996
+ return this.resolveImportPath(match[1], fileDir);
997
+ }
998
+ }
999
+ return null;
1000
+ }
1001
+ /**
1002
+ * Get all importers for a specific asset.
1003
+ * @param assetPath - Relative path to the asset from project root
1004
+ */
1005
+ getImporters(assetPath) {
1006
+ const normalizedPath = assetPath.split(path5.sep).join("/");
1007
+ let importers = this.cache.get(normalizedPath);
1008
+ if (!importers) {
1009
+ if (normalizedPath.startsWith("public/")) {
1010
+ importers = this.cache.get(normalizedPath.slice(7));
1011
+ } else {
1012
+ importers = this.cache.get("public/" + normalizedPath);
1013
+ }
1014
+ }
1015
+ return importers || [];
1016
+ }
1017
+ /**
1018
+ * Get assets affected when a source file changes.
1019
+ */
1020
+ getAffectedAssets(sourceFile) {
1021
+ return Array.from(this.reverseIndex.get(sourceFile) || []);
1022
+ }
1023
+ initWatcher() {
1024
+ const watchPaths = this.options.include.map((dir) => path5.join(this.root, dir));
1025
+ this.watcher = chokidar2.watch(watchPaths, {
1026
+ ignored: [
1027
+ ...this.options.exclude.map((p) => `**/${p}/**`),
1028
+ (filePath) => {
1029
+ const ext = path5.extname(filePath).slice(1);
1030
+ return ext !== "" && !SOURCE_EXTENSIONS.includes(ext);
1031
+ }
1032
+ ],
1033
+ persistent: true,
1034
+ ignoreInitial: true,
1035
+ awaitWriteFinish: {
1036
+ stabilityThreshold: 200,
1037
+ pollInterval: 50
1038
+ }
1039
+ });
1040
+ this.watcher.on("add", (filePath) => this.handleFileChange("add", filePath));
1041
+ this.watcher.on("unlink", (filePath) => this.handleFileChange("unlink", filePath));
1042
+ this.watcher.on("change", (filePath) => this.handleFileChange("change", filePath));
1043
+ }
1044
+ async handleFileChange(event, absolutePath) {
1045
+ const relativePath = path5.relative(this.root, absolutePath);
1046
+ const extension = path5.extname(relativePath).slice(1);
1047
+ if (!SOURCE_EXTENSIONS.includes(extension)) {
1048
+ return;
1049
+ }
1050
+ const previousAssets = this.getAffectedAssets(relativePath);
1051
+ if (event === "unlink") {
1052
+ for (const assetPath of previousAssets) {
1053
+ const assetImporters = this.cache.get(assetPath);
1054
+ if (assetImporters) {
1055
+ const filtered = assetImporters.filter((i) => i.filePath !== relativePath);
1056
+ if (filtered.length > 0) {
1057
+ this.cache.set(assetPath, filtered);
1058
+ } else {
1059
+ this.cache.delete(assetPath);
1060
+ }
1061
+ }
1062
+ }
1063
+ this.reverseIndex.delete(relativePath);
1064
+ } else {
1065
+ await this.scanFile(relativePath);
1066
+ }
1067
+ const currentAssets = this.getAffectedAssets(relativePath);
1068
+ const allAffectedAssets = [.../* @__PURE__ */ new Set([...previousAssets, ...currentAssets])];
1069
+ if (allAffectedAssets.length > 0) {
1070
+ this.emit("change", {
1071
+ event,
1072
+ path: relativePath,
1073
+ affectedAssets: allAffectedAssets
1074
+ });
1075
+ }
1076
+ }
1077
+ destroy() {
1078
+ this.watcher?.close();
1079
+ }
1080
+ };
1081
+
1082
+ // src/server/duplicate-scanner.ts
1083
+ import { EventEmitter as EventEmitter3 } from "events";
1084
+ import crypto from "crypto";
1085
+ import fs5 from "fs";
1086
+ import path6 from "path";
1087
+ import chokidar3 from "chokidar";
1088
+ var STREAMING_THRESHOLD = 1024 * 1024;
1089
+ var DuplicateScanner = class extends EventEmitter3 {
1090
+ root;
1091
+ options;
1092
+ /** Maps relative path -> { hash, mtime, size } for cache validation */
1093
+ hashCache = /* @__PURE__ */ new Map();
1094
+ /** Maps hash -> set of relative paths (for grouping duplicates) */
1095
+ duplicateGroups = /* @__PURE__ */ new Map();
1096
+ /** Reverse index: path -> hash (for quick lookups) */
1097
+ pathToHash = /* @__PURE__ */ new Map();
1098
+ watcher;
1099
+ scanPromise;
1100
+ initialized = false;
1101
+ constructor(root, options) {
1102
+ super();
1103
+ this.root = root;
1104
+ this.options = options;
1105
+ }
1106
+ async init() {
1107
+ if (this.initialized) return;
1108
+ this.initialized = true;
1109
+ }
1110
+ /**
1111
+ * Scan all assets and compute hashes.
1112
+ * Called after AssetScanner has discovered assets.
1113
+ */
1114
+ async scanAssets(assets) {
1115
+ if (this.scanPromise) {
1116
+ await this.scanPromise;
1117
+ return;
1118
+ }
1119
+ this.scanPromise = this.performScan(assets);
1120
+ await this.scanPromise;
1121
+ this.scanPromise = void 0;
1122
+ }
1123
+ async performScan(assets) {
1124
+ this.duplicateGroups.clear();
1125
+ this.pathToHash.clear();
1126
+ const BATCH_SIZE = 20;
1127
+ for (let i = 0; i < assets.length; i += BATCH_SIZE) {
1128
+ const batch = assets.slice(i, i + BATCH_SIZE);
1129
+ await Promise.all(batch.map((asset) => this.processAsset(asset)));
1130
+ }
1131
+ }
1132
+ async processAsset(asset) {
1133
+ try {
1134
+ const hash = await this.getOrComputeHash(asset.path, asset.absolutePath);
1135
+ if (hash) {
1136
+ this.pathToHash.set(asset.path, hash);
1137
+ if (!this.duplicateGroups.has(hash)) {
1138
+ this.duplicateGroups.set(hash, /* @__PURE__ */ new Set());
1139
+ }
1140
+ this.duplicateGroups.get(hash).add(asset.path);
1141
+ }
1142
+ } catch {
1143
+ }
1144
+ }
1145
+ /**
1146
+ * Get cached hash or compute new one if cache is invalid.
1147
+ */
1148
+ async getOrComputeHash(relativePath, absolutePath) {
1149
+ try {
1150
+ const stats = await fs5.promises.stat(absolutePath);
1151
+ const cached = this.hashCache.get(relativePath);
1152
+ if (cached && cached.mtime === stats.mtimeMs && cached.size === stats.size) {
1153
+ return cached.hash;
1154
+ }
1155
+ const hash = await this.computeFileHash(absolutePath, stats.size);
1156
+ this.hashCache.set(relativePath, {
1157
+ hash,
1158
+ mtime: stats.mtimeMs,
1159
+ size: stats.size
1160
+ });
1161
+ return hash;
1162
+ } catch {
1163
+ return null;
1164
+ }
1165
+ }
1166
+ /**
1167
+ * Compute MD5 hash of file contents.
1168
+ * Uses streaming for large files to avoid memory issues.
1169
+ */
1170
+ async computeFileHash(absolutePath, size) {
1171
+ if (size > STREAMING_THRESHOLD) {
1172
+ return new Promise((resolve, reject) => {
1173
+ const hash = crypto.createHash("md5");
1174
+ const stream = fs5.createReadStream(absolutePath);
1175
+ stream.on("data", (chunk) => hash.update(chunk));
1176
+ stream.on("end", () => resolve(hash.digest("hex")));
1177
+ stream.on("error", reject);
1178
+ });
1179
+ }
1180
+ const content = await fs5.promises.readFile(absolutePath);
1181
+ return crypto.createHash("md5").update(content).digest("hex");
1182
+ }
1183
+ /**
1184
+ * Get duplicate info for a specific asset.
1185
+ */
1186
+ getDuplicateInfo(assetPath) {
1187
+ const normalizedPath = assetPath.split(path6.sep).join("/");
1188
+ const hash = this.pathToHash.get(normalizedPath);
1189
+ if (!hash) {
1190
+ return { hash: "", duplicatesCount: 0 };
1191
+ }
1192
+ const group = this.duplicateGroups.get(hash);
1193
+ const duplicatesCount = group ? group.size - 1 : 0;
1194
+ return { hash, duplicatesCount };
1195
+ }
1196
+ /**
1197
+ * Get all assets in a duplicate group by hash.
1198
+ */
1199
+ getDuplicatesByHash(hash) {
1200
+ const group = this.duplicateGroups.get(hash);
1201
+ return group ? Array.from(group).sort() : [];
1202
+ }
1203
+ /**
1204
+ * Get duplicate statistics.
1205
+ */
1206
+ getStats() {
1207
+ let duplicateGroups = 0;
1208
+ let duplicateFiles = 0;
1209
+ for (const [, paths] of this.duplicateGroups) {
1210
+ if (paths.size > 1) {
1211
+ duplicateGroups++;
1212
+ duplicateFiles += paths.size;
1213
+ }
1214
+ }
1215
+ return { duplicateGroups, duplicateFiles };
1216
+ }
1217
+ /**
1218
+ * Enrich assets with duplicate detection metadata.
1219
+ */
1220
+ enrichAssetsWithDuplicateInfo(assets) {
1221
+ for (const asset of assets) {
1222
+ const info = this.getDuplicateInfo(asset.path);
1223
+ asset.contentHash = info.hash;
1224
+ asset.duplicatesCount = info.duplicatesCount;
1225
+ }
1226
+ }
1227
+ /**
1228
+ * Handle file change event from watcher.
1229
+ * Recalculates hash and updates duplicate groups.
1230
+ */
1231
+ async handleAssetChange(event, relativePath, absolutePath) {
1232
+ const normalizedPath = relativePath.split(path6.sep).join("/");
1233
+ const previousHash = this.pathToHash.get(normalizedPath);
1234
+ const affectedHashes = [];
1235
+ if (previousHash) {
1236
+ affectedHashes.push(previousHash);
1237
+ const oldGroup = this.duplicateGroups.get(previousHash);
1238
+ if (oldGroup) {
1239
+ oldGroup.delete(normalizedPath);
1240
+ if (oldGroup.size === 0) {
1241
+ this.duplicateGroups.delete(previousHash);
1242
+ }
1243
+ }
1244
+ this.pathToHash.delete(normalizedPath);
1245
+ }
1246
+ if (event === "unlink") {
1247
+ this.hashCache.delete(normalizedPath);
1248
+ } else {
1249
+ try {
1250
+ const stats = await fs5.promises.stat(absolutePath);
1251
+ const hash = await this.computeFileHash(absolutePath, stats.size);
1252
+ this.hashCache.set(normalizedPath, {
1253
+ hash,
1254
+ mtime: stats.mtimeMs,
1255
+ size: stats.size
1256
+ });
1257
+ this.pathToHash.set(normalizedPath, hash);
1258
+ if (!this.duplicateGroups.has(hash)) {
1259
+ this.duplicateGroups.set(hash, /* @__PURE__ */ new Set());
1260
+ }
1261
+ this.duplicateGroups.get(hash).add(normalizedPath);
1262
+ if (!affectedHashes.includes(hash)) {
1263
+ affectedHashes.push(hash);
1264
+ }
1265
+ } catch {
1266
+ }
1267
+ }
1268
+ if (affectedHashes.length > 0) {
1269
+ this.emit("change", { event, affectedHashes });
1270
+ }
1271
+ }
1272
+ /**
1273
+ * Initialize file watcher for real-time updates.
1274
+ * Note: This watches asset files, not source files.
1275
+ */
1276
+ initWatcher() {
1277
+ if (this.watcher) return;
1278
+ const watchPaths = this.options.include.map((dir) => path6.join(this.root, dir));
1279
+ this.watcher = chokidar3.watch(watchPaths, {
1280
+ ignored: [
1281
+ ...this.options.exclude.map((p) => `**/${p}/**`),
1282
+ (filePath) => {
1283
+ const ext = path6.extname(filePath).toLowerCase();
1284
+ return ext !== "" && !this.options.extensions.includes(ext);
1285
+ }
1286
+ ],
1287
+ persistent: true,
1288
+ ignoreInitial: true,
1289
+ awaitWriteFinish: {
1290
+ stabilityThreshold: 200,
1291
+ pollInterval: 50
1292
+ }
1293
+ });
1294
+ this.watcher.on("add", async (filePath) => {
1295
+ const relativePath = path6.relative(this.root, filePath);
1296
+ await this.handleAssetChange("add", relativePath, filePath);
1297
+ });
1298
+ this.watcher.on("change", async (filePath) => {
1299
+ const relativePath = path6.relative(this.root, filePath);
1300
+ await this.handleAssetChange("change", relativePath, filePath);
1301
+ });
1302
+ this.watcher.on("unlink", async (filePath) => {
1303
+ const relativePath = path6.relative(this.root, filePath);
1304
+ await this.handleAssetChange("unlink", relativePath, filePath);
1305
+ });
1306
+ }
1307
+ destroy() {
1308
+ this.watcher?.close();
1309
+ this.hashCache.clear();
1310
+ this.duplicateGroups.clear();
1311
+ this.pathToHash.clear();
1312
+ }
1313
+ };
1314
+
1315
+ // src/server/thumbnail.ts
1316
+ import sharp from "sharp";
1317
+ import path7 from "path";
1318
+ import fs6 from "fs/promises";
1319
+ import { createHash } from "crypto";
1320
+ import os from "os";
1321
+ var ThumbnailService = class {
1322
+ size;
1323
+ cache = /* @__PURE__ */ new Map();
1324
+ cacheDir;
1325
+ supportedFormats = [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".tiff"];
1326
+ constructor(size = 200) {
1327
+ this.size = size;
1328
+ this.cacheDir = path7.join(os.tmpdir(), "vite-asset-manager-thumbnails");
1329
+ }
1330
+ async getThumbnail(absolutePath) {
1331
+ const extension = path7.extname(absolutePath).toLowerCase();
1332
+ if (!this.supportedFormats.includes(extension)) {
1333
+ return null;
1334
+ }
1335
+ const cacheKey = await this.getCacheKey(absolutePath);
1336
+ const cached = this.cache.get(cacheKey);
1337
+ if (cached) {
1338
+ return cached;
1339
+ }
1340
+ const diskCached = await this.loadFromDiskCache(cacheKey);
1341
+ if (diskCached) {
1342
+ this.cache.set(cacheKey, diskCached);
1343
+ return diskCached;
1344
+ }
1345
+ try {
1346
+ const thumbnail = await this.generateThumbnail(absolutePath);
1347
+ this.cache.set(cacheKey, thumbnail);
1348
+ await this.saveToDiskCache(cacheKey, thumbnail);
1349
+ return thumbnail;
1350
+ } catch (error) {
1351
+ console.warn(`[asset-manager] Failed to generate thumbnail for ${absolutePath}:`, error);
1352
+ return null;
1353
+ }
1354
+ }
1355
+ async generateThumbnail(absolutePath) {
1356
+ return sharp(absolutePath).resize(this.size, this.size, {
1357
+ fit: "cover",
1358
+ position: "center"
1359
+ }).jpeg({ quality: 80 }).toBuffer();
1360
+ }
1361
+ async getCacheKey(absolutePath) {
1362
+ const hash = createHash("md5");
1363
+ hash.update(absolutePath);
1364
+ hash.update(this.size.toString());
1365
+ try {
1366
+ const stats = await fs6.stat(absolutePath);
1367
+ hash.update(stats.mtimeMs.toString());
1368
+ } catch {
1369
+ }
1370
+ return hash.digest("hex");
1371
+ }
1372
+ async loadFromDiskCache(key) {
1373
+ try {
1374
+ const cachePath = path7.join(this.cacheDir, `${key}.jpg`);
1375
+ return await fs6.readFile(cachePath);
1376
+ } catch {
1377
+ return null;
1378
+ }
1379
+ }
1380
+ async saveToDiskCache(key, data) {
1381
+ try {
1382
+ await fs6.mkdir(this.cacheDir, { recursive: true });
1383
+ const cachePath = path7.join(this.cacheDir, `${key}.jpg`);
1384
+ await fs6.writeFile(cachePath, data);
1385
+ } catch {
1386
+ }
1387
+ }
1388
+ invalidate(absolutePath) {
1389
+ this.getCacheKey(absolutePath).then((key) => {
1390
+ this.cache.delete(key);
1391
+ });
1392
+ }
1393
+ isSupportedFormat(extension) {
1394
+ return this.supportedFormats.includes(extension.toLowerCase());
1395
+ }
1396
+ };
1397
+
1398
+ // src/shared/types.ts
1399
+ var DEFAULT_OPTIONS = {
1400
+ base: "/__asset_manager__",
1401
+ include: ["src", "public"],
1402
+ exclude: ["node_modules", ".git", "dist", ".cache", "coverage"],
1403
+ extensions: [
1404
+ /**
1405
+ * Images:
1406
+ */
1407
+ ".png",
1408
+ ".jpg",
1409
+ ".jpeg",
1410
+ ".gif",
1411
+ ".svg",
1412
+ ".webp",
1413
+ ".avif",
1414
+ ".ico",
1415
+ ".bmp",
1416
+ ".tiff",
1417
+ ".tif",
1418
+ ".heic",
1419
+ ".heif",
1420
+ /**
1421
+ * Videos:
1422
+ */
1423
+ ".mp4",
1424
+ ".webm",
1425
+ ".ogg",
1426
+ ".mov",
1427
+ ".avi",
1428
+ /**
1429
+ * Audio:
1430
+ */
1431
+ ".mp3",
1432
+ ".wav",
1433
+ ".flac",
1434
+ ".aac",
1435
+ /**
1436
+ * Documents:
1437
+ */
1438
+ ".pdf",
1439
+ ".doc",
1440
+ ".docx",
1441
+ ".xls",
1442
+ ".xlsx",
1443
+ ".ppt",
1444
+ ".pptx",
1445
+ /**
1446
+ * Text/Config:
1447
+ */
1448
+ ".json",
1449
+ ".md",
1450
+ ".txt",
1451
+ ".csv",
1452
+ ".yml",
1453
+ ".yaml",
1454
+ ".toml",
1455
+ ".xml",
1456
+ /**
1457
+ * Fonts:
1458
+ */
1459
+ ".woff",
1460
+ ".woff2",
1461
+ ".ttf",
1462
+ ".otf",
1463
+ ".eot"
1464
+ ],
1465
+ thumbnails: true,
1466
+ thumbnailSize: 200,
1467
+ watch: true,
1468
+ floatingIcon: true,
1469
+ launchEditor: "code"
1470
+ };
1471
+ function resolveOptions(options) {
1472
+ return {
1473
+ ...DEFAULT_OPTIONS,
1474
+ ...options
1475
+ };
1476
+ }
1477
+
1478
+ // src/plugin.ts
1479
+ var FLOATING_ICON_SCRIPT = (base) => `
1480
+ <script type="module">
1481
+ (function() {
1482
+ const BASE_URL = '${base}';
1483
+ const STORAGE_KEY = 'vite-asset-manager-open';
1484
+
1485
+ // Styles for the floating button and overlay
1486
+ const styles = document.createElement('style');
1487
+ styles.textContent = \`
1488
+ #vam-trigger {
1489
+ position: fixed;
1490
+ bottom: 20px;
1491
+ right: 20px;
1492
+ width: 48px;
1493
+ height: 48px;
1494
+ border-radius: 14px;
1495
+ background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
1496
+ border: none;
1497
+ cursor: pointer;
1498
+ display: flex;
1499
+ align-items: center;
1500
+ justify-content: center;
1501
+ box-shadow: 0 4px 24px -4px rgba(139, 92, 246, 0.5), 0 0 0 1px rgba(255,255,255,0.1) inset;
1502
+ transition: all 0.2s ease;
1503
+ z-index: 99998;
1504
+ }
1505
+ #vam-trigger:hover {
1506
+ transform: translateY(-2px) scale(1.05);
1507
+ box-shadow: 0 8px 32px -4px rgba(139, 92, 246, 0.6), 0 0 0 1px rgba(255,255,255,0.15) inset;
1508
+ }
1509
+ #vam-trigger:active {
1510
+ transform: translateY(0) scale(0.98);
1511
+ }
1512
+ #vam-trigger svg {
1513
+ width: 24px;
1514
+ height: 24px;
1515
+ color: white;
1516
+ }
1517
+ #vam-overlay {
1518
+ position: fixed;
1519
+ inset: 0;
1520
+ background: rgba(0, 0, 0, 0.6);
1521
+ backdrop-filter: blur(4px);
1522
+ z-index: 99999;
1523
+ opacity: 0;
1524
+ visibility: hidden;
1525
+ transition: opacity 0.3s ease, visibility 0.3s ease;
1526
+ }
1527
+ #vam-overlay.open {
1528
+ opacity: 1;
1529
+ visibility: visible;
1530
+ }
1531
+ #vam-panel {
1532
+ position: fixed;
1533
+ top: 0;
1534
+ right: 0;
1535
+ bottom: 0;
1536
+ width: min(90vw, 1200px);
1537
+ background: #09090b;
1538
+ border-left: 1px solid rgba(255,255,255,0.08);
1539
+ transform: translateX(100%);
1540
+ transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
1541
+ z-index: 100000;
1542
+ display: flex;
1543
+ flex-direction: column;
1544
+ }
1545
+ #vam-overlay.open #vam-panel {
1546
+ transform: translateX(0);
1547
+ }
1548
+ #vam-panel-header {
1549
+ display: flex;
1550
+ align-items: center;
1551
+ justify-content: space-between;
1552
+ padding: 12px 16px;
1553
+ border-bottom: 1px solid rgba(255,255,255,0.08);
1554
+ background: #0f0f11;
1555
+ }
1556
+ #vam-panel-title {
1557
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
1558
+ font-size: 12px;
1559
+ font-weight: 600;
1560
+ color: #fafafa;
1561
+ letter-spacing: 0.05em;
1562
+ display: flex;
1563
+ align-items: center;
1564
+ gap: 8px;
1565
+ }
1566
+ #vam-panel-title svg {
1567
+ width: 18px;
1568
+ height: 18px;
1569
+ color: #8b5cf6;
1570
+ }
1571
+ #vam-close-btn {
1572
+ width: 32px;
1573
+ height: 32px;
1574
+ border-radius: 8px;
1575
+ border: none;
1576
+ background: transparent;
1577
+ cursor: pointer;
1578
+ display: flex;
1579
+ align-items: center;
1580
+ justify-content: center;
1581
+ color: #71717a;
1582
+ transition: all 0.15s ease;
1583
+ }
1584
+ #vam-close-btn:hover {
1585
+ background: rgba(255,255,255,0.1);
1586
+ color: #fafafa;
1587
+ }
1588
+ #vam-iframe {
1589
+ flex: 1;
1590
+ border: none;
1591
+ width: 100%;
1592
+ height: 100%;
1593
+ }
1594
+ \`;
1595
+ document.head.appendChild(styles);
1596
+
1597
+ // Create trigger button
1598
+ const trigger = document.createElement('button');
1599
+ trigger.id = 'vam-trigger';
1600
+ trigger.title = 'Open Asset Manager (\u2325\u21E7A)';
1601
+ trigger.innerHTML = \`
1602
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor">
1603
+ <path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,16V88H40V56Zm0,144H40V104H216v96ZM64,128a8,8,0,0,1,8-8h80a8,8,0,0,1,0,16H72A8,8,0,0,1,64,128Zm0,32a8,8,0,0,1,8-8h80a8,8,0,0,1,0,16H72A8,8,0,0,1,64,160Z"/>
1604
+ </svg>
1605
+ \`;
1606
+
1607
+ // Create overlay and panel
1608
+ const overlay = document.createElement('div');
1609
+ overlay.id = 'vam-overlay';
1610
+ overlay.innerHTML = \`
1611
+ <div id="vam-panel">
1612
+ <div id="vam-panel-header">
1613
+ <div id="vam-panel-title">
1614
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor">
1615
+ <path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,160H40V56H216V200ZM176,88a48,48,0,1,0-96,0,48,48,0,0,0,96,0Zm-48,32a32,32,0,1,1,32-32A32,32,0,0,1,128,120Zm80,56a8,8,0,0,1-8,8H56a8,8,0,0,1-6.65-12.44l24-36a8,8,0,0,1,13.3,0l15.18,22.77,24.89-41.48a8,8,0,0,1,13.72.18l40,64A8,8,0,0,1,208,176Z"/>
1616
+ </svg>
1617
+ ASSET MANAGER
1618
+ </div>
1619
+ <button id="vam-close-btn" title="Close (Esc)">
1620
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256" fill="currentColor">
1621
+ <path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/>
1622
+ </svg>
1623
+ </button>
1624
+ </div>
1625
+ <iframe id="vam-iframe" src="\${BASE_URL}?embedded=true"></iframe>
1626
+ </div>
1627
+ \`;
1628
+
1629
+ document.body.appendChild(trigger);
1630
+ document.body.appendChild(overlay);
1631
+
1632
+ // State management
1633
+ let isOpen = sessionStorage.getItem(STORAGE_KEY) === 'true';
1634
+
1635
+ function open() {
1636
+ isOpen = true;
1637
+ overlay.classList.add('open');
1638
+ sessionStorage.setItem(STORAGE_KEY, 'true');
1639
+ }
1640
+
1641
+ function close() {
1642
+ isOpen = false;
1643
+ overlay.classList.remove('open');
1644
+ sessionStorage.setItem(STORAGE_KEY, 'false');
1645
+ }
1646
+
1647
+ // Restore state
1648
+ if (isOpen) {
1649
+ requestAnimationFrame(() => open());
1650
+ }
1651
+
1652
+ // Event listeners
1653
+ trigger.addEventListener('click', () => {
1654
+ if (isOpen) close();
1655
+ else open();
1656
+ });
1657
+
1658
+ overlay.addEventListener('click', (e) => {
1659
+ if (e.target === overlay) close();
1660
+ });
1661
+
1662
+ document.getElementById('vam-close-btn').addEventListener('click', close);
1663
+
1664
+ document.addEventListener('keydown', (e) => {
1665
+ // Close on Escape
1666
+ if (e.key === 'Escape' && isOpen) {
1667
+ close();
1668
+ }
1669
+ // Toggle on Option/Alt + Shift + A
1670
+ if (e.altKey && e.shiftKey && e.code === 'KeyA') {
1671
+ e.preventDefault();
1672
+ if (isOpen) close();
1673
+ else open();
1674
+ }
1675
+ });
1676
+ })();
1677
+ </script>
1678
+ `;
1679
+ function createAssetManagerPlugin(options = {}) {
1680
+ let config;
1681
+ let scanner;
1682
+ let importerScanner;
1683
+ let duplicateScanner;
1684
+ let thumbnailService;
1685
+ const resolvedOptions = resolveOptions(options);
1686
+ return {
1687
+ name: "vite-plugin-asset-manager",
1688
+ apply: "serve",
1689
+ configResolved(resolvedConfig) {
1690
+ config = resolvedConfig;
1691
+ },
1692
+ configureServer(server) {
1693
+ scanner = new AssetScanner(config.root, resolvedOptions);
1694
+ importerScanner = new ImporterScanner(config.root, resolvedOptions);
1695
+ duplicateScanner = new DuplicateScanner(config.root, resolvedOptions);
1696
+ thumbnailService = new ThumbnailService(resolvedOptions.thumbnailSize);
1697
+ setupMiddleware(server, {
1698
+ base: resolvedOptions.base,
1699
+ scanner,
1700
+ importerScanner,
1701
+ duplicateScanner,
1702
+ thumbnailService,
1703
+ root: config.root,
1704
+ launchEditor: resolvedOptions.launchEditor
1705
+ });
1706
+ scanner.init().then(async () => {
1707
+ await importerScanner.init();
1708
+ scanner.enrichWithImporterCounts(importerScanner);
1709
+ await duplicateScanner.init();
1710
+ await duplicateScanner.scanAssets(scanner.getAssets());
1711
+ scanner.enrichWithDuplicateInfo(duplicateScanner);
1712
+ if (resolvedOptions.watch) {
1713
+ duplicateScanner.initWatcher();
1714
+ }
1715
+ });
1716
+ const _printUrls = server.printUrls;
1717
+ server.printUrls = () => {
1718
+ _printUrls();
1719
+ const colorUrl = (url2) => colors.cyan(url2.replace(/:(\d+)\//, (_, port) => `:${colors.bold(port)}/`));
1720
+ let host = `${server.config.server.https ? "https" : "http"}://localhost:${server.config.server.port || "80"}`;
1721
+ const url = server.resolvedUrls?.local[0];
1722
+ if (url) {
1723
+ try {
1724
+ const u = new URL(url);
1725
+ host = `${u.protocol}//${u.host}`;
1726
+ } catch {
1727
+ }
1728
+ }
1729
+ const base = server.config.base || "/";
1730
+ const fullUrl = `${host}${base}${resolvedOptions.base.replace(/^\//, "")}/`;
1731
+ server.config.logger.info(
1732
+ ` ${colors.magenta("\u279C")} ${colors.bold("Asset Manager")}: Open ${colorUrl(fullUrl)} as a separate window`
1733
+ );
1734
+ server.config.logger.info(
1735
+ ` ${colors.magenta("\u279C")} ${colors.bold("Asset Manager")}: Press ${colors.yellow("Option(\u2325)+Shift(\u21E7)+A")} in App to toggle the Asset Manager`
1736
+ );
1737
+ };
1738
+ if (resolvedOptions.watch) {
1739
+ scanner.on("change", async (event) => {
1740
+ await duplicateScanner.scanAssets(scanner.getAssets());
1741
+ scanner.enrichWithDuplicateInfo(duplicateScanner);
1742
+ broadcastSSE("asset-manager:update", event);
1743
+ });
1744
+ importerScanner.on("change", (event) => {
1745
+ scanner.enrichWithImporterCounts(importerScanner);
1746
+ broadcastSSE("asset-manager:importers-update", event);
1747
+ });
1748
+ duplicateScanner.on("change", (event) => {
1749
+ scanner.enrichWithDuplicateInfo(duplicateScanner);
1750
+ broadcastSSE("asset-manager:duplicates-update", event);
1751
+ });
1752
+ }
1753
+ },
1754
+ transformIndexHtml() {
1755
+ if (!resolvedOptions.floatingIcon) {
1756
+ return [];
1757
+ }
1758
+ return [
1759
+ {
1760
+ tag: "script",
1761
+ attrs: { type: "module" },
1762
+ children: FLOATING_ICON_SCRIPT(resolvedOptions.base).replace(/<\/?script[^>]*>/g, "").trim(),
1763
+ injectTo: "body"
1764
+ }
1765
+ ];
1766
+ },
1767
+ resolveId(id) {
1768
+ if (id === "virtual:asset-manager-config") {
1769
+ return "\0virtual:asset-manager-config";
1770
+ }
1771
+ },
1772
+ load(id) {
1773
+ if (id === "\0virtual:asset-manager-config") {
1774
+ return `export default ${JSON.stringify({
1775
+ base: resolvedOptions.base,
1776
+ extensions: resolvedOptions.extensions
1777
+ })}`;
1778
+ }
1779
+ },
1780
+ buildEnd() {
1781
+ scanner?.destroy();
1782
+ importerScanner?.destroy();
1783
+ duplicateScanner?.destroy();
1784
+ }
1785
+ };
1786
+ }
1787
+
1788
+ // src/index.ts
1789
+ function assetManager(options = {}) {
1790
+ return createAssetManagerPlugin(options);
1791
+ }
1792
+ export {
1793
+ assetManager,
1794
+ assetManager as default
1795
+ };
1796
+ //# sourceMappingURL=index.js.map