image-guard 5.3.0 → 5.4.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
@@ -109,17 +109,21 @@ If you give the hook options like `--ignore`, add them to the `postprepare` scri
109
109
 
110
110
  Image Guard takes an optional path to the directory to process—`npx image-guard assets`—and works on the current directory when that path is omitted.
111
111
 
112
- * `--dry` allows you to run Image Guard in “dry mode.” All changes are shown in the terminal.
112
+ * `--heic-to-avif` converts HEIC/HEIF files to AVIF. Because HEIC sources are already lossy-compressed (HEVC), the conversion uses lossy AVIF encoding (quality 80) to produce files smaller than the originals at near-identical visual quality. Original HEIC/HEIF files are deleted after conversion by default. (Note: The conversion maps Display P3 colors—common on iPhones—to sRGB, which may result in slightly less vivid colors.)
113
113
 
114
- * `--ignore` allows you to specify paths to be ignored (as in `--ignore=example,test`). Multiple paths must be separated by commas. Naming a directory excludes everything inside it. The option supports glob patterns (e.g., `assets/**`, `**/*.png`); matching is case-insensitive and honors `.gitignore`. Patterns are relative to the directory being processed—with `--staged`, to the repository root.
114
+ - `--keep-heic` (used with `--heic-to-avif`) preserves the original HEIC/HEIF files instead of deleting them after conversion.
115
115
 
