image-guard 5.2.4 → 5.2.6

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
@@ -1,6 +1,6 @@
1
1
  # Image Guard
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/image-guard.svg)](https://www.npmjs.com/package/image-guard) [![Build status](https://github.com/j9t/image-guard/workflows/Tests/badge.svg)](https://github.com/j9t/image-guard/actions) [![Socket](https://badge.socket.dev/npm/package/image-guard)](https://socket.dev/npm/package/image-guard)
3
+ [![npm version](https://img.shields.io/npm/v/image-guard.svg)](https://www.npmjs.com/package/image-guard) [![Build status](https://github.com/j9t/image-guard/workflows/Tests/badge.svg)](https://github.com/j9t/image-guard/actions) [![Socket](https://badge.socket.dev/npm/package/image-guard)](https://socket.dev/npm/package/image-guard) [![GitHub Sponsors](https://badgen.net/static/Support/Open%20Source/cyan)](https://github.com/j9t/image-guard?sponsor=1)
4
4
 
5
5
  (This project was based on [sum.cumo’s imagemin-merlin](https://github.com/sumcumo/imagemin-merlin). [Changes are documented](https://github.com/sumcumo/imagemin-merlin/compare/master...j9t:master), and include this README. Image Guard supports additional file formats—WebP, AVIF, and HEIC/HEIF—, comes with improved code and documentation, and is being maintained. For this reason, it’s [not based on any Imagemin packages](https://meiert.com/blog/image-guard-4/) anymore.)
6
6
 
@@ -20,7 +20,7 @@ It’s as safe as possible because compression happens losslessly (near-lossless
20
20
 
21
21
  You can use Image Guard right away, without installation, by running
22
22
 
23
- ```console
23
+ ```shell
24
24
  npx image-guard
25
25
  ```
26
26
 
@@ -28,13 +28,13 @@ npx image-guard
28
28
 
29
29
  Install Image Guard in your project:
30
30
 
31
- ```console
31
+ ```shell
32
32
  npm i -D image-guard
33
33
  ```
34
34
 
35
35
  Run Image Guard by calling
36
36
 
37
- ```console
37
+ ```shell
38
38
  npx image-guard
39
39
  ```
40
40
 
@@ -44,13 +44,13 @@ To make sure that _all_ images are being compressed, it’s recommended to run I
44
44
 
45
45
  Install Image Guard in your project:
46
46
 
47
- ```console
47
+ ```shell
48
48
  npm i -D image-guard
49
49
  ```
50
50
 
51
51
  To compress images already in the codebase, run Image Guard once by calling
52
52
 
53
- ```console
53
+ ```shell
54
54
  npx image-guard
55
55
  ```
56
56
 
@@ -60,7 +60,7 @@ For automated use, Image Guard should be triggered through a [Git hook](https://
60
60
 
61
61
  Native Git hooks are simpler to set up and don’t require additional dependencies. Run these commands from your project root:
62
62
 
63
- ```console
63
+ ```shell
64
64
  mkdir -p .githooks;\
65
65
  cat > .githooks/pre-commit << 'EOF'
66
66
  #!/bin/sh
@@ -81,7 +81,7 @@ chmod +x .githooks/pre-commit && git config core.hooksPath .githooks"
81
81
 
82
82
  If you already use [Husky](https://typicode.github.io/husky/), run the following commands in your project root (you can copy and execute them at once):
83
83
 
84
- ```console
84
+ ```shell
85
85
  grep -qxF "./node_modules/.bin/image-guard --staged" .husky/pre-commit || printf '\n./node_modules/.bin/image-guard --staged' >> .husky/pre-commit;\
86
86
  git add .husky/pre-commit;\
87
87
  git commit -m "feat: add Husky pre-commit hook for Image Guard";\
@@ -90,7 +90,7 @@ npm pkg set scripts.postprepare="grep -qxF './node_modules/.bin/image-guard --st
90
90
 
91
91
  If you don’t yet but want to use Husky, run the following commands from your project root:
92
92
 
93
- ```console
93
+ ```shell
94
94
  npm i -D husky;\
95
95
  npx husky init;\
96
96
  echo "./node_modules/.bin/image-guard --staged" > .husky/pre-commit;\
@@ -107,7 +107,7 @@ npm pkg set scripts.postprepare="grep -qxF './node_modules/.bin/image-guard --st
107
107
 
108
108
  * `--dry` allows you to run Image Guard in “dry mode.” All changes are shown in the terminal.
109
109
 
110
- * `--ignore` allows you to specify paths to be ignored (as in `--ignore=example,test`). Multiple paths must be separated by commas. The option supports glob patterns (e.g., `assets/**`, `**/*.png`); matching is case‑insensitive and honors `.gitignore`.
110
+ * `--ignore` allows you to specify paths to be ignored (as in `--ignore=example,test`). Multiple paths must be separated by commas. The option supports glob patterns (e.g., `assets/**`, `**/*.png`); matching is case-insensitive and honors `.gitignore`.
111
111
 
112
112
  * `--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) in `git diff` and only processes those files—that approach makes Image Guard more efficient in operation.
113
113
 
@@ -115,7 +115,7 @@ npm pkg set scripts.postprepare="grep -qxF './node_modules/.bin/image-guard --st
115
115
 
116
116
  - `--keep-heic` (used with `--heic-to-avif`) preserves the original HEIC/HEIF files instead of deleting them after conversion.
117
117
 
118
- * `--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.
118
+ * `--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.
119
119
 
120
120
  ## What Does the Output Look Like?
121
121
 
@@ -128,7 +128,7 @@ Roughly like this:
128
128
  * White (light gray): The image file has not been changed.
129
129
  * Blue: The image file had already been compressed more effectively than the new result, and was therefore skipped, too.
130
130
 
131
- Tip: Use `--quiet` to suppress these per‑file lines and keep only the final summary.
131
+ Tip: Use `--quiet` to suppress these per-file lines and keep only the final summary.
132
132
 
133
133
  ## How Does Image Guard Work?
134
134
 
@@ -165,4 +165,12 @@ Licensed under the Apache License, Version 2.0 (the “License”); you may not
165
165
 
166
166
  http://www.apache.org/licenses/LICENSE-2.0
167
167
 
168
- Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
168
+ Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
169
+
170
+ ***
171
+
172
+ You might like some of my other work:
173
+
174
+ * Optimization tools: [hihtml](https://github.com/j9t/hihtml) · [HTML Minifier Next](https://github.com/j9t/html-minifier-next) · [ObsoHTML](https://github.com/j9t/obsohtml) · [CSS Dedup](https://github.com/j9t/css-dedup) · Image Guard · [Compressor.js Next](https://github.com/j9t/compressorjs-next) · [.htaccess Punk](https://github.com/j9t/htaccess-punk)
175
+ * Defense tools: [IA Defensa](https://iadefensa.com/solutions/)
176
+ * Resources for quality web development: [Articles](https://meiert.com/topics/development/) · [Books](https://meiert.com/topics/books/) (including [_On Web Development_](https://meiert.com/blog/on-web-development-2/)) · [News](https://frontenddogma.com/) · [Terminology](https://webglossary.info/)
package/package.json CHANGED
@@ -6,15 +6,22 @@
6
6
  "imagemin-merlin": "bin/image-guard.sh"
7
7
  },
8
8
  "dependencies": {
9
- "globby": "^16.1.0",
9
+ "globby": "^16.2.2",
10
10
  "heic-decode": "^2.1.0",
11
- "sharp": "^0.34.5",
12
- "simple-git": "^3.31.1"
11
+ "sharp": "^0.35.3",
12
+ "simple-git": "^3.36.0"
13
13
  },
14
14
  "description": "Near-lossless image compressor that’s easy to use and easy to automate",
15
15
  "devDependencies": {
16
- "eslint": "^10.0.0"
16
+ "@eslint/js": "^10.0.1",
17
+ "eslint": "^10.7.0"
17
18
  },
19
+ "files": [
20
+ "bin/image-guard.js",
21
+ "bin/image-guard.sh",
22
+ "NOTICE.md",
23
+ "src"
24
+ ],
18
25
  "funding": "https://github.com/j9t/image-guard?sponsor=1",
19
26
  "homepage": "https://github.com/j9t/image-guard",
20
27
  "keywords": [
@@ -46,5 +53,5 @@
46
53
  "test": "node --test bin/image-guard.test.js"
47
54
  },
48
55
  "type": "module",
49
- "version": "5.2.4"
56
+ "version": "5.2.6"
50
57
  }
package/src/index.js CHANGED
@@ -179,7 +179,7 @@ export async function runImageGuard() {
179
179
  }
180
180
 
181
181
  const doConversion = argv['heic-to-avif']
182
- let hadFailures = false
182
+ let hadFailures
183
183
  let totalFiles = 0
184
184
 
185
185
  if (argv.staged) {
package/src/utils.js CHANGED
@@ -283,6 +283,6 @@ const size = async (file) => {
283
283
  return stats.size
284
284
  }
285
285
 
286
- const sizeReadable = (size) => `${(size / 1024).toFixed(2)} KB`
286
+ const sizeReadable = (size) => `${(size / 1024).toFixed(2)} KB` // eslint-disable-line no-irregular-whitespace
287
287
 
288
288
  export const utils = { compression, conversion, sizeReadable }
package/.editorconfig DELETED
@@ -1,12 +0,0 @@
1
- root = true
2
-
3
- [*]
4
- charset = utf-8
5
- end_of_line = lf
6
- insert_final_newline = false
7
- tab_width = 2
8
- trim_trailing_whitespace = true
9
-
10
- [*.js]
11
- indent_style = space
12
- indent_size = 2
@@ -1,3 +0,0 @@
1
- # buy_me_a_coffee: meiert
2
- github: j9t
3
- # liberapay: j9t
@@ -1,12 +0,0 @@
1
- # https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference
2
-
3
- version: 2
4
- updates:
5
- - package-ecosystem: "npm"
6
- directory: "/"
7
- schedule:
8
- interval: "monthly"
9
- - package-ecosystem: "github-actions"
10
- directory: "/"
11
- schedule:
12
- interval: "monthly"
@@ -1,21 +0,0 @@
1
- name: "npm Publishing"
2
-
3
- on:
4
- workflow_dispatch:
5
-
6
- jobs:
7
- publish:
8
- runs-on: ubuntu-latest
9
- permissions:
10
- contents: read
11
- id-token: write
12
- steps:
13
- - uses: actions/checkout@v6
14
- - uses: actions/setup-node@v6
15
- with:
16
- node-version: 'lts/*'
17
- registry-url: 'https://registry.npmjs.org'
18
- - run: npm ci
19
- - run: npm publish --provenance
20
- env:
21
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -1,54 +0,0 @@
1
- name: "Tests"
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
-
8
- permissions:
9
- contents: read
10
-
11
- concurrency:
12
- group: ${{ github.workflow }}-${{ github.ref }}
13
- cancel-in-progress: true
14
-
15
- jobs:
16
- test:
17
- strategy:
18
- matrix:
19
- platform: [ubuntu-latest]
20
- node: ['lts/*', 'current']
21
- name: "Node ${{ matrix.node }} (${{ matrix.platform }})"
22
- runs-on: ${{ matrix.platform }}
23
- steps:
24
- - name: "Check out repository"
25
- uses: actions/checkout@v6
26
- - name: "Set up Node.js"
27
- uses: actions/setup-node@v6
28
- with:
29
- node-version: ${{ matrix.node }}
30
- cache: npm
31
- - name: "Install dependencies"
32
- run: npm ci
33
- - name: "Run tests"
34
- run: npm test
35
-
36
- test-macos:
37
- if: github.event_name == 'push' && github.ref == 'refs/heads/main'
38
- strategy:
39
- matrix:
40
- node: ['lts/*', 'current']
41
- name: "Node ${{ matrix.node }} (macOS)"
42
- runs-on: macos-latest
43
- steps:
44
- - name: "Check out repository"
45
- uses: actions/checkout@v6
46
- - name: "Set up Node.js"
47
- uses: actions/setup-node@v6
48
- with:
49
- node-version: ${{ matrix.node }}
50
- cache: npm
51
- - name: "Install dependencies"
52
- run: npm ci
53
- - name: "Run tests"
54
- run: npm test
package/CHANGELOG.md DELETED
@@ -1,48 +0,0 @@
1
- # Changelog
2
-
3
- Starting with version 5.2.0, all notable changes to Image Guard are documented in this file, which is (mostly) AI-generated and (always) human-edited. Dependency updates may or may not be called out specifically.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
-
7
- ## [5.2.4] - 2026-03-04
8
-
9
- ### Fixed
10
-
11
- * Changed bin entry from a Node.js script to a shell wrapper that locates node from common version managers (nvm, Volta, asdf, mise) and package managers (Homebrew) before invocation, resolving hook failures in GUI Git clients that don’t inherit the shell’s PATH
12
- - **Migration:** If you set up hooks, replace `npx image-guard --staged` with `./node_modules/.bin/image-guard --staged` in your `.githooks/pre-commit` or `.husky/pre-commit`.
13
-
14
- ## [5.2.3] - 2026-02-21
15
-
16
- ### Fixed
17
-
18
- * Improved corrupt-file error detection to cover WebP and AVIF formats, normalize message casing before matching, and avoid false positives from the overly broad `Invalid` string match
19
- * Warned when a `.bak` file is left behind after a failed file replacement
20
- * Removed a dead code branch in result processing
21
-
22
- ### Changed
23
-
24
- * Moved to compression and conversion running in parallel (non-overlapping file sets), reducing wall-clock time when `--heic-to-avif` is active
25
- * Reduced directory traversals from two to one in non-staged mode when `--heic-to-avif` is active
26
- * Extracted shared `MAX_FILE_SIZE` constant; aligned `node:fs` and `node:path` import style in `utils.js`
27
- * Extended test coverage for corrupt file detection and reporting
28
-
29
- ## [5.2.2] - 2026-02-17
30
-
31
- ### Fixed
32
-
33
- * Updated HEIC dry run to report actual file sizes instead of omitting size data
34
-
35
- ## [5.2.1] - 2026-02-17
36
-
37
- ### Changed
38
-
39
- * Adjusted HEIC-to-AVIF conversion to use lossy instead of lossless encoding (quality 80), to produce files smaller than the HEIC originals but still at high quality
40
- * Removed redundant lossless compression pass after HEIC conversion
41
- * Documented Display P3 to sRGB color space mapping during HEIC conversion
42
-
43
- ## [5.2.0] - 2026-02-17
44
-
45
- ### Added
46
-
47
- * Added opt-in HEIC/HEIF-to-AVIF conversion via `--heic-to-avif` (with `--keep-heic` to preserve originals)
48
- * Introduced changelog
package/SECURITY.md DELETED
@@ -1,9 +0,0 @@
1
- # Security Policy
2
-
3
- ## Supported Versions
4
-
5
- Only the latest and therefore current version of Image Guard is supported. It’s advised to update older versions to the latest version.
6
-
7
- ## Reporting a Vulnerability
8
-
9
- To report a vulnerability, please [file an issue](https://github.com/j9t/image-guard/issues/new) or email info@meiert.com.
@@ -1,553 +0,0 @@
1
- import fs from 'fs'
2
- import os from 'os'
3
- import path from 'path'
4
- import { execSync } from 'child_process'
5
- import { fileURLToPath } from 'url'
6
- import { test, describe, before, after } from 'node:test'
7
- import assert from 'node:assert'
8
- import simpleGit from 'simple-git'
9
- import { fileTypes as allowedFileTypes } from '../src/index.js'
10
-
11
- const __filename = fileURLToPath(import.meta.url)
12
- const __dirname = path.dirname(__filename)
13
- const testFolder = path.join(__dirname, '../media/test')
14
- const testFolderGit = path.join(__dirname, '../media/test-git')
15
- const imageGuardScript = path.join(__dirname, '../bin/image-guard.js')
16
-
17
- // Function to copy files
18
- function copyFiles(srcDir, destDir) {
19
- if (!fs.existsSync(destDir)) {
20
- fs.mkdirSync(destDir, { recursive: true })
21
- }
22
- fs.readdirSync(srcDir).forEach(file => {
23
- const srcFile = path.join(srcDir, file)
24
- const destFile = path.join(destDir, file)
25
- fs.copyFileSync(srcFile, destFile)
26
- })
27
- }
28
-
29
- // Function to check if images are compressed
30
- const ignoreFiles = ['test#corrupt.gif']
31
-
32
- function areImagesCompressed(dir, originalDir = testFolder) {
33
- const uncompressedFiles = []
34
- const allCompressed = fs.readdirSync(dir).every(file => {
35
- if (ignoreFiles.includes(file)) {
36
- // console.info(`Ignoring file: ${file}`)
37
- return true
38
- }
39
- const ext = path.extname(file).slice(1).toLowerCase()
40
- if (!allowedFileTypes.includes(ext)) return true
41
- const filePath = path.join(dir, file)
42
- const originalFilePath = path.join(originalDir, file)
43
- try {
44
- const originalStats = fs.statSync(originalFilePath)
45
- const compressedStats = fs.statSync(filePath)
46
- const isCompressed = compressedStats.size < originalStats.size
47
- if (!isCompressed) {
48
- uncompressedFiles.push(file)
49
- }
50
- return isCompressed
51
- } catch (err) {
52
- console.warn(`Skipping possibly corrupt file: ${file} (${err.message})`)
53
- return true
54
- }
55
- })
56
- return { allCompressed, uncompressedFiles }
57
- }
58
-
59
- // Function to check if images are already compressed
60
- function areImagesAlreadyCompressed(dir) {
61
- return fs.readdirSync(dir).some(file => {
62
- const ext = path.extname(file).slice(1).toLowerCase()
63
- if (!allowedFileTypes.includes(ext)) return false
64
- const filePath = path.join(dir, file)
65
- const originalFilePath = path.join(testFolder, file)
66
- const originalStats = fs.statSync(originalFilePath)
67
- const compressedStats = fs.statSync(filePath)
68
- return compressedStats.size >= originalStats.size
69
- })
70
- }
71
-
72
- describe('Image Guard', () => {
73
- before(() => {
74
- // Back up original images
75
- copyFiles(testFolder, testFolderGit)
76
- })
77
-
78
- after(() => {
79
- // Clean up temporary directory
80
- if (fs.existsSync(testFolderGit)) {
81
- fs.rmSync(testFolderGit, { recursive: true, force: true })
82
- }
83
- })
84
-
85
- test('Compress images', () => {
86
- // Ensure images in temp folder are not already compressed
87
- assert.strictEqual(areImagesAlreadyCompressed(testFolderGit), true)
88
-
89
- // Run the script in a completely isolated temporary directory
90
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-test-'))
91
- const tempTestFolder = path.join(tempDir, 'test')
92
-
93
- // Copy test files to isolated temp directory
94
- copyFiles(testFolder, tempTestFolder)
95
-
96
- // Run image-guard from temp directory—only files in “tempDir” will be processed
97
- const originalCwd = process.cwd()
98
- try {
99
- process.chdir(tempDir)
100
- execSync(`node "${imageGuardScript}"`)
101
- } finally {
102
- process.chdir(originalCwd)
103
- }
104
-
105
- // Check results from the isolated temp files
106
- const { allCompressed, uncompressedFiles } = areImagesCompressed(tempTestFolder, testFolder)
107
-
108
- // Clean up
109
- fs.rmSync(tempDir, { recursive: true, force: true })
110
-
111
- if (uncompressedFiles.length > 0) {
112
- console.log('The following files were not compressed:', uncompressedFiles.join(', '))
113
- }
114
- assert.strictEqual(allCompressed, true)
115
- })
116
-
117
- test('Compress only staged images', async () => {
118
- const git = simpleGit(testFolderGit)
119
-
120
- // Ensure the temp folder exists
121
- if (!fs.existsSync(testFolderGit)) {
122
- fs.mkdirSync(testFolderGit, { recursive: true })
123
- }
124
-
125
- // Initialize a temporary Git repository
126
- await git.init()
127
- await git.addConfig('user.name', 'Test User')
128
- await git.addConfig('user.email', 'test@example.com')
129
-
130
- // Stage files
131
- await git.add('.')
132
-
133
- // Run image-guard script with “--staged” option
134
- execSync(`node "${imageGuardScript}" --staged`, { cwd: testFolderGit })
135
-
136
- // Verify images are compressed
137
- const { allCompressed, uncompressedFiles } = areImagesCompressed(testFolderGit)
138
- if (uncompressedFiles.length > 0) {
139
- console.log('The following files were not compressed:', uncompressedFiles.join(', '))
140
- }
141
- assert.strictEqual(allCompressed, true)
142
- })
143
-
144
- test('Ensure files are not modified in dry run', () => {
145
- const originalStats = fs.readdirSync(testFolderGit).sort().map(file => {
146
- const filePath = path.join(testFolderGit, file)
147
- return { file, stats: fs.statSync(filePath) }
148
- })
149
- execSync(`node "${imageGuardScript}" --dry`, { cwd: testFolderGit, stdio: 'pipe' })
150
- const newStats = fs.readdirSync(testFolderGit).sort().map(file => {
151
- const filePath = path.join(testFolderGit, file)
152
- return { file, stats: fs.statSync(filePath) }
153
- })
154
- originalStats.forEach((original, index) => {
155
- const newFile = newStats[index]
156
- assert.strictEqual(newFile.file, original.file)
157
- assert.strictEqual(newFile.stats.size, original.stats.size)
158
- assert.strictEqual(newFile.stats.mtime.getTime(), original.stats.mtime.getTime())
159
- })
160
- })
161
-
162
- test('Ignore parity: single file (non-staged vs. staged)', async () => {
163
- // Prepare isolated temp directory
164
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-ignore-one-'))
165
- const tempTestFolder = path.join(tempDir, 'test')
166
- copyFiles(testFolder, tempTestFolder)
167
-
168
- // Pick a known file from fixture folder
169
- const entries = fs.readdirSync(tempTestFolder).filter(n => /\.(png|jpe?g|gif|webp|avif)$/i.test(n))
170
- if (entries.length === 0) {
171
- fs.rmSync(tempDir, { recursive: true, force: true })
172
- return
173
- }
174
- const target = entries[0]
175
- const tempPath = path.join(tempTestFolder, target)
176
- // Snapshot the temp copy before running the CLI to ensure equality checks reflect true non-mutation
177
- const before = fs.statSync(tempPath)
178
-
179
- // Prepare a pre-run snapshot for non-ignored candidates to verify at least one gets compressed
180
- const preSnapshot = new Map()
181
- fs.readdirSync(tempTestFolder).sort().forEach(name => {
182
- if (name === target) return // excluded: explicitly ignored
183
- if (ignoreFiles.includes(name)) return // excluded: known corrupt fixture
184
- const ext = path.extname(name).slice(1).toLowerCase()
185
- if (!allowedFileTypes.includes(ext)) return
186
- const p = path.join(tempTestFolder, name)
187
- preSnapshot.set(name, fs.statSync(p))
188
- })
189
-
190
- // Non-staged: Run with `--ignore=<file>`
191
- const originalCwd = process.cwd()
192
- try {
193
- process.chdir(tempDir)
194
- execSync(`node "${imageGuardScript}" --ignore=${path.posix.join('test', target)}`, { stdio: 'pipe' })
195
- } finally {
196
- process.chdir(originalCwd)
197
- }
198
-
199
- // Verify the ignored file was not modified (size and mtime unchanged vs. pre-run snapshot)
200
- const tempStats = fs.statSync(tempPath)
201
- assert.strictEqual(tempStats.size, before.size)
202
- assert.strictEqual(tempStats.mtime.getTime(), before.mtime.getTime())
203
-
204
- // Verify at least one non-ignored candidate was compressed in the non-staged run
205
- let shrunkCount = 0
206
- for (const [name, statBefore] of preSnapshot) {
207
- const statAfter = fs.statSync(path.join(tempTestFolder, name))
208
- if (statAfter.size < statBefore.size) shrunkCount++
209
- }
210
- assert.ok(shrunkCount >= 1, 'Expected at least one non-ignored file to be compressed')
211
-
212
- // Staged: Init repo, stage only target and another file, ensure ignore prevents its processing
213
- const git = simpleGit(tempTestFolder)
214
- await git.init()
215
- await git.addConfig('user.name', 'Test User')
216
- await git.addConfig('user.email', 'test@example.com')
217
- await git.add('.')
218
-
219
- // Run staged with `ignore`
220
- execSync(`node "${imageGuardScript}" --staged --ignore=${path.posix.join('test', target)}`, { cwd: tempTestFolder, stdio: 'pipe' })
221
-
222
- // Check file still not modified compared to its current state (size should not shrink due to ignore)
223
- const afterStats = fs.statSync(tempPath)
224
- assert.strictEqual(afterStats.size, tempStats.size)
225
-
226
- // Cleanup
227
- fs.rmSync(tempDir, { recursive: true, force: true })
228
- })
229
-
230
- test('Ignore supports multiple patterns and directories; case-insensitive', () => {
231
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-ignore-multi-'))
232
- const tempTestFolder = path.join(tempDir, 'test')
233
- copyFiles(testFolder, tempTestFolder)
234
-
235
- // Create a subdirectory to simulate directory ignore
236
- const subDir = path.join(tempTestFolder, 'Assets')
237
- fs.mkdirSync(subDir, { recursive: true })
238
- // Copy one file into subdir
239
- const oneFile = fs.readdirSync(tempTestFolder).find(n => /\.(png|jpe?g|gif|webp|avif)$/i.test(n))
240
- if (oneFile) fs.copyFileSync(path.join(tempTestFolder, oneFile), path.join(subDir, oneFile))
241
-
242
- // Build ignore list: specific file (if available) and directory (case-insensitive path)
243
- const ignoreArg = oneFile ? `--ignore=test/${oneFile},test/assets/` : `--ignore=test/assets/`
244
- // Snapshot the file placed in ignored directory, before running the CLI
245
- let preInside
246
- if (oneFile) {
247
- preInside = fs.statSync(path.join(subDir, oneFile))
248
- }
249
-
250
- // Snapshot before running CLI for file-level ignore check
251
- let preIgnored
252
- if (oneFile) {
253
- preIgnored = fs.statSync(path.join(tempTestFolder, oneFile))
254
- }
255
-
256
- // Build a pre-run snapshot of candidates that are not ignored
257
- const preSnapshot = new Map()
258
- fs.readdirSync(tempTestFolder).sort().forEach(name => {
259
- // Exclude the explicitly ignored file (if any) and anything inside the ignored directory
260
- if (oneFile && name === oneFile) return
261
- if (ignoreFiles.includes(name)) return // exclude corrupt fixture
262
- const ext = path.extname(name).slice(1).toLowerCase()
263
- if (!allowedFileTypes.includes(ext)) return
264
- const p = path.join(tempTestFolder, name)
265
- preSnapshot.set(name, fs.statSync(p))
266
- })
267
-
268
- const originalCwd = process.cwd()
269
- try {
270
- process.chdir(tempDir)
271
- execSync(`node "${imageGuardScript}" ${ignoreArg}`, { stdio: 'pipe' })
272
- } finally {
273
- process.chdir(originalCwd)
274
- }
275
-
276
- // Assert ignored file unchanged (only if there was a file to ignore explicitly)
277
- if (oneFile) {
278
- const ignoredCopy = fs.statSync(path.join(tempTestFolder, oneFile))
279
- assert.strictEqual(ignoredCopy.size, preIgnored.size)
280
- assert.strictEqual(ignoredCopy.mtime.getTime(), preIgnored.mtime.getTime())
281
- }
282
-
283
- // Assert that at least one non-ignored file in the root `tempTestFolder` was compressed
284
- let shrunkCount = 0
285
- for (const [name, statBefore] of preSnapshot) {
286
- const statAfter = fs.statSync(path.join(tempTestFolder, name))
287
- if (statAfter.size < statBefore.size) shrunkCount++
288
- }
289
- assert.ok(shrunkCount >= 1, 'Expected at least one non-ignored file to be compressed')
290
-
291
- // Assert file inside ignored directory unchanged (if created)
292
- if (oneFile) {
293
- const inside = fs.statSync(path.join(subDir, oneFile))
294
- // Since original may change, assert that the file inside ignored directory remained unchanged, by comparing to its own pre-run snapshot
295
- assert.strictEqual(inside.size, preInside.size)
296
- }
297
-
298
- fs.rmSync(tempDir, { recursive: true, force: true })
299
- })
300
-
301
- test('Ensure quiet mode suppresses per-file logs but keeps summary', () => {
302
- // Prepare isolated temp directory with test images
303
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-quiet-'))
304
- const tempTestFolder = path.join(tempDir, 'test')
305
- copyFiles(testFolder, tempTestFolder)
306
-
307
- const originalCwd = process.cwd()
308
- let stdout = ''
309
- try {
310
- process.chdir(tempDir)
311
- stdout = execSync(`node "${imageGuardScript}" --quiet`, { encoding: 'utf8' })
312
- } finally {
313
- process.chdir(originalCwd)
314
- fs.rmSync(tempDir, { recursive: true, force: true })
315
- }
316
-
317
- // Summary should be present
318
- assert.match(stdout, /Defensive base compression completed\./)
319
- // Per-file lines like “Compressed <file>” or “Skipped <file>” should be suppressed
320
- assert.strictEqual(!(/Compressed|Skipped/.test(stdout)), true)
321
- })
322
-
323
- test('Ensure dry and quiet runs leave no artifacts and do not mutate files', () => {
324
- // Use isolated temp directory
325
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-dry-quiet-'))
326
- const tempTestFolder = path.join(tempDir, 'test')
327
- copyFiles(testFolder, tempTestFolder)
328
-
329
- // Snapshot sizes/mtimes
330
- const before = fs.readdirSync(tempTestFolder).sort().map(file => {
331
- const filePath = path.join(tempTestFolder, file)
332
- return { file, stats: fs.statSync(filePath) }
333
- })
334
-
335
- const originalCwd = process.cwd()
336
- let stdout = ''
337
- try {
338
- process.chdir(tempDir)
339
- stdout = execSync(`node "${imageGuardScript}" --dry --quiet`, { encoding: 'utf8' })
340
- } finally {
341
- process.chdir(originalCwd)
342
- }
343
-
344
- // Summary present; no per-file lines
345
- assert.match(stdout, /There were no images to compress\.|Defensive base compression completed\./)
346
- assert.strictEqual(!(/Compressed|Skipped/.test(stdout)), true)
347
-
348
- // Verify no mutations
349
- const after = fs.readdirSync(tempTestFolder).sort().map(file => {
350
- const filePath = path.join(tempTestFolder, file)
351
- return { file, stats: fs.statSync(filePath) }
352
- })
353
- before.forEach((b, i) => {
354
- const a = after[i]
355
- assert.strictEqual(a.file, b.file)
356
- assert.strictEqual(a.stats.size, b.stats.size)
357
- assert.strictEqual(a.stats.mtime.getTime(), b.stats.mtime.getTime())
358
- })
359
-
360
- // Ensure no temp or backup artifacts present
361
- const entries = fs.readdirSync(tempTestFolder)
362
- const hasTemp = entries.some(name => name.startsWith('.image-guard-'))
363
- const hasBak = entries.some(name => name.endsWith('.bak'))
364
- assert.strictEqual(hasTemp, false)
365
- assert.strictEqual(hasBak, false)
366
-
367
- // Cleanup
368
- fs.rmSync(tempDir, { recursive: true, force: true })
369
- })
370
-
371
- test('Ensure no .bak files remain after normal compression', () => {
372
- // Prepare isolated temp directory with test images
373
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-bak-'))
374
- const tempTestFolder = path.join(tempDir, 'test')
375
- copyFiles(testFolder, tempTestFolder)
376
-
377
- const originalCwd = process.cwd()
378
- try {
379
- process.chdir(tempDir)
380
- execSync(`node "${imageGuardScript}"`, { stdio: 'pipe' })
381
- } finally {
382
- process.chdir(originalCwd)
383
- }
384
-
385
- const entries = fs.readdirSync(tempTestFolder)
386
- const hasBak = entries.some(name => name.endsWith('.bak'))
387
- assert.strictEqual(hasBak, false)
388
-
389
- // Cleanup
390
- fs.rmSync(tempDir, { recursive: true, force: true })
391
- })
392
-
393
- test('Convert HEIC to AVIF with `--heic-to-avif`', () => {
394
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-'))
395
- const tempTestFolder = path.join(tempDir, 'test')
396
- copyFiles(testFolder, tempTestFolder)
397
-
398
- // Ensure HEIC test fixture exists
399
- const heicFile = path.join(tempTestFolder, 'test.heic')
400
- assert.strictEqual(fs.existsSync(heicFile), true, 'HEIC test fixture must exist')
401
-
402
- // Remove pre-existing AVIF so we can verify the conversion actually creates it
403
- const avifFile = path.join(tempTestFolder, 'test.avif')
404
- if (fs.existsSync(avifFile)) {
405
- fs.unlinkSync(avifFile)
406
- }
407
-
408
- const originalCwd = process.cwd()
409
- try {
410
- process.chdir(tempDir)
411
- execSync(`node "${imageGuardScript}" --heic-to-avif`, { stdio: 'pipe' })
412
- } finally {
413
- process.chdir(originalCwd)
414
- }
415
-
416
- // AVIF should be created by the conversion
417
- assert.strictEqual(fs.existsSync(avifFile), true, 'AVIF file should be created')
418
- const avifSize = fs.statSync(avifFile).size
419
- assert.ok(avifSize > 0, 'AVIF file should not be empty')
420
-
421
- // Lossy AVIF should not be larger than the HEIC source
422
- const heicSize = fs.statSync(path.join(testFolder, 'test.heic')).size
423
- assert.ok(avifSize <= heicSize, `AVIF (${avifSize}) should not be larger than HEIC (${heicSize})`)
424
-
425
- // Original HEIC should be deleted
426
- assert.strictEqual(fs.existsSync(heicFile), false, 'Original HEIC should be deleted')
427
-
428
- fs.rmSync(tempDir, { recursive: true, force: true })
429
- })
430
-
431
- test('Ensure `--keep-heic` preserves original HEIC file', () => {
432
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-keep-heic-'))
433
- const tempTestFolder = path.join(tempDir, 'test')
434
- copyFiles(testFolder, tempTestFolder)
435
-
436
- const heicFile = path.join(tempTestFolder, 'test.heic')
437
- assert.strictEqual(fs.existsSync(heicFile), true, 'HEIC test fixture must exist')
438
-
439
- const originalCwd = process.cwd()
440
- try {
441
- process.chdir(tempDir)
442
- execSync(`node "${imageGuardScript}" --heic-to-avif --keep-heic`, { stdio: 'pipe' })
443
- } finally {
444
- process.chdir(originalCwd)
445
- }
446
-
447
- // Both files should exist
448
- assert.strictEqual(fs.existsSync(path.join(tempTestFolder, 'test.avif')), true, 'AVIF file should be created')
449
- assert.strictEqual(fs.existsSync(heicFile), true, 'HEIC file should be preserved with `--keep-heic`')
450
-
451
- fs.rmSync(tempDir, { recursive: true, force: true })
452
- })
453
-
454
- test('Ensure dry run does not convert HEIC files but reports sizes', () => {
455
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-dry-'))
456
- const tempTestFolder = path.join(tempDir, 'test')
457
- copyFiles(testFolder, tempTestFolder)
458
-
459
- // Remove pre-existing AVIF so the conversion path is exercised
460
- const avifFile = path.join(tempTestFolder, 'test.avif')
461
- if (fs.existsSync(avifFile)) {
462
- fs.unlinkSync(avifFile)
463
- }
464
-
465
- const heicFile = path.join(tempTestFolder, 'test.heic')
466
- const heicBefore = fs.statSync(heicFile)
467
- const filesBefore = fs.readdirSync(tempTestFolder).sort()
468
-
469
- const originalCwd = process.cwd()
470
- let output = ''
471
- try {
472
- process.chdir(tempDir)
473
- output = execSync(`node "${imageGuardScript}" --heic-to-avif --dry`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
474
- } finally {
475
- process.chdir(originalCwd)
476
- }
477
-
478
- // HEIC should be unchanged
479
- const heicAfter = fs.statSync(heicFile)
480
- assert.strictEqual(heicAfter.size, heicBefore.size)
481
- assert.strictEqual(heicAfter.mtime.getTime(), heicBefore.mtime.getTime())
482
-
483
- // No files should be added or removed
484
- const filesAfter = fs.readdirSync(tempTestFolder).sort()
485
- assert.deepStrictEqual(filesAfter, filesBefore)
486
-
487
- // Dry run should report size data for HEIC conversion
488
- assert.match(output, /Converted.*test\.heic.*KB.*→.*KB/i, 'Dry run should report HEIC conversion sizes')
489
-
490
- fs.rmSync(tempDir, { recursive: true, force: true })
491
- })
492
-
493
- test('Ensure HEIC files are ignored without `--heic-to-avif` flag', () => {
494
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-noflag-'))
495
- const tempTestFolder = path.join(tempDir, 'test')
496
- copyFiles(testFolder, tempTestFolder)
497
-
498
- const heicFile = path.join(tempTestFolder, 'test.heic')
499
- const heicBefore = fs.statSync(heicFile)
500
-
501
- const originalCwd = process.cwd()
502
- try {
503
- process.chdir(tempDir)
504
- execSync(`node "${imageGuardScript}"`, { stdio: 'pipe' })
505
- } finally {
506
- process.chdir(originalCwd)
507
- }
508
-
509
- // HEIC should be untouched
510
- const heicAfter = fs.statSync(heicFile)
511
- assert.strictEqual(heicAfter.size, heicBefore.size)
512
- assert.strictEqual(heicAfter.mtime.getTime(), heicBefore.mtime.getTime())
513
-
514
- fs.rmSync(tempDir, { recursive: true, force: true })
515
- })
516
-
517
- test('Skip and report corrupt files', () => {
518
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-corrupt-'))
519
- const tempTestFolder = path.join(tempDir, 'test')
520
- copyFiles(testFolder, tempTestFolder)
521
-
522
- const originalCwd = process.cwd()
523
- let output = ''
524
- try {
525
- process.chdir(tempDir)
526
- output = execSync(`node "${imageGuardScript}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
527
- } finally {
528
- process.chdir(originalCwd)
529
- fs.rmSync(tempDir, { recursive: true, force: true })
530
- }
531
-
532
- assert.match(output, /Skipped.*test#corrupt\.gif.*corrupt file/i)
533
- })
534
-
535
- test('Ensure `--keep-heic` without `--heic-to-avif` issues warning', () => {
536
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-keep-warn-'))
537
- const tempTestFolder = path.join(tempDir, 'test')
538
- copyFiles(testFolder, tempTestFolder)
539
-
540
- const originalCwd = process.cwd()
541
- let output = ''
542
- try {
543
- process.chdir(tempDir)
544
- output = execSync(`node "${imageGuardScript}" --keep-heic 2>&1`, { encoding: 'utf8' })
545
- } finally {
546
- process.chdir(originalCwd)
547
- }
548
-
549
- assert.match(output, /`--keep-heic` has no effect without `--heic-to-avif`/)
550
-
551
- fs.rmSync(tempDir, { recursive: true, force: true })
552
- })
553
- })
package/eslint.config.js DELETED
@@ -1,26 +0,0 @@
1
- export default [
2
- {
3
- ignores: [
4
- 'media/',
5
- 'node_modules/**'
6
- ]
7
- },
8
- {
9
- languageOptions: {
10
- ecmaVersion: 'latest',
11
- sourceType: 'module',
12
- globals: {
13
- console: 'readonly',
14
- process: 'readonly',
15
- setTimeout: 'readonly'
16
- }
17
- },
18
- rules: {
19
- 'no-console': 'off', // CLI tool—allow console
20
- 'no-undef': 'error', // Catch undefined vars
21
- 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
22
- eqeqeq: ['warn', 'smart'],
23
- curly: ['warn', 'multi-line']
24
- }
25
- }
26
- ]
package/media/output.png DELETED
Binary file