image-guard 5.4.2 → 5.5.0

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/README.md CHANGED
@@ -121,6 +121,8 @@ Image Guard takes an optional path to the directory to process—`npx image-guar
121
121
 
122
122
  * `--staged` (recommended with automated use) triggers a mode that watches PNG, JPG, GIF, WebP, and AVIF files (and HEIC/HEIF, when `--heic-to-avif` is used) from staged changes (`git diff --cached`) and only processes those files—that approach makes Image Guard more efficient in operation. Because the file set comes from Git, `--staged` can’t be combined with a path.
123
123
 
124
+ * `--no-cache` processes all images, including those recorded as already processed (see [“Record of Processed Images”](#record-of-processed-images)).
125
+
124
126
  * `--help`/`-h` lists the arguments and options.
125
127
 
126
128
  * `--version`/`-V` prints the version number.
@@ -146,11 +148,17 @@ Automated compression works by monitoring whether a given [change list](https://
146
148
 
147
149
  Through this approach, though glossed over here, Image Guard makes up for what’s missing or complicated in other packages, namely easy, near-riskless, automatable, resource-friendly in-repo optimization.
148
150
 
151
+ ### Record of Processed Images
152
+
153
+ To keep repeat runs fast, Image Guard remembers which images it has already processed (compressed, or found not to shrink any further) by their content, and skips them in later runs. This also protects JPG and GIF images from being re-encoded over and over. An image is processed again as soon as its content changes, and all images are when the compression settings or the underlying image libraries change (as through an update).
154
+
155
+ The record is kept in node_modules/.cache/image-guard of the project being processed, or—where there is no node_modules folder, as is common with `npx`—in the per-user cache folder (~/Library/Caches/image-guard on macOS, %LOCALAPPDATA%\image-guard\Cache on Windows, and ~/.cache/image-guard elsewhere). The `IMAGE_GUARD_CACHE_DIR` environment variable sets a different folder—for example, to persist the record in CI. Deleting the folder, or using `--no-cache`, makes Image Guard process all images again.
156
+
149
157
  ## Why Use Image Guard?
150
158
 
151
159
  You use Image Guard when you need a simple, automatable, robust solution to compress images in a way that limits unnecessary image payload right from the start, in your repositories, and that reduces the risk that entirely uncompressed images go into production.
152
160
 
153
- As Image Guard compresses near-losslessly, there’s little risk of quality issues from compression. (Lossless compression is not possible for every image format, however, so there’s a risk when excessively iterating over the same images. Doing so may eventually degrade quality.)
161
+ As Image Guard compresses near-losslessly, there’s little risk of quality issues from compression. (Lossless compression is not possible for every image format, however, so there’s a risk when excessively iterating over the same images. Doing so may eventually degrade quality. The [record of processed images](#record-of-processed-images) prevents this, unless it’s bypassed or cleared.)
154
162
 
155
163
  ## What Does Image Guard _Not_ Do?
156
164
 
@@ -168,7 +176,7 @@ To check how a change affects speed, memory, and compression, run the benchmark
168
176
  npm run benchmark -- ~/Projects
169
177
  ```
170
178
 
171
- The benchmark copies a deterministic sample of the images (honoring .gitignore) to a temporary folder, runs Image Guard on fresh copies of it several times, and reports the median time, peak memory, and bytes saved. Your images are not modified.
179
+ The benchmark copies a deterministic sample of the images (honoring .gitignore) to a temporary folder, runs Image Guard on fresh copies of it several times (each without a record of processed images), and reports the median time, peak memory, and bytes saved. Your images are not modified.
172
180
 
173
181
  * `--runs <n>` sets the number of runs (default: 3).
174
182
  * `--sample <n>` sets the approximate sample size (default: 300). Each format is sampled in proportion to its share, with at least 10 files per format where available.
package/package.json CHANGED
@@ -60,5 +60,5 @@
60
60
  "test": "node --test bin/image-guard.test.js benchmark/benchmark.test.js"
61
61
  },
