diff --git a/README.md b/README.md index 94e052e..dc29046 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - Can create Avif versions of supported images (jpg/png). - Skips optimized version if output is larger than original. - Skips Avif/WebP version if output is larger than original, optimized version or smallest version of an image. -- Uses cache to skip processing of unchanged files +- Uses optional cache by default to skip processing of unchanged files ## Install @@ -109,13 +109,6 @@ Default: `true` > Ignore the optimized output if it is larger than the original file. -### cache - -Type: `boolean`
-Default: `true` - -> Skip optimizing the input if it did not change since the last run. - ### makeAvif / makeWebp Type: `object`
@@ -147,6 +140,40 @@ Default: `'optimized'` > - it is not the smallest version of the image (`'smallest'`) > - never skip (`false`) +### cache + +Type: `boolean`
+Default: `true` + +> Skip optimizing the input if it did not change since the last run. + +### cacheDir + +Type: `string`
+Default: `/node_modules/.cache/vite-plugin-imagemin//` + +> Choose the directory to use for caching. +> - Relative paths will be relative to the Vite project's **root** directory. +> - Absolute paths will be use as-is. +> - Absent/non-string value will use the default location. + +### cacheKey + +Type: `string`
+Default: `` + +> Optional string to distinguish build configs and their caches. +> +> The cache identifier is a hash of most used options including this key. +> Note: Because options like `formatFilePath` and `plugins` cannot be stringified, they will have little or no influence on the hash key and its uniqueness. + +### clearCache + +Type: `boolean`
+Default: `false` + +> Clears the cache folder before processing. + ### root Type: `string`
diff --git a/packages/core/package.json b/packages/core/package.json index cfbe326..a46557e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -61,7 +61,6 @@ "node": ">=16.0.0" }, "dependencies": { - "@file-cache/core": "^1.1.4", "chalk": "^5.2.0", "fast-glob": "^3.2.12", "fs-extra": "^11.1.1", @@ -79,4 +78,4 @@ "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^4.3.9" } -} +} \ No newline at end of file diff --git a/packages/core/src/cache.ts b/packages/core/src/cache.ts new file mode 100644 index 0000000..4dea75f --- /dev/null +++ b/packages/core/src/cache.ts @@ -0,0 +1,246 @@ +import crypto, { BinaryLike } from 'node:crypto' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { isAbsolute, resolve } from 'node:path' +import { normalizePath } from 'vite' + +import { + getPackageDirectory, + getPackageName, + isString, + smartEnsureDirs, +} from './utils' + +import type { CacheValue, ResolvedConfigOptions } from './typings' + +let cacheEnabled = false +let cacheDir = '' +let cacheKey = '' +let cacheFile = '' +let fileCacheMap = new Map() +let entryMap = new Map() + +function md5(buffer: BinaryLike): string { + return crypto.createHash('md5').update(buffer).digest('hex') +} + +export function createCacheKey(options: ResolvedConfigOptions) { + return md5( + Object.entries(options) + .filter( + ([k]) => + // Ignore these options, since they don't influence the files' content. + ![ + 'cache', + 'clearCache', + 'logByteDivider', + 'logger', + 'verbose', + ].includes(k), + ) + .sort(([ka], [kb]) => ka.localeCompare(kb)) + .map(([k, v], i) => `${i}_${k}_${v}`) + .join('|'), + ) +} + +async function initCacheDir(rootDir: string, options: ResolvedConfigOptions) { + cacheKey = createCacheKey(options) + + const packageDir = normalizePath(getPackageDirectory()) + const packageName = getPackageName(packageDir) + + if (isString(options.cacheDir)) { + cacheDir = normalizePath( + isAbsolute(options.cacheDir) + ? options.cacheDir + : resolve(rootDir, options.cacheDir), + ) + } else { + cacheDir = `${packageDir}/node_modules/.cache/vite-plugin-imagemin` + } + + // cacheDir = cacheDir + `/${packageName}/${cacheKey}/` + cacheDir = cacheDir + `/${packageName}/` + + if (options.clearCache) { + await rm(cacheDir.slice(0, -1), { recursive: true, force: true }) + } + + if (!cacheEnabled) { + return + } + + await mkdir(cacheDir.slice(0, -1), { recursive: true }) +} + +async function initCacheMaps() { + cacheFile = `${cacheDir}/contents-${cacheKey}.json` + + try { + const json = JSON.parse(await readFile(cacheFile, 'utf-8')) + entryMap = new Map(Object.entries(json)) + } catch { + entryMap = new Map() + } + + fileCacheMap = new Map(entryMap) +} + +async function checkAndUpdate({ + fileName, + directory, + stats, + buffer, + restoreTo, +}: { + fileName: string + directory?: string + stats?: Omit + buffer?: Buffer + restoreTo?: string | false +}): Promise<{ + changed?: boolean + value?: CacheValue + error?: Error +}> { + if (cacheEnabled) { + if (!fileName) { + return { + error: new Error('Missing filename'), + } + } + + const filePath = (directory ?? cacheDir) + fileName + + if (!buffer) { + try { + buffer = await readFile(filePath) + } catch (error) { + return { + error: error as Error, + } + } + } + + const hash = md5(buffer) + const cacheValue = fileCacheMap.get(filePath) + if (cacheValue && cacheValue.hash === hash) { + if (restoreTo) { + try { + await writeFile(restoreTo + fileName, buffer) + } catch (error) { + return { + error: error as Error, + } + } + } + + return { + changed: false, + value: cacheValue, + } + } + + entryMap.set(filePath, { + hash, + oldSize: stats?.oldSize ?? 1, + newSize: stats?.newSize ?? 1, + }) + } + + return { + changed: true, + } +} + +export const FileCache = { + init: async (options: ResolvedConfigOptions, rootDir: string) => { + cacheEnabled = options.cache !== false + + await initCacheDir(rootDir, options) + + if (!cacheEnabled) { + return + } + + await initCacheMaps() + }, + + prepareDirs: (filePaths: string[]): void => { + if (!cacheEnabled) { + return + } + + smartEnsureDirs(filePaths.map(file => cacheDir + file)) + }, + + checkAndUpdate: checkAndUpdate, + + update: async ({ + fileName, + buffer, + directory, + stats, + }: { + fileName: string + buffer: Buffer + directory?: string + stats?: Omit + }) => { + if (!cacheEnabled) { + return false + } + + if (!fileName) { + return { + error: new Error('Missing filename'), + } + } + + if (!buffer) { + return { + error: new Error('Missing content for cache file'), + } + } + + const filePath = (directory ?? cacheDir) + fileName + + try { + await writeFile(filePath, buffer) + } catch (error) { + return { + error: new Error( + `Could not write cache file [${(error as Error).message}]`, + ), + } + } + + return await checkAndUpdate({ + fileName, + directory, + buffer, + stats, + }) + }, + + reconcile: async () => { + if (!cacheEnabled) { + return true + } + + try { + await writeFile( + cacheFile, + JSON.stringify(Object.fromEntries(entryMap)), + 'utf-8', + ) + + fileCacheMap = new Map(entryMap) + + return true + } catch (error) { + // console.error('Cache reconcile has failed', error) + return false + } + }, +} diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 67c59cb..954ab42 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -642,7 +642,7 @@ describe('processFile', () => { }) }) - it('returns error object if OUTPUT PROCESS error (Error & warn)', async () => { + it('returns error object if OUTPUT PROCESS error (Error & string)', async () => { await expect( // @ts-expect-error missing properties are used after expected error processFile({ @@ -655,9 +655,9 @@ describe('processFile', () => { // optimization: 4, // strip: 'safe', // }), - () => Promise.reject(new Error('Test')), + () => Promise.reject(new Error('Test error processing file')), // () => { - // throw new Error('Test') + // throw new Error('Test error processing file') // }, ], skipIfLarger: false, @@ -668,7 +668,9 @@ describe('processFile', () => { ).resolves.toContainEqual({ status: 'rejected', reason: expect.objectContaining({ - error: expect.stringMatching(/^Error processing file/), + error: expect.stringMatching( + /^Error processing file:\s+Test error processing file/, + ), }), }) @@ -684,9 +686,9 @@ describe('processFile', () => { // optimization: 4, // strip: 'safe', // }), - () => Promise.reject('WARN: Test error processing file'), + () => Promise.reject('Test error processing file'), // () => { - // throw 'WARN: Test error processing file' + // throw 'Test error processing file' // }, ], skipIfLarger: false, @@ -697,7 +699,9 @@ describe('processFile', () => { ).resolves.toContainEqual({ status: 'rejected', reason: expect.objectContaining({ - error: 'Test error processing file', + error: expect.stringMatching( + /^Error processing file:\s*Test error processing file/, + ), }), }) }) @@ -1368,7 +1372,7 @@ describe('logErrors', () => { * dist/images/opaque-1.png.avif */ -// TODO: add tests for usage with cache +// TODO: add tests for cache usage and its options // TODO: expand after-build checks describe.skipIf(skipBuilds)('viteImagemin', () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index da06f29..6f573e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,48 +1,40 @@ -import path from 'node:path' -import { - existsSync, - mkdirSync, - lstatSync, - readdirSync, - unlinkSync /*, rmSync */, - copyFileSync, -} from 'node:fs' -import { readFile, writeFile } from 'node:fs/promises' -import { Buffer } from 'node:buffer' -import { performance } from 'node:perf_hooks' import chalk from 'chalk' -import { normalizePath, createFilter } from 'vite' import imagemin from 'imagemin' import isAPNG from 'is-apng' -import { createCache } from '@file-cache/core' +import { Buffer } from 'node:buffer' +import { lstatSync, readdirSync, unlinkSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { createFilter, normalizePath } from 'vite' +import { FileCache } from './cache' import { - isFunction, + escapeRegExp, isBoolean, - isString, - isObject, isFilterPattern, - escapeRegExp, + isFunction, + isObject, + isString, smartEnsureDirs, } from './utils' import type { PluginOption, ResolvedConfig } from 'vite' -import type { CacheInterface } from '@file-cache/core/mjs/CacheInterface' import type { ConfigOptions, - ResolvedConfigOptions, - ResolvedPluginsConfig, - ResolvedMakeConfigOptions, - PluginsConfig, + ErroredFile, + FormatProcessedFileParams, Logger, - Stack, + PluginsConfig, ProcessFileParams, + ProcessFileReturn, + ProcessResult, ProcessedFile, - ErroredFile, ProcessedResults, - ProcessResultWhenOutput, - ProcessResult, - ProcessFileReturn, + ResolvedConfigOptions, + ResolvedMakeConfigOptions, + ResolvedPluginsConfig, + Stack, } from './typings' // export const pathIsWithin = (parentPath: string, childPath: string) => { @@ -151,6 +143,9 @@ export const parseOptions = ( ? _options.skipIfLarger : true, cache: isBoolean(_options?.cache) ? _options.cache : true, + cacheDir: isString(_options?.cacheDir) ? _options.cacheDir : undefined, + cacheKey: isString(_options?.cacheDir) ? _options.cacheDir : '', + clearCache: isBoolean(_options?.clearCache) ? _options.clearCache : false, plugins, makeAvif, makeWebp, @@ -192,6 +187,45 @@ export function getAllFiles(dir: string, logger: Logger): string[] { return files } +export function formatProcessedFile({ + oldPath, + newPath, + oldSize, + newSize, + duration, + fromCache, + precisions, + bytesDivider, + sizeUnit, +}: FormatProcessedFileParams): ProcessedFile { + const ratio = newSize / oldSize - 1 + + return { + oldPath, + newPath, + oldSize, + newSize, + ratio, + duration, + oldSizeString: `${(oldSize / bytesDivider).toFixed( + precisions.size, + )} ${sizeUnit}`, + newSizeString: `${(newSize / bytesDivider).toFixed( + precisions.size, + )} ${sizeUnit}`, + ratioString: `${ratio > 0 ? '+' : ratio === 0 ? ' ' : ''}${( + ratio * 100 + ).toFixed(precisions.ratio)} %`, + durationString: fromCache + ? 'Cache' + : `${duration.toFixed(precisions.duration)} ms`, + fromCache, + optimizedDeleted: false as const, + avifDeleted: false as const, + webpDeleted: false as const, + } +} + export async function processFile({ filePathFrom, fileToStack = [], @@ -199,18 +233,14 @@ export async function processFile({ precisions, bytesDivider, sizeUnit, - cacheDir = '', - cache = null, }: ProcessFileParams): ProcessFileReturn { - // const start = performance.now() - if (!filePathFrom?.length) { return Promise.reject({ oldPath: filePathFrom, newPath: '', error: 'Empty filepath', errorType: 'error', - }) as Promise + }) } if (!fileToStack?.length) { @@ -219,215 +249,133 @@ export async function processFile({ newPath: '', error: 'Empty to-stack', errorType: 'error', - }) as Promise + }) } - if (cache) { - // Check if input file hasn't changed - const inputFileCache = await cache.getAndUpdateCache(baseDir + filePathFrom) - - if (!inputFileCache.error && !inputFileCache.changed) { - // Check if output files are in cache and use them if they haven't changed - const outputFilesExist = await Promise.allSettled( - fileToStack.map( - item => - new Promise((resolve, reject) => - cache - .getAndUpdateCache(cacheDir + item.toPath) - .then(outputFileCache => { - if (!outputFileCache.error && !outputFileCache.changed) { - copyFileSync(cacheDir + item.toPath, baseDir + item.toPath) - if (existsSync(baseDir + item.toPath)) { - resolve(true) - } - } - reject( - outputFileCache.error - ? `Error while checking cache [${outputFileCache.error.message}]` - : 'Could not copy cached files', - ) - }) - .catch(reject), - ), - ), - ) + let oldBuffer: Buffer + let oldSize = 0 - if (outputFilesExist.every(p => p.status === 'fulfilled')) { - return Promise.reject({ - oldPath: filePathFrom, - newPath: '', - error: '', - errorType: 'cache', - }) as Promise - } - } + try { + oldBuffer = await readFile(baseDir + filePathFrom) + oldSize = oldBuffer.byteLength + } catch (error) { + return Promise.reject({ + oldPath: filePathFrom, + newPath: '', + error: `Error reading file [${(error as Error).message}]`, + errorType: 'error', + }) } - let oldBuffer: Buffer - let oldSize = 0 + if (filePathFrom.match(/\.a?png$/i) && isAPNG(oldBuffer)) { + return Promise.reject({ + oldPath: filePathFrom, + newPath: '', + error: `Animated PNGs not supported`, + errorType: 'skip', + }) + } - return readFile(baseDir + filePathFrom) - .then(buffer => { - const start = performance.now() - - oldBuffer = buffer - oldSize = oldBuffer.byteLength - - // TODO: handle elsewhere with - // (to-make-myself) `imagemin-apngquant` plugin - // `imagemin-apngquant` uses: - // - is-apng - // - apngdis-bin - // - (concat frames to strip) [see script at: ...?] - // - optimize using: - // - user-supplied plugin + options - // ? pngquant fallback - // - apng-asm (strip to apng) - if (filePathFrom.match(/\.a?png$/i) && isAPNG(buffer)) { - /** - * TODO: - * - apngdis the input APNG, then: - * - if output path .png: make strip + run plugins + apngasm from strip + output - * - if output path .webp: plugin each frame + run frames through webpmux-bin + single .webp output - * - * @see C:\Users\phili\Desktop\APNG\test\index.js - */ - throw new Error('SKIP: Animated PNGs not supported') - - // Copy input to tmp dir .png - - // apngdis-bin: this file to frame `.png`s - - // convert/optimize each .png to .webp - - // webpmux-bin: combine all .webp to final .webp - - // // const fileString = buffer.toString('utf8') - // const idatIdx = buffer.indexOf('IDAT') - // const actlIdx = buffer.indexOf('acTL') - // if ( - // filePathFrom.endsWith('.png') && - // idatIdx > 0 && - // actlIdx > 0 && - // idatIdx > actlIdx - // ) { - // throw new Error('SKIP: Animated PNGs not supported') - // } - } + const inputFileCacheStatus = await FileCache.checkAndUpdate({ + fileName: filePathFrom, + directory: baseDir, + buffer: oldBuffer, + restoreTo: false, + }) + const skipCache = Boolean( + inputFileCacheStatus?.error || inputFileCacheStatus?.changed, + ) - return Promise.allSettled( - fileToStack.map(item => { - let newBuffer: Buffer - let newSize = 0 + const start = performance.now() - const filePathTo = item.toPath + return Promise.allSettled( + fileToStack.map(async item => { + const filePathTo = item.toPath - return imagemin - .buffer(oldBuffer, { plugins: item.plugins }) - .catch(e => - Promise.reject( - // e.message ? `Error processing file [${e.message}]` : e, - e.message ? `Error processing file:\n${e.message}` : e, - ), - ) - .then(buffer2 => { - newBuffer = buffer2 - newSize = newBuffer.byteLength - - if (false !== item.skipIfLarger && newSize > oldSize) { - // throw new Error('SKIP: Output is larger') - if (filePathTo === filePathFrom) { - // NOTE: - // If this optimized file is larger than original - // and this is not a WebP/Avif version; - // don't overwrite the original, but do continue (to have it log with the rest). - return Promise.resolve() - } - } + if (!skipCache) { + const outputFileCacheStatus = await FileCache.checkAndUpdate({ + fileName: filePathTo, + restoreTo: baseDir, + }) + if (!outputFileCacheStatus?.error && !outputFileCacheStatus?.changed) { + return Promise.resolve( + formatProcessedFile({ + oldPath: filePathFrom, + newPath: filePathTo, + oldSize: outputFileCacheStatus?.value?.oldSize ?? 1, + newSize: outputFileCacheStatus?.value?.newSize ?? 1, + duration: 0, + fromCache: true, + precisions, + bytesDivider, + sizeUnit, + }), + ) + } + } - // const newDirectory = path.dirname(baseDir + filePathTo) - // await ensureDir(newDirectory, 0o755) - return writeFile(baseDir + filePathTo, newBuffer) - }) - .catch(e => { - if (e?.message) { - if (e.message.startsWith('SKIP: ')) { - e = e.message - } else { - e = `Error writing file [${e.message}]` - } - } - return Promise.reject(e) - }) - .then(async () => { - // Add to/update in cache - if (cache) { - copyFileSync(baseDir + filePathTo, cacheDir + filePathTo) - await cache.getAndUpdateCache(cacheDir + filePathTo) - } + let newBuffer: Buffer + let newSize = 0 - const duration = performance.now() - start - const ratio = newSize / oldSize - 1 - return Promise.resolve({ - oldPath: filePathFrom, - newPath: filePathTo, - oldSize, - newSize, - ratio, - duration, - oldSizeString: `${(oldSize / bytesDivider).toFixed( - precisions.size, - )} ${sizeUnit}`, - newSizeString: `${(newSize / bytesDivider).toFixed( - precisions.size, - )} ${sizeUnit}`, - ratioString: `${ratio > 0 ? '+' : ratio === 0 ? ' ' : ''}${( - ratio * 100 - ).toFixed(precisions.ratio)} %`, - durationString: `${duration.toFixed(precisions.duration)} ms`, - }) - }) - .catch(error => { - let errorType = 'error' - if (error.startsWith('SKIP: ')) { - error = error.slice(6) - errorType = 'skip' - } else if (error.startsWith('WARN: ')) { - error = error.slice(6) - errorType = 'warning' - } + try { + newBuffer = await imagemin.buffer(oldBuffer, { plugins: item.plugins }) + newSize = newBuffer.byteLength + } catch (error) { + return Promise.reject({ + oldPath: filePathFrom, + newPath: filePathTo, + error: `Error processing file:\n${ + (error as Error)?.message ?? error + }`, + errorType: 'error', + }) + } - return Promise.reject({ - oldPath: filePathFrom, - newPath: filePathTo, - error, - errorType, - }) - }) - }), - ) as Promise - }) - .catch(e => { - let errorType = 'error' - // if (e?.message) { - if (e.message.startsWith('SKIP: ')) { - e = e.message.slice(6) - errorType = 'skip' - // } else if (e.message.startsWith('WARN: ')) { - // e = e.message.slice(6) - // errorType = 'warning' - } else { - e = `Error reading file [${e.message}]` + /** + * NOTE: Don't overwrite the original if the optimized content is larger, + * the option is set and this doesn't concern a WebP/Avif version. + */ + if ( + newSize <= oldSize || + filePathFrom !== filePathTo || + false === item.skipIfLarger + ) { + try { + await writeFile(baseDir + filePathTo, newBuffer) + } catch (error) { + return Promise.reject({ + oldPath: filePathFrom, + newPath: filePathTo, + error: `Error writing file [${(error as Error).message}]`, + errorType: 'error', + }) + } } - // } - return Promise.reject({ - oldPath: filePathFrom, - newPath: '', - error: e, - errorType, - }) as Promise - }) + await FileCache.update({ + fileName: filePathTo, + buffer: newBuffer, + stats: { + oldSize, + newSize, + }, + }) + + return Promise.resolve( + formatProcessedFile({ + oldPath: filePathFrom, + newPath: filePathTo, + oldSize, + newSize, + duration: performance.now() - start, + fromCache: false, + precisions, + bytesDivider, + sizeUnit, + }), + ) + }), + ) } export function processResults(results: ProcessResult[]): ProcessedResults { @@ -565,7 +513,7 @@ export function logResults( // Skipped file logArray.push( // Filename - chalk.dim(basenameTo), + file.fromCache ? chalk.blue.dim(basenameTo) : chalk.dim(basenameTo), ' '.repeat( maxPathLength - bulletLength - file.newPath.length + spaceLength, ), @@ -593,7 +541,9 @@ export function logResults( logArray.push( // Filename file.ratio < 0 - ? chalk.green(basenameTo) + ? file.fromCache + ? chalk.blue(basenameTo) + : chalk.green(basenameTo) : file.ratio > 0 ? chalk.yellow(basenameTo) : basenameTo, @@ -681,9 +631,6 @@ export function logErrors( file.error, ) break - case 'cache': - logArray.push(chalk.black.bgBlue(' CACHED '), ' ', file.error) - break case 'warning': logArray.push( chalk.bgYellow(' WARNING '), @@ -725,9 +672,6 @@ export function logErrors( file.error, ) break - case 'cache': - logArray.push(chalk.black.bgBlue(' CACHED '), ' ', file.error) - break case 'warning': logArray.push( chalk.bgYellow(' WARNING '), @@ -791,9 +735,6 @@ export default function viteImagemin(_options: ConfigOptions): PluginOption { let hadFilesToProcess = false // const mtimeCache = new Map() - let cache: CacheInterface - let cacheDir = '' - return { name: 'vite-plugin-imagemin', enforce: 'post', @@ -812,7 +753,6 @@ export default function viteImagemin(_options: ConfigOptions): PluginOption { outDir = normalizePath(path.resolve(rootDir, config.build.outDir)) assetsDir = normalizePath(path.resolve(outDir, config.build.assetsDir)) // publicDir = normalizePath(path.resolve(rootDir, config.publicDir)) - cacheDir = `${rootDir}/node_modules/.cache/vite-plugin-imagemin/` // const emptyOutDir = config.build.emptyOutDir || pathIsWithin(rootDir, outDir) @@ -825,20 +765,7 @@ export default function viteImagemin(_options: ConfigOptions): PluginOption { logger.info('') - // Create cache - if (options.cache !== false) { - cache = (await createCache({ - // noCache: options.cache === false, - mode: 'content', - keys: [ - () => { - return JSON.stringify(options) - }, - ], - })) as CacheInterface - - mkdirSync(cacheDir.slice(0, -1), { recursive: true }) - } + await FileCache.init(options, rootDir) const processDir = onlyAssets ? assetsDir : outDir const baseDir = `${rootDir}/` @@ -932,9 +859,7 @@ export default function viteImagemin(_options: ConfigOptions): PluginOption { // Ensure all destination (sub)directories are present smartEnsureDirs(toPaths.map(file => baseDir + file)) - if (options.cache !== false) { - smartEnsureDirs(toPaths.map(file => cacheDir + file)) - } + FileCache.prepareDirs(toPaths) // Process stack const { @@ -953,8 +878,6 @@ export default function viteImagemin(_options: ConfigOptions): PluginOption { precisions, bytesDivider, sizeUnit, - cacheDir, - cache, }), ), ) as Promise @@ -1068,10 +991,7 @@ export default function viteImagemin(_options: ConfigOptions): PluginOption { logResults(processedFiles[k], logger, maxLengths) }) - // Write cache state to file for persistence - if (options.cache !== false) { - await cache.reconcile() - } + FileCache.reconcile() Object.keys(erroredFiles) .sort((a, b) => a.localeCompare(b)) // TODO: sort by (sub)folder and depth? diff --git a/packages/core/src/typings.d.ts b/packages/core/src/typings.d.ts index e527c29..7350115 100644 --- a/packages/core/src/typings.d.ts +++ b/packages/core/src/typings.d.ts @@ -86,6 +86,27 @@ export interface ConfigOptions { */ cache?: boolean + /** + * Path of the directory to use for caching. + * Either: + * - relative path to Vite's root + * - absolute path + * @default /node_modules/.cache/vite-plugin-imagemin + */ + cacheDir?: string + + /** + * Optional string to distinguish build configs and their caches. + * @default '' + */ + cacheKey?: string + + /** + * Force-clear the cache. + * @default false + */ + clearCache?: boolean + /** * Only use optimized contents if smaller than original. * @default true @@ -136,6 +157,9 @@ export interface ResolvedConfigOptions { verbose: boolean skipIfLarger: boolean cache: boolean + cacheDir?: string + clearCache: boolean + cacheKey: string plugins: ResolvedPluginsConfig makeAvif: false | ResolvedMakeConfigOptions makeWebp: false | ResolvedMakeConfigOptions @@ -159,6 +183,22 @@ export type Stack = { [fromPath: string]: StackItem[] } +export type FormatProcessedFileParams = { + oldPath: string + newPath: string + oldSize: number + newSize: number + duration: number + fromCache: boolean + precisions: { + size: number + ratio: number + duration: number + } + bytesDivider: number + sizeUnit: string +} + export type ProcessFileParams = { baseDir?: string filePathFrom: string @@ -188,6 +228,7 @@ export type ProcessedFile = { optimizedDeleted: ResolvedMakeConfigOptions['skipIfLargerThan'] avifDeleted: ResolvedMakeConfigOptions['skipIfLargerThan'] webpDeleted: ResolvedMakeConfigOptions['skipIfLargerThan'] + fromCache: boolean } export type ErroredFile = { @@ -237,3 +278,9 @@ export type ProcessResult = | IPromiseRejectedResult export type ProcessFileReturn = Promise + +export type CacheValue = { + hash: string + oldSize: number + newSize: number +} diff --git a/packages/core/src/utils.test.ts b/packages/core/src/utils.test.ts index 3b879b5..4758837 100644 --- a/packages/core/src/utils.test.ts +++ b/packages/core/src/utils.test.ts @@ -213,3 +213,6 @@ describe('smartEnsureDirs', () => { }) }) }) + +// TODO: add tests for getPackageDirectory and getPackageName +// TODO: create `cache.test.ts` with tests for cache functions diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 58a71c2..d6cc6f7 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,4 +1,6 @@ -import { existsSync, mkdirSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, parse, resolve } from 'node:path' +import { cwd } from 'node:process' import { FilterPattern } from 'vite' // import { ensureDirSync } from 'fs-extra' @@ -66,3 +68,38 @@ export function smartEnsureDirs(filePaths: string[], mode = 0o0755): string[] { return dir }) } + +export function getPackageDirectory() { + let filePath = '' + let directory = resolve(cwd()) + const { root } = parse(directory) + const stopAt = resolve(directory, root) + + while (directory && directory !== stopAt && directory !== root) { + filePath = join(directory, 'package.json') + + try { + const stats = statSync(filePath, { throwIfNoEntry: false }) + if (stats?.isFile()) { + break + } + } catch { + /* ignore */ + } + + directory = dirname(directory) + } + + return filePath && dirname(filePath) +} + +export const getPackageName = (pkgPath: string) => { + const pkgFile = join(pkgPath, 'package.json') + try { + const pkg = readFileSync(pkgFile, 'utf8') + const pkgJson = JSON.parse(pkg) + return pkgJson.name + } catch { + return 'file-cache' + } +} diff --git a/packages/playground/vite.config.ts b/packages/playground/vite.config.ts index 3eddf1d..fed26c7 100644 --- a/packages/playground/vite.config.ts +++ b/packages/playground/vite.config.ts @@ -52,6 +52,8 @@ export default defineConfig({ // skipIfLargerThan: 'smallest', }, // cache: false, + // clearCache: true, + // cacheDir: 'cache', }), ], })