codebase-asset-optimizer
Version:
Professional CLI development tool for optimizing and managing assets in codebases. Detects unused assets, optimizes images to WebP, optimizes videos, and automatically replaces asset references. GIFs are preserved unchanged to maintain animation functiona
757 lines ⢠33.5 kB
JavaScript
import fs from "fs-extra";
import path from "path";
import os from "os";
import { execSync } from "child_process";
import sharp from "sharp";
import fastGlob from "fast-glob";
import mimeTypes from "mime-types";
/**
* Main asset optimizer class with smart size detection
*/
export class AssetOptimizer {
constructor(config) {
this.assets = [];
this.results = {
totalAssets: 0,
totalSize: 0,
usedAssets: [],
unusedAssets: [],
largeAssets: [],
optimizableAssets: [],
summary: {
usedCount: 0,
usedSize: 0,
unusedCount: 0,
unusedSize: 0,
largeUnusedCount: 0,
largeUsedCount: 0,
optimizableCount: 0,
optimizableSize: 0,
potentialSavings: 0,
assetsByType: {
images: { total: 0, used: 0, unused: 0, optimizable: 0 },
videos: { total: 0, used: 0, unused: 0, optimizable: 0 },
},
},
};
this.config = config;
}
/**
* Reset audit results to initial state
*/
resetResults() {
this.results = {
totalAssets: 0,
totalSize: 0,
usedAssets: [],
unusedAssets: [],
largeAssets: [],
optimizableAssets: [],
summary: {
usedCount: 0,
usedSize: 0,
unusedCount: 0,
unusedSize: 0,
largeUnusedCount: 0,
largeUsedCount: 0,
optimizableCount: 0,
optimizableSize: 0,
potentialSavings: 0,
assetsByType: {
images: { total: 0, used: 0, unused: 0, optimizable: 0 },
videos: { total: 0, used: 0, unused: 0, optimizable: 0 },
},
},
};
}
/**
* Run complete audit of assets
*/
async audit() {
console.log("š Starting asset audit...\n");
// Reset results from previous audits
this.resetResults();
await this.discoverAssets();
await this.checkAssetUsage();
await this.identifyOptimizableAssets();
await this.generateReport();
return this.results;
}
/**
* Discover all image and video assets
*/
async discoverAssets() {
console.log("š Discovering assets...");
const patterns = [
...this.config.imageExtensions.map((ext) => `**/*${ext}`),
...this.config.videoExtensions.map((ext) => `**/*${ext}`),
];
const files = await fastGlob(patterns, {
cwd: this.config.publicDir,
ignore: this.config.excludePatterns,
absolute: false,
caseSensitiveMatch: false,
});
this.assets = [];
for (const relativePath of files) {
const fullPath = path.join(this.config.publicDir, relativePath);
const stats = await fs.stat(fullPath);
const extension = path.extname(relativePath).toLowerCase();
const assetType = this.getAssetType(extension);
const mimeType = mimeTypes.lookup(extension) || undefined;
const asset = {
filename: path.basename(relativePath),
fullPath,
relativePath,
size: stats.size,
extension,
assetType,
used: false,
references: [],
optimizable: await this.isOptimizable(fullPath, extension, assetType),
mimeType,
};
this.assets.push(asset);
this.results.totalSize += stats.size;
}
this.results.totalAssets = this.assets.length;
console.log(` Found ${this.results.totalAssets} assets (${this.formatBytes(this.results.totalSize)})`);
}
/**
* Check if each asset is used in the codebase
*/
async checkAssetUsage() {
console.log("š Checking asset usage...");
let processed = 0;
for (const asset of this.assets) {
const isUsed = await this.isAssetUsed(asset);
asset.used = isUsed;
if (isUsed) {
this.results.usedAssets.push(asset);
}
else {
this.results.unusedAssets.push(asset);
}
if (asset.size > this.config.largeFileThreshold) {
this.results.largeAssets.push(asset);
}
processed++;
if (processed % 10 === 0) {
process.stdout.write(` Processed ${processed}/${this.assets.length} assets...\r`);
}
}
console.log(` Processed ${processed}/${this.assets.length} assets`);
}
/**
* Check if an asset is used by searching for references
*/
async isAssetUsed(asset) {
try {
const searchPatterns = [
asset.filename,
path.parse(asset.filename).name,
asset.relativePath.replace(/\\/g, "/"),
asset.relativePath.replace(/\\/g, "/").replace(/^\//, ""),
];
for (const pattern of searchPatterns) {
// Use fast-glob to search for files containing the pattern
const files = await fastGlob(["**/*.{ts,tsx,js,jsx,css,scss,json,md,html}"], {
cwd: this.config.sourceDir,
ignore: this.config.excludePatterns,
absolute: true,
});
for (const file of files) {
try {
const content = await fs.readFile(file, "utf8");
if (content.includes(pattern)) {
asset.references.push({
file: path.relative(this.config.projectRoot, file),
pattern,
context: `Found pattern: ${pattern}`,
});
return true;
}
}
catch (error) {
// Skip files that can't be read
continue;
}
}
}
return false;
}
catch (error) {
console.warn(` ā ļø Error checking ${asset.filename}: ${error}`);
return true; // Assume used to be safe
}
}
/**
* Identify assets that can be optimized
*/
async identifyOptimizableAssets() {
console.log("šÆ Identifying optimization opportunities...");
this.results.optimizableAssets = this.assets.filter((asset) => {
if (!asset.used)
return false; // Don't optimize unused assets
return asset.optimizable;
});
console.log(` Found ${this.results.optimizableAssets.length} assets that can be optimized`);
}
/**
* Check if an asset can be optimized
*/
async isOptimizable(filePath, extension, assetType) {
if (assetType === "image") {
// Don't optimize SVGs as they're already optimized vectors
if (extension === ".svg")
return false;
// Don't optimize GIFs - they are often animations and conversion could break functionality
if (extension === ".gif")
return false;
// Only optimize if target format is different and likely to be smaller
return extension !== `.${this.config.optimization.images.targetFormat}`;
}
if (assetType === "video") {
// Always try to optimize videos that are larger than 1MB, regardless of format
// Even MP4/WebM files can often be compressed further with better encoding settings
const stats = await fs.stat(filePath);
const fileSizeThreshold = 1 * 1024 * 1024; // 1MB
return stats.size > fileSizeThreshold;
}
return false;
}
/**
* Optimize assets with smart size detection
*/
async optimizeAssets(createBackup = true) {
const startTime = Date.now();
const stats = {
processed: 0,
optimized: 0,
failed: 0,
sizeBefore: 0,
sizeAfter: 0,
duration: 0,
};
if (this.results.optimizableAssets.length === 0) {
console.log("ā
No assets need optimization!");
return stats;
}
console.log(`\nšÆ Optimizing ${this.results.optimizableAssets.length} assets...`);
// Create backup if requested
let backupInfo = null;
if (createBackup) {
backupInfo = await this.createBackup(this.results.optimizableAssets);
}
for (const asset of this.results.optimizableAssets) {
try {
console.log(` š Optimizing: ${asset.filename}`);
stats.processed++;
stats.sizeBefore += asset.size;
let result;
if (asset.assetType === "image") {
result = await this.optimizeImage(asset);
}
else {
result = await this.optimizeVideo(asset);
}
if (result.success) {
// Critical: Only use optimization if it actually reduces size
if (result.newSize < result.originalSize) {
stats.optimized++;
stats.sizeAfter += result.newSize;
// Replace the original file with optimized version
await this.replaceAsset(asset, result.outputPath);
console.log(` ā ${this.formatBytes(result.originalSize)} ā ${this.formatBytes(result.newSize)} (${result.savings}% smaller)`);
}
else {
// Optimization made file larger, keep original
stats.sizeAfter += result.originalSize;
if (result.outputPath && (await fs.pathExists(result.outputPath))) {
await fs.remove(result.outputPath);
}
console.log(` āŖ Kept original (optimization would increase size)`);
}
}
else {
stats.failed++;
stats.sizeAfter += asset.size;
console.log(` ā Failed: ${result.error}`);
}
}
catch (error) {
stats.failed++;
stats.sizeAfter += asset.size;
console.error(` ā Error optimizing ${asset.filename}: ${error}`);
}
}
stats.duration = Date.now() - startTime;
const totalSavings = stats.sizeBefore - stats.sizeAfter;
const savingsPercent = stats.sizeBefore > 0
? Math.round((totalSavings / stats.sizeBefore) * 100)
: 0;
console.log(`\nā
Optimization complete:`);
console.log(` Assets processed: ${stats.processed}`);
console.log(` Successfully optimized: ${stats.optimized}`);
console.log(` Failed: ${stats.failed}`);
console.log(` Total savings: ${this.formatBytes(totalSavings)} (${savingsPercent}% reduction)`);
console.log(` Duration: ${(stats.duration / 1000).toFixed(1)}s`);
if (backupInfo) {
console.log(` Backup created: ${backupInfo.backupPath}`);
}
return stats;
}
/**
* Optimize a single image using Sharp with size verification
*/
async optimizeImage(asset) {
try {
// Create a temporary file in the OS temp directory
const tempDir = os.tmpdir();
const tempFileName = `optimized-${Date.now()}-${Math.random().toString(36).substring(7)}.${this.config.optimization.images.targetFormat}`;
const outputPath = path.join(tempDir, tempFileName);
// Use Sharp for reliable image optimization
const sharpInstance = sharp(asset.fullPath);
// Get original metadata to check if resize is needed
const metadata = await sharpInstance.metadata();
const pipeline = sharpInstance.clone();
// Only resize if image is larger than max dimensions
if (metadata.width && metadata.height) {
if (metadata.width > this.config.optimization.images.maxWidth ||
metadata.height > this.config.optimization.images.maxHeight) {
pipeline.resize(this.config.optimization.images.maxWidth, this.config.optimization.images.maxHeight, { fit: "inside", withoutEnlargement: true });
}
}
// Apply format-specific optimization
if (this.config.optimization.images.targetFormat === "webp") {
pipeline.webp({
quality: this.config.optimization.images.quality,
effort: 6, // Better compression
smartSubsample: true,
});
}
else if (this.config.optimization.images.targetFormat === "jpeg") {
pipeline.jpeg({
quality: this.config.optimization.images.quality,
progressive: true,
mozjpeg: true, // Better compression
});
}
else if (this.config.optimization.images.targetFormat === "png") {
pipeline.png({
quality: this.config.optimization.images.quality,
compressionLevel: 9, // Maximum compression
progressive: true,
});
}
await pipeline.toFile(outputPath);
const newStats = await fs.stat(outputPath);
const savings = Math.round(((asset.size - newStats.size) / asset.size) * 100);
return {
success: true,
originalSize: asset.size,
newSize: newStats.size,
savings,
format: this.config.optimization.images.targetFormat,
outputPath,
};
}
catch (error) {
return {
success: false,
originalSize: asset.size,
newSize: asset.size,
savings: 0,
error: String(error),
};
}
}
/**
* Optimize a single video using FFmpeg with size verification
*/
async optimizeVideo(asset) {
try {
let bestResult = null;
for (const format of this.config.optimization.videos.targetFormats) {
// Create a temporary file in the OS temp directory
const tempDir = os.tmpdir();
const tempFileName = `optimized-${Date.now()}-${Math.random().toString(36).substring(7)}.${format}`;
const outputPath = path.join(tempDir, tempFileName);
try {
let ffmpegCommand;
if (format === "webm") {
ffmpegCommand = `ffmpeg -i "${asset.fullPath}" -c:v libvpx-vp9 -crf 30 -b:v 0 -b:a 128k -c:a libopus -vf "scale=${this.config.optimization.videos.maxWidth}:${this.config.optimization.videos.maxHeight}:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2" "${outputPath}" -y`;
}
else if (format === "mp4") {
ffmpegCommand = `ffmpeg -i "${asset.fullPath}" -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k -vf "scale=${this.config.optimization.videos.maxWidth}:${this.config.optimization.videos.maxHeight}:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2" "${outputPath}" -y`;
}
else {
continue;
}
execSync(ffmpegCommand, { stdio: "pipe" });
if (await fs.pathExists(outputPath)) {
const newStats = await fs.stat(outputPath);
const savings = Math.round(((asset.size - newStats.size) / asset.size) * 100);
const result = {
success: true,
originalSize: asset.size,
newSize: newStats.size,
savings,
format,
outputPath,
};
// Keep the best (smallest) result
if (!bestResult || newStats.size < bestResult.newSize) {
// Remove previous best if it exists
if (bestResult?.outputPath &&
bestResult.outputPath !== outputPath) {
await fs.remove(bestResult.outputPath);
}
bestResult = result;
}
else {
// Remove this one since it's not the best
await fs.remove(outputPath);
}
}
}
catch (formatError) {
console.warn(` ā ļø Failed to convert to ${format}: ${formatError}`);
}
}
return (bestResult || {
success: false,
originalSize: asset.size,
newSize: asset.size,
savings: 0,
error: "All video optimization formats failed",
});
}
catch (error) {
return {
success: false,
originalSize: asset.size,
newSize: asset.size,
savings: 0,
error: String(error),
};
}
}
/**
* Replace original asset with optimized version and update references
*/
async replaceAsset(originalAsset, optimizedPath) {
const originalExt = path.extname(originalAsset.filename);
const newExt = path.extname(optimizedPath);
if (originalExt !== newExt) {
// Extension changed, we need to rename the file and update references
const newFilename = path.parse(originalAsset.filename).name + newExt;
const newFullPath = path.join(path.dirname(originalAsset.fullPath), newFilename);
// Move optimized file to new location with new extension
await fs.move(optimizedPath, newFullPath, { overwrite: true });
// Remove original file if it exists
if (await fs.pathExists(originalAsset.fullPath)) {
await fs.remove(originalAsset.fullPath);
}
// Update all references to use new extension
await this.updateAssetReferences(originalAsset, newExt);
}
else {
// Same extension, just replace the file
await fs.move(optimizedPath, originalAsset.fullPath, { overwrite: true });
}
}
/**
* Update all references to an asset with new extension
*/
async updateAssetReferences(asset, newExtension) {
const operations = [];
const newFilename = path.parse(asset.filename).name + newExtension;
const newRelativePath = path
.join(path.dirname(asset.relativePath), newFilename)
.replace(/\\/g, "/");
console.log(` š Updating references from ${asset.filename} to ${newFilename}`);
// Find all files that might reference this asset
const sourceFiles = await fastGlob(["**/*.{ts,tsx,js,jsx,css,scss,json,md,html,vue,svelte}"], {
cwd: this.config.sourceDir,
ignore: this.config.excludePatterns,
absolute: true,
});
for (const file of sourceFiles) {
try {
const content = await fs.readFile(file, "utf8");
let updatedContent = content;
let totalReplacements = 0;
// Create multiple pattern variations to catch different reference styles
const searchPatterns = [
// Exact filename
asset.filename,
// Relative path with leading slash
"/" + asset.relativePath.replace(/\\/g, "/"),
// Relative path without leading slash
asset.relativePath.replace(/\\/g, "/").replace(/^\//, ""),
// Just the path part for imports
asset.relativePath.replace(/\\/g, "/"),
// Relative paths with parent directory navigation
"../public/" + asset.relativePath.replace(/\\/g, "/"),
"../../public/" + asset.relativePath.replace(/\\/g, "/"),
"../" + asset.relativePath.replace(/\\/g, "/"),
// URL-encoded versions
encodeURI(asset.filename),
encodeURI(asset.relativePath.replace(/\\/g, "/")),
];
const replacementPatterns = [
newFilename,
"/" + newRelativePath,
newRelativePath.replace(/^\//, ""),
newRelativePath,
// Relative paths with parent directory navigation
"../public/" + newRelativePath,
"../../public/" + newRelativePath,
"../" + newRelativePath,
encodeURI(newFilename),
encodeURI(newRelativePath),
];
// Remove duplicates
const uniquePatterns = [...new Set(searchPatterns)];
const uniqueReplacements = [...new Set(replacementPatterns)];
for (let i = 0; i < uniquePatterns.length && i < uniqueReplacements.length; i++) {
const searchPattern = uniquePatterns[i];
const replacement = uniqueReplacements[i];
if (searchPattern &&
replacement &&
updatedContent.includes(searchPattern)) {
// Use word boundaries and quotes to avoid partial matches
const escapedPattern = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Try different quote patterns
const patterns = [
new RegExp(`(['"\`])${escapedPattern}\\1`, "g"), // Quoted strings
new RegExp(`(src=["'])${escapedPattern}(["'])`, "g"), // src attributes
new RegExp(`(href=["'])${escapedPattern}(["'])`, "g"), // href attributes
new RegExp(`(import.*["'])${escapedPattern}(["'])`, "g"), // imports
new RegExp(`(\\burl\\(["']?)${escapedPattern}(["']?\\))`, "g"), // CSS url()
];
const replacements_arr = [
`$1${replacement}$1`,
`$1${replacement}$2`,
`$1${replacement}$2`,
`$1${replacement}$2`,
`$1${replacement}$2`,
];
for (let j = 0; j < patterns.length; j++) {
const regex = patterns[j];
const repl = replacements_arr[j];
const matches = updatedContent.match(regex);
if (matches) {
updatedContent = updatedContent.replace(regex, repl);
totalReplacements += matches.length;
}
}
}
}
if (totalReplacements > 0) {
await fs.writeFile(file, updatedContent);
operations.push({
filePath: path.relative(this.config.projectRoot, file),
originalPath: asset.relativePath,
newPath: newRelativePath,
replacements: totalReplacements,
success: true,
});
console.log(` ā Updated ${totalReplacements} references in ${path.relative(this.config.projectRoot, file)}`);
}
}
catch (error) {
operations.push({
filePath: path.relative(this.config.projectRoot, file),
originalPath: asset.relativePath,
newPath: newRelativePath,
replacements: 0,
success: false,
error: String(error),
});
console.warn(` ā ļø Failed to update ${path.relative(this.config.projectRoot, file)}: ${error}`);
}
}
return operations;
}
/**
* Create backup of assets before optimization
*/
async createBackup(assets) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = path.join(this.config.backupDir, `backup-${timestamp}`);
await fs.ensureDir(backupPath);
const backedUpFiles = [];
for (const asset of assets) {
const backupFilePath = path.join(backupPath, asset.relativePath);
await fs.ensureDir(path.dirname(backupFilePath));
await fs.copy(asset.fullPath, backupFilePath);
backedUpFiles.push(asset.relativePath);
}
const backupInfo = {
timestamp,
files: backedUpFiles,
backupPath,
};
// Save backup manifest
await fs.writeJson(path.join(backupPath, "manifest.json"), backupInfo, {
spaces: 2,
});
return backupInfo;
}
/**
* Remove unused assets safely
*/
async removeUnusedAssets(createBackup = true) {
if (this.results.unusedAssets.length === 0) {
console.log("ā
No unused assets to remove!");
return { removed: 0, savedSpace: 0 };
}
console.log(`\nšļø Removing ${this.results.unusedAssets.length} unused assets...`);
let backupPath;
if (createBackup) {
const backupInfo = await this.createBackup(this.results.unusedAssets);
backupPath = backupInfo.backupPath;
}
let removed = 0;
let savedSpace = 0;
for (const asset of this.results.unusedAssets) {
try {
await fs.remove(asset.fullPath);
removed++;
savedSpace += asset.size;
console.log(` ā Removed: ${asset.relativePath}`);
}
catch (error) {
console.error(` ā Failed to remove ${asset.relativePath}: ${error}`);
}
}
console.log(`\nā
Removed ${removed} assets, freed ${this.formatBytes(savedSpace)}`);
if (backupPath) {
console.log(`š¦ Backup created: ${backupPath}`);
}
return { removed, savedSpace, backupPath };
}
/**
* Generate detailed audit report
*/
async generateReport() {
console.log("š Generating report...");
// Calculate summary statistics
const unusedSize = this.results.unusedAssets.reduce((sum, asset) => sum + asset.size, 0);
const usedSize = this.results.usedAssets.reduce((sum, asset) => sum + asset.size, 0);
const optimizableSize = this.results.optimizableAssets.reduce((sum, asset) => sum + asset.size, 0);
const assetsByType = {
images: this.assets.filter((a) => a.assetType === "image"),
videos: this.assets.filter((a) => a.assetType === "video"),
};
this.results.summary = {
usedCount: this.results.usedAssets.length,
usedSize,
unusedCount: this.results.unusedAssets.length,
unusedSize,
largeUnusedCount: this.results.largeAssets.filter((a) => !a.used).length,
largeUsedCount: this.results.largeAssets.filter((a) => a.used).length,
optimizableCount: this.results.optimizableAssets.length,
optimizableSize,
potentialSavings: unusedSize,
assetsByType: {
images: {
total: assetsByType.images.length,
used: assetsByType.images.filter((a) => a.used).length,
unused: assetsByType.images.filter((a) => !a.used).length,
optimizable: assetsByType.images.filter((a) => a.optimizable && a.used).length,
},
videos: {
total: assetsByType.videos.length,
used: assetsByType.videos.filter((a) => a.used).length,
unused: assetsByType.videos.filter((a) => !a.used).length,
optimizable: assetsByType.videos.filter((a) => a.optimizable && a.used).length,
},
},
};
// Sort assets by size for better reporting
this.results.unusedAssets.sort((a, b) => b.size - a.size);
this.results.usedAssets.sort((a, b) => b.size - a.size);
this.results.optimizableAssets.sort((a, b) => b.size - a.size);
// Save detailed report
const reportData = {
timestamp: new Date().toISOString(),
config: this.config,
summary: this.results.summary,
assets: this.assets.map((asset) => ({
filename: asset.filename,
relativePath: asset.relativePath,
size: asset.size,
extension: asset.extension,
assetType: asset.assetType,
used: asset.used,
optimizable: asset.optimizable,
references: asset.references.length,
mimeType: asset.mimeType,
})),
};
await fs.writeJson(this.config.reportFile, reportData, { spaces: 2 });
console.log(` Report saved: ${this.config.reportFile}`);
}
/**
* Display audit results
*/
displayResults() {
const stats = this.results.summary;
console.log("\nš ASSET AUDIT RESULTS");
console.log("=".repeat(50));
console.log(`Total Assets: ${this.results.totalAssets} (${this.formatBytes(this.results.totalSize)})`);
console.log("\nš BY TYPE:");
console.log(`Images: ${stats.assetsByType.images.total} total (${stats.assetsByType.images.used} used, ${stats.assetsByType.images.unused} unused)`);
console.log(`Videos: ${stats.assetsByType.videos.total} total (${stats.assetsByType.videos.used} used, ${stats.assetsByType.videos.unused} unused)`);
console.log("\nš¾ USAGE:");
console.log(`Used: ${stats.usedCount} (${this.formatBytes(stats.usedSize)})`);
console.log(`Unused: ${stats.unusedCount} (${this.formatBytes(stats.unusedSize)})`);
console.log(`Large files (>500KB): ${stats.largeUsedCount} used, ${stats.largeUnusedCount} unused`);
console.log("\nšÆ OPTIMIZATION:");
console.log(`Optimizable: ${stats.optimizableCount} (${this.formatBytes(stats.optimizableSize)})`);
console.log(` - Images ā WebP: ${stats.assetsByType.images.optimizable} (GIFs excluded)`);
console.log(` - Videos ā efficient: ${stats.assetsByType.videos.optimizable}`);
if (stats.unusedCount > 0) {
console.log("\nšļø TOP UNUSED ASSETS:");
this.results.unusedAssets.slice(0, 10).forEach((asset, i) => {
console.log(`${i + 1}. ${asset.filename} - ${this.formatBytes(asset.size)}`);
});
}
if (stats.optimizableCount > 0) {
console.log("\nšÆ TOP OPTIMIZATION OPPORTUNITIES:");
this.results.optimizableAssets.slice(0, 10).forEach((asset, i) => {
console.log(`${i + 1}. ${asset.filename} - ${this.formatBytes(asset.size)}`);
});
}
}
// Helper methods
getAssetType(extension) {
if (this.config.imageExtensions.includes(extension))
return "image";
if (this.config.videoExtensions.includes(extension))
return "video";
return "image"; // fallback
}
formatBytes(bytes) {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
// Getters for results
get auditResults() {
return this.results;
}
get unusedAssets() {
return this.results.unusedAssets;
}
get optimizableAssets() {
return this.results.optimizableAssets;
}
}
//# sourceMappingURL=optimizer.js.map