62
62
  "type": "module",
63
- "version": "5.4.2"
63
+ "version": "5.5.0"
64
64
  }
package/src/cache.js ADDED
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { styleStderr } from './utils.js'
5
+
6
+ // Records made with other settings or encoder versions are deleted once unused for this long
7
+ const MAX_AGE_STALE = 30 * 24 * 60 * 60 * 1000
8
+
9
+ // Matches record files only, as the folder may be shared (see `IMAGE_GUARD_CACHE_DIR`)
10
+ const PATTERN_RECORD = /^[0-9a-f]{16}\.txt$/
11
+
12
+ // The nearest project’s node_modules/.cache, else the per-user cache folder
13
+ export function findDirCache(dirStart) {
14
+ if (process.env.IMAGE_GUARD_CACHE_DIR) return path.resolve(process.env.IMAGE_GUARD_CACHE_DIR)
15
+ for (let current = path.resolve(dirStart); ; current = path.dirname(current)) {
16
+ if (fs.existsSync(path.join(current, 'package.json'))) {
17
+ const dirModules = path.join(current, 'node_modules')
18
+ if (fs.existsSync(dirModules)) return path.join(dirModules, '.cache', 'image-guard')
19
+ break
20
+ }
21
+ if (path.dirname(current) === current) break
22
+ }
23
+ if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Caches', 'image-guard')
24
+ if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'image-guard', 'Cache')
25
+ return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'image-guard')
26
+ }
27
+
28
+ // Remembers images that compression can’t shrink any further (as format and
29
+ // content hash); the fingerprint keeps records apart per compression settings
30
+ // and encoder version, so changing either processes all images again
31
+ export function createCache(dirStart, fingerprint) {
32
+ const dirCache = findDirCache(dirStart)
33
+ const fileRecord = path.join(dirCache, `${fingerprint}.txt`)
34
+ const added = []
35
+ let entries = new Set()
36
+ let hits = 0
37
+
38
+ try {
39
+ entries = new Set(fs.readFileSync(fileRecord, 'utf8').split('\n').filter(Boolean))
40
+ } catch (err) {
41
+ if (err.code !== 'ENOENT') console.warn(styleStderr('yellow', `Could not read the record of processed images (${err.message})`))
42
+ }
43
+
44
+ return {
45
+ has(entry) {
46
+ const found = entries.has(entry)
47
+ if (found) hits++
48
+ return found
49
+ },
50
+ add(entry) {
51
+ if (entries.has(entry)) return
52
+ entries.add(entry)
53
+ added.push(entry)
54
+ },
55
+ get hits() {
56
+ return hits
57
+ },
58
+ save() {
59
+ if (added.length === 0) {
60
+ // Keeps runs with other settings from deleting a record that’s still in use
61
+ if (hits > 0) {
62
+ try {
63
+ const now = new Date()
64
+ fs.utimesSync(fileRecord, now, now)
65
+ } catch {
66
+ // Best-effort—at worst, the images are processed once more
67
+ }
68
+ }
69
+ return
70
+ }
71
+ try {
72
+ fs.mkdirSync(dirCache, { recursive: true })
73
+ // Appending (rather than rewriting) keeps what concurrent runs add
74
+ fs.appendFileSync(fileRecord, added.map(entry => `${entry}\n`).join(''))
75
+ } catch (err) {
76
+ console.warn(styleStderr('yellow', `Could not save the record of processed images (${err.message})`))
77
+ return
78
+ }
79
+ let names = []
80
+ try {
81
+ names = fs.readdirSync(dirCache)
82
+ } catch {
83
+ // Nothing to clean up if the folder can’t be read
84
+ }
85
+ for (const name of names) {
86
+ const file = path.join(dirCache, name)
87
+ if (file === fileRecord || !PATTERN_RECORD.test(name)) continue
88
+ try {
89
+ if (Date.now() - fs.statSync(file).mtimeMs > MAX_AGE_STALE) fs.rmSync(file)
90
+ } catch (err) {
91
+ // Another run may have deleted it already
92
+ if (err.code !== 'ENOENT') console.warn(styleStderr('yellow', `Could not delete outdated record ${file} (${err.message})`))
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import fsSync from 'node:fs'
7
7
  import os from 'node:os'
8
8
  import path from 'node:path'
9
9
  import sharp from 'sharp'
10
+ import { createCache } from './cache.js'
10
11
  import { createGitignoreFilter } from './gitignore.js'
11
12
  import { styleStderr, utils } from './utils.js'
12
13
 
@@ -31,6 +32,7 @@ export async function runImageGuard() {
31
32
  quiet: { type: 'boolean', short: 'q', default: false },
32
33
  dry: { type: 'boolean', short: 'd', default: false },
33
34
  staged: { type: 'boolean', default: false },
35
+ 'no-cache': { type: 'boolean', default: false },
34
36
  help: { type: 'boolean', short: 'h', default: false },
35
37
  version: { type: 'boolean', short: 'V', default: false }
36
38
  }
@@ -59,6 +61,7 @@ Options:
59
61
  -q, --quiet Print only the final summary
60
62
  -d, --dry Show what would change without writing any files
61
63
  --staged Process only images staged in Git (not combinable with a directory)
64
+ --no-cache Process images even if recorded as already processed
62
65
  -h, --help Show this help
63
66
  -V, --version Show the version number`)
64
67
  return
@@ -89,11 +92,20 @@ Options:
89
92
  throw setupError(`Not a directory: ${dir}`)
90
93
  }
91
94
 
95
+ // Remembers images that can’t be compressed further, so later runs can skip them
96
+ const cache = argv['no-cache'] ? undefined : createCache(argv.staged ? '.' : dir, utils.fingerprint)
97
+
98
+ const noteCached = () => {
99
+ const hits = cache?.hits ?? 0
100
+ if (hits === 0) return ''
101
+ return hits === 1 ? ' 1 image was skipped as already processed.' : ` ${hits} images were skipped as already processed.`
102
+ }
103
+
92
104
  // Share status
93
105
  const summary = (run, includesConversion = false) => {
94
106
  if (run) {
95
107
  const action = includesConversion ? 'compression and conversion' : 'compression'
96
- console.info(styleText(['bold'], `\nDefensive base ${action} completed. You saved ${utils.sizeReadable(savedKB)}.`))
108
+ console.info(styleText(['bold'], `\nDefensive base ${action} completed. You saved ${utils.sizeReadable(savedKB)}.${noteCached()}`))
97
109
  } else {
98
110
  const what = includesConversion ? 'images to compress or convert' : 'images to compress'
99
111
  console.info(styleText(['bold'], `There were no ${what}.`))
@@ -181,7 +193,7 @@ Options:
181
193
  const compress = async (files, dry) => {
182
194
  if (files.length === 0) return false
183
195
 
184
- const tasks = files.map(file => limit(() => utils.compression(file, dry, argv.quiet)))
196
+ const tasks = files.map(file => limit(() => utils.compression(file, dry, argv.quiet, cache)))
185
197
  const results = await Promise.allSettled(tasks)
186
198
 
187
199
  const { hadFailures, addedKB } = processResults(results)
@@ -309,11 +321,13 @@ Options:
309
321
  }
310
322
  }
311
323
 
324
+ if (!argv.dry) cache?.save()
325
+
312
326
  if (hadFailures) {
313
327
  process.exitCode = 1
314
328
  if (totalFiles > 0) {
315
329
  const action = doConversion ? 'compression and conversion' : 'compression'
316
- const savings = savedKB > 0 ? ` You saved ${utils.sizeReadable(savedKB)}.` : ''
330
+ const savings = `${savedKB > 0 ? ` You saved ${utils.sizeReadable(savedKB)}.` : ''}${noteCached()}`
317
331
  console.info(styleText(['bold'], `\nDefensive base ${action} partially completed (some tasks failed).${savings}`))
318
332
  } else {
319
333
  summary(false, doConversion)
package/src/utils.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // This file, which had been forked from imagemin-merlin, was modified for image-guard: https://github.com/sumcumo/imagemin-merlin/compare/master...j9t:master
2
2
 
3
+ import { createHash } from 'node:crypto'
3
4
  import { promises as fs } from 'node:fs'
4
5
  import path from 'node:path'
5
6
  import sharp from 'sharp'
@@ -8,6 +9,42 @@ import decode from 'heic-decode'
8
9
 
9
10
  const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB
10
11
 
12
+ // Compression configuration for each format (as sharp names them)
13
+ const formatConfigs = {
14
+ jpeg: {
15
+ options: {},
16
+ settings: { quality: 100 }
17
+ },
18
+ png: {
19
+ options: { pages: -1 },
20
+ settings: { animated: true, compressionLevel: 9, quality: 100 } // Still waiting for APNG support though (`animated` doesn’t seem to have an effect), https://github.com/lovell/sharp/issues/2375
21
+ },
22
+ gif: {
23
+ options: { pages: -1 },
24
+ settings: {
25
+ reuse: true, // Preserve original palette for lossless quality (default)
26
+ effort: 10, // Maximum compression effort
27
+ dither: 0, // No dithering = lossless quality
28
+ interFrameMaxError: 0, // No transparency errors = lossless (default)
29
+ interPaletteMaxError: 0, // Perfect palette match = lossless
30
+ colors: 256 // Full palette available (default)
31
+ }
32
+ },
33
+ webp: {
34
+ options: { pages: -1 },
35
+ settings: { animated: true, lossless: true }
36
+ },
37
+ avif: {
38
+ options: {},
39
+ settings: { effort: 5, lossless: true } // Temporarily specifying effort, too, as per https://github.com/lovell/sharp/issues/4370#issuecomment-2798848572
40
+ }
41
+ }
42
+
43
+ export const hash = (data) => createHash('sha256').update(data).digest('hex')
44
+
45
+ // Identifies what compression results depend on, to tell when recorded results no longer apply
46
+ const fingerprint = hash(JSON.stringify({ formatConfigs, versions: sharp.versions })).slice(0, 16)
47
+
11
48
  // `styleText` detects color on `process.stdout` by default, so anything bound
12
49
  // for STDERR has to name that stream—otherwise a redirected STDERR collects
13
50
  // escape codes whenever STDOUT happens to be a terminal
@@ -34,7 +71,7 @@ const retryFileOperation = async (operation, maxRetries = 5, delayMs = 100) => {
34
71
  }
35
72
  }
36
73
 
37
- const compression = async (filename, dry, quiet = false) => {
74
+ const compression = async (filename, dry, quiet = false, cache) => {
38
75
  const filenameBackup = `${filename}.bak`
39
76
  const fileSizeBefore = await size(filename)
40
77
  // Track whether original file was successfully replaced
@@ -66,48 +103,28 @@ const compression = async (filename, dry, quiet = false) => {
66
103
  }
67
104
 
68
105
  const outputFormat = ext === 'jpg' ? 'jpeg' : ext // sharp uses “jpeg” instead of “jpg”
69
-
70
- // Compression configuration for each format
71
- const formatConfigs = {
72
- png: {
73
- options: { pages: -1 },
74
- settings: { animated: true, compressionLevel: 9, quality: 100 } // Still waiting for APNG support though (`animated` doesn’t seem to have an effect), https://github.com/lovell/sharp/issues/2375
75
- },
76
- gif: {
77
- options: { pages: -1 },
78
- settings: {
79
- reuse: true, // Preserve original palette for lossless quality (default)
80
- effort: 10, // Maximum compression effort
81
- dither: 0, // No dithering = lossless quality
82
- interFrameMaxError: 0, // No transparency errors = lossless (default)
83
- interPaletteMaxError: 0, // Perfect palette match = lossless
84
- colors: 256 // Full palette available (default)
85
- }
86
- },
87
- webp: {
88
- options: { pages: -1 },
89
- settings: { animated: true, lossless: true }
90
- },
91
- avif: {
92
- options: {},
93
- settings: { effort: 5, lossless: true } // Temporarily specifying effort, too, as per https://github.com/lovell/sharp/issues/4370#issuecomment-2798848572
94
- }
106
+ const config = formatConfigs[outputFormat]
107
+ if (!config) {
108
+ throw new Error(`Unsupported file type for ${filename}`)
95
109
  }
96
110
 
97
- // Apply format-specific compression or use default
98
- const config = formatConfigs[outputFormat]
99
- if (config) {
100
- await sharp(filename, config.options)
101
- .toFormat(outputFormat, config.settings)
102
- .toFile(tempFilePath)
103
- } else {
104
- // Fallback for any other supported formats (like JPG)
105
- await sharp(filename)
106
- .toFormat(outputFormat, { quality: 100 })
107
- .toFile(tempFilePath)
111
+ // The format follows the extension
112
+ const toEntry = (data) => `${outputFormat}:${hash(data)}`
113
+
114
+ const input = await fs.readFile(filename)
115
+ const entryInput = cache ? toEntry(input) : undefined
116
+ if (cache?.has(entryInput)) {
117
+ logMessage(`Skipped ${filename} (already processed)`, dry, 'white', quiet)
118
+ return 0
108
119
  }
109
120
 
110
- const fileSizeAfter = await size(tempFilePath)
121
+ const output = await sharp(input, config.options)
122
+ .toFormat(outputFormat, config.settings)
123
+ .toBuffer()
124
+ const fileSizeAfter = output.length
125
+ if (fileSizeAfter === 0) {
126
+ throw new Error('Compressed file size is 0')
127
+ }
111
128
 
112
129
  let color = 'white'
113
130
  let status = 'Skipped'
@@ -120,6 +137,7 @@ const compression = async (filename, dry, quiet = false) => {
120
137
  if (!dry) {
121
138
  // Only now create a backup and replace the original
122
139
  await retryFileOperation(() => fs.copyFile(filename, filenameBackup))
140
+ await fs.writeFile(tempFilePath, output)
123
141
  // Prefer atomic rename when possible
124
142
  try {
125
143
  await retryFileOperation(() => fs.rename(tempFilePath, filename))
@@ -141,14 +159,15 @@ const compression = async (filename, dry, quiet = false) => {
141
159
  details = 'already compressed more effectively'
142
160
  }
143
161
 
162
+ // Records the file as it now is, so compressed results aren’t re-encoded again either
163
+ if (!dry) {
164
+ cache?.add(fileSizeAfter < fileSizeBefore ? toEntry(output) : entryInput)
165
+ }
166
+
144
167
  logMessage(`${status} ${filename} (${details})`, dry, color, quiet)
145
168
 
146
169
  if (dry) {
147
- return 0 // Temp file cleaned up in finally
148
- }
149
-
150
- if (fileSizeAfter === 0) {
151
- console.error(styleStderr('red', `Error compressing ${filename}: Compressed file size is 0`))
170
+ return 0
152
171
  }
153
172
 
154
173
  return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0
@@ -174,7 +193,7 @@ const compression = async (filename, dry, quiet = false) => {
174
193
 
175
194
  } finally {
176
195
 
177
- // Clean up temp file if it wasn’t consumed (covers dry-run, error, and no-improvement paths)
196
+ // Clean up temp file if it wasn’t consumed (covers error paths)
178
197
  if (!tempConsumed) {
179
198
  try {
180
199
  await retryFileOperation(() => fs.unlink(tempFilePath))
@@ -290,4 +309,4 @@ const size = async (file) => {
290
309
 
291
310
  const sizeReadable = (size) => `${(size / 1024).toFixed(2)} KB` // eslint-disable-line no-irregular-whitespace
292
311
 
293
- export const utils = { compression, conversion, sizeReadable }
312
+ export const utils = { compression, conversion, sizeReadable, fingerprint }