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',
}),
],
})