116
- * `--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.
116
+ * `--ignore`/`-i` allows you to specify paths to be ignored (as in `--ignore=example,test`). Multiple paths must be separated by commas. Naming a directory excludes everything inside it. The option supports glob patterns (e.g., `assets/**`, `**/*.png`); matching is case-insensitive and honors .gitignore. Patterns are relative to the directory being processed—with `--staged`, to the repository root.
117
117
 
118
- * `--heic-to-avif` converts HEIC/HEIF files to AVIF. Because HEIC sources are already lossy-compressed (HEVC), the conversion uses lossy AVIF encoding (quality 80) to produce files smaller than the originals at near-identical visual quality. Original HEIC/HEIF files are deleted after conversion by default. (Note: The conversion maps Display P3 colors—common on iPhones—to sRGB, which may result in slightly less vivid colors.)
118
+ * `--quiet`/`-q` suppresses per-file logs and prints only the final summary (plus errors). This reduces console noise and speeds up runs in CI and Git hooks.
119
119
 
120
- - `--keep-heic` (used with `--heic-to-avif`) preserves the original HEIC/HEIF files instead of deleting them after conversion.
120
+ * `--dry`/`-d` allows you to run Image Guard in “dry mode.” All changes are shown in the terminal (unless combined with `--quiet`, which leaves just the summary).
121
+
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
+
124
+ * `--help`/`-h` lists the arguments and options.
121
125
 
122
- * `--quiet` suppresses per-file logs and prints only the final summary (plus errors). This reduces console noise and speeds up runs in CI and Git hooks.
126
+ * `--version`/`-V` prints the version number.
123
127
 
124
128
  ## What Does the Output Look Like?
125
129
 
@@ -1,10 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { runImageGuard } from '../src/index.js'
4
+ import { styleStderr } from '../src/utils.js'
4
5
 
5
6
  try {
6
7
  await runImageGuard()
7
8
  } catch (err) {
8
- console.error('Error running Image Guard:', err.message)
9
+ if (err.setupFailed) {
10
+ console.error(styleStderr('red', err.message))
11
+ } else {
12
+ console.error('Error running Image Guard:', err)
13
+ }
9
14
  process.exit(1)
10
15
  }
package/package.json CHANGED
@@ -14,7 +14,8 @@
14
14
  "description": "Near-lossless image compressor that’s easy to use and easy to automate",
15
15
  "devDependencies": {
16
16
  "@eslint/js": "^10.0.1",
17
- "eslint": "^10.8.1"
17
+ "eslint": "^10.8.1",
18
+ "globals": "^17.11.0"
18
19
  },
19
20
  "files": [
20
21
  "bin/image-guard.js",
@@ -54,5 +55,5 @@
54
55
  "test": "node --test bin/image-guard.test.js"
55
56
  },
56
57
  "type": "module",
57
- "version": "5.3.0"
58
- }
58
+ "version": "5.4.0"
59
+ }
package/src/index.js CHANGED
@@ -7,7 +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 { utils } from './utils.js'
10
+ import { styleStderr, utils } from './utils.js'
11
11
 
12
12
  // Files to be compressed
13
13
  export const fileTypes = ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp'];
@@ -15,29 +15,77 @@ export const fileTypes = ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp'];
15
15
  // Files to be converted (require explicit opt-in)
16
16
  export const convertTypes = ['heic', 'heif'];
17
17
 
18
+ // Marks a failure as the user’s to fix rather than a bug
19
+ function setupError(message) {
20
+ const err = new Error(message)
21
+ err.setupFailed = true
22
+ return err
23
+ }
24
+
18
25
  export async function runImageGuard() {
19
26
  const options = {
20
- dry: { type: 'boolean', default: false },
21
27
  'heic-to-avif': { type: 'boolean', default: false },
22
- ignore: { type: 'string', multiple: false, default: '' },
23
28
  'keep-heic': { type: 'boolean', default: false },
29
+ ignore: { type: 'string', short: 'i', multiple: false, default: '' },
30
+ quiet: { type: 'boolean', short: 'q', default: false },
31
+ dry: { type: 'boolean', short: 'd', default: false },
24
32
  staged: { type: 'boolean', default: false },
25
- quiet: { type: 'boolean', default: false }
33
+ help: { type: 'boolean', short: 'h', default: false },
34
+ version: { type: 'boolean', short: 'V', default: false }
35
+ }
36
+
37
+ let argv, positionals
38
+ try {
39
+ ({ values: argv, positionals } = parseArgs({ options, allowPositionals: true }))
40
+ } catch (err) {
41
+ // `parseArgs` appends guidance about `--` that only muddies a plain typo
42
+ const [summary] = err.message.split('. ')
43
+ throw setupError(`${summary}—run \`image-guard --help\` for the available options.`)
44
+ }
45
+
46
+ if (argv.help) {
47
+ console.log(`Usage: image-guard [options] [directory]
48
+
49
+ Compress images in place, and optionally convert HEIC/HEIF files to AVIF.
50
+
51
+ Arguments:
52
+ directory Directory to process (default: current directory)
53
+
54
+ Options:
55
+ --heic-to-avif Also convert HEIC/HEIF files to AVIF
56
+ --keep-heic Keep the original HEIC/HEIF files (only with \`--heic-to-avif\`)
57
+ -i, --ignore <paths> Comma-separated paths or glob patterns to exclude
58
+ -q, --quiet Print only the final summary
59
+ -d, --dry Show what would change without writing any files
60
+ --staged Process only images staged in Git (not combinable with a directory)
61
+ -h, --help Show this help
62
+ -V, --version Show the version number`)
63
+ return
64
+ }
65
+
66
+ if (argv.version) {
67
+ const pkg = JSON.parse(fsSync.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
68
+ console.log(pkg.version)
69
+ return
26
70
  }
27
- const { values: argv, positionals } = parseArgs({ options, allowPositionals: true })
28
71
 
29
72
  if (positionals.length > 1) {
30
- throw new Error(`Expected at most one path, got ${positionals.length}: ${positionals.join(', ')}`)
73
+ throw setupError(`Expected at most one path, got ${positionals.length}: ${positionals.join(', ')}`)
31
74
  }
32
75
 
33
76
  const dir = positionals[0] || '.'
34
77
 
35
78
  if (positionals.length && argv.staged) {
36
- throw new Error('`--staged` takes its files from Git, not from a path—pass one or the other.')
79
+ throw setupError('`--staged` takes its files from Git, not from a path—pass one or the other.')
80
+ }
81
+
82
+ if (!fsSync.existsSync(dir)) {
83
+ throw setupError(`No such directory: ${dir}`)
37
84
  }
38
85
 
39
- if (!fsSync.existsSync(dir) || !fsSync.statSync(dir).isDirectory()) {
40
- throw new Error(`No such directory: ${dir}`)
86
+ // A path that is there but isn’t a directory gets its own message
87
+ if (!fsSync.statSync(dir).isDirectory()) {
88
+ throw setupError(`Not a directory: ${dir}`)
41
89
  }
42
90
 
43
91
  // Share status
@@ -52,7 +100,7 @@ export async function runImageGuard() {
52
100
  }
53
101
 
54
102
  if (argv['keep-heic'] && !argv['heic-to-avif']) {
55
- console.warn(styleText('yellow', '`--keep-heic` has no effect without `--heic-to-avif`'))
103
+ console.warn(styleStderr('yellow', '`--keep-heic` has no effect without `--heic-to-avif`'))
56
104
  }
57
105
 
58
106
  const allTypes = argv['heic-to-avif'] ? [...fileTypes, ...convertTypes] : fileTypes
@@ -117,7 +165,7 @@ export async function runImageGuard() {
117
165
  } else {
118
166
  hadFailures = true
119
167
  const reason = r.reason && r.reason.message ? r.reason.message : String(r.reason)
120
- console.error(styleText('red', 'Task failed:'), reason)
168
+ console.error(styleStderr('red', 'Task failed:'), reason)
121
169
  }
122
170
  }
123
171
  return { hadFailures, addedKB }
@@ -171,7 +219,7 @@ export async function runImageGuard() {
171
219
  return patterns
172
220
  }
173
221
 
174
- // Globbing runs with `dir` as its base—so ignore patterns and `.gitignore`
222
+ // Globbing runs with `dir` as its base—so ignore patterns and .gitignore
175
223
  // lookup are relative to the searched directory, not to the shell’s—and the
176
224
  // results are rejoined for display and file access
177
225
  const findFiles = async (patterns, options = {}) => {
package/src/utils.js CHANGED
@@ -8,6 +8,11 @@ import decode from 'heic-decode'
8
8
 
9
9
  const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB
10
10
 
11
+ // `styleText` detects color on `process.stdout` by default, so anything bound
12
+ // for STDERR has to name that stream—otherwise a redirected STDERR collects
13
+ // escape codes whenever STDOUT happens to be a terminal
14
+ export const styleStderr = (format, text) => styleText(format, text, { stream: process.stderr })
15
+
11
16
  const logMessage = (message, dry, color = 'yellow', quiet = false) => {
12
17
  if (quiet) return
13
18
  const prefix = dry ? 'Dry run: ' : ''
@@ -143,7 +148,7 @@ const compression = async (filename, dry, quiet = false) => {
143
148
  }
144
149
 
145
150
  if (fileSizeAfter === 0) {
146
- console.error(styleText('red', `Error compressing ${filename}: Compressed file size is 0`))
151
+ console.error(styleStderr('red', `Error compressing ${filename}: Compressed file size is 0`))
147
152
  }
148
153
 
149
154
  return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0
@@ -163,7 +168,7 @@ const compression = async (filename, dry, quiet = false) => {
163
168
  ) {
164
169
  logMessage(`Skipped ${filename} (corrupt file)`, dry, 'yellow', quiet)
165
170
  } else {
166
- console.error(styleText('red', `Error compressing ${filename}:`), err)
171
+ console.error(styleStderr('red', `Error compressing ${filename}:`), err)
167
172
  }
168
173
  return 0
169
174
 
@@ -184,14 +189,14 @@ const compression = async (filename, dry, quiet = false) => {
184
189
  await retryFileOperation(() => fs.unlink(filenameBackup))
185
190
  } catch (err) {
186
191
  if (err.code !== 'ENOENT') {
187
- console.warn(styleText('yellow', `Failed to delete backup file ${filenameBackup}:`), err)
192
+ console.warn(styleStderr('yellow', `Failed to delete backup file ${filenameBackup}:`), err)
188
193
  }
189
194
  }
190
195
  } else if (!dry && !replacementSucceeded) {
191
196
  // If a backup was created but replacement failed, warn so the user can recover it
192
197
  try {
193
198
  await fs.access(filenameBackup)
194
- console.warn(styleText('yellow', `Replacement failed for ${filename}; backup preserved at ${filenameBackup}`))
199
+ console.warn(styleStderr('yellow', `Replacement failed for ${filename}; backup preserved at ${filenameBackup}`))
195
200
  } catch {
196
201
  // No backup exists—nothing to warn about
197
202
  }
@@ -262,7 +267,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
262
267
  await retryFileOperation(() => fs.unlink(avifPath))
263
268
  } catch (cleanupErr) {
264
269
  if (cleanupErr.code !== 'ENOENT') {
265
- console.warn(styleText('yellow', `Failed to clean up ${avifPath}:`), cleanupErr)
270
+ console.warn(styleStderr('yellow', `Failed to clean up ${avifPath}:`), cleanupErr)
266
271
  }
267
272
  }
268
273
 
@@ -272,7 +277,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
272
277
  )) {
273
278
  logMessage(`Skipped ${filename} (corrupt or unsupported HEIC/HEIF file)`, dry, 'yellow', quiet)
274
279
  } else {
275
- console.error(styleText('red', `Error converting ${filename}:`), err)
280
+ console.error(styleStderr('red', `Error converting ${filename}:`), err)
276
281
  }
277
282
  return 0
278
283
  }