image-guard 5.2.5 → 5.3.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
@@ -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/sponsors/j9t)
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;\
@@ -101,21 +101,25 @@ npm pkg set scripts.postprepare="grep -qxF './node_modules/.bin/image-guard --st
101
101
 
102
102
  (The `postprepare` script ensures that the hook is added to the repository whenever someone installs the package.)
103
103
 
104
+ If you give the hook options like `--ignore`, add them to the `postprepare` script as well—the hook command is the only place to configure Image Guard, and the next install rewrites that line.
105
+
104
106
  **Important:** When you commit images that have not yet been compressed, the automated compression process (triggered by the pre-commit hook) will modify those image files to reduce their size. As a result, after your initial commit attempt, you will see these images appear as changed files in Git. To include the optimized images in your repository, you need to stage and commit them again. In rare cases, if further compression is possible, you may need to repeat this process until no further changes are detected. This workflow is intentional and ensures that only optimally compressed images are committed. Many editors can display diffs for images, helping you review these changes.
105
107
 
106
108
  ### Parameters
107
109
 
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
+
108
112
  * `--dry` allows you to run Image Guard in “dry mode.” All changes are shown in the terminal.
109
113
 
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`.
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.
111
115
 
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.
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.
113
117
 
114
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.)
115
119
 
116
120
  - `--keep-heic` (used with `--heic-to-avif`) preserves the original HEIC/HEIF files instead of deleting them after conversion.
117
121
 
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.
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.
119
123
 
120
124
  ## What Does the Output Look Like?
121
125
 
@@ -128,7 +132,7 @@ Roughly like this:
128
132
  * White (light gray): The image file has not been changed.
129
133
  * Blue: The image file had already been compressed more effectively than the new result, and was therefore skipped, too.
130
134
 
131
- Tip: Use `--quiet` to suppress these per‑file lines and keep only the final summary.
135
+ Tip: Use `--quiet` to suppress these per-file lines and keep only the final summary.
132
136
 
133
137
  ## How Does Image Guard Work?
134
138
 
@@ -165,4 +169,12 @@ Licensed under the Apache License, Version 2.0 (the “License”); you may not
165
169
 
166
170
  http://www.apache.org/licenses/LICENSE-2.0
167
171
 
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.
172
+ 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.
173
+
174
+ ***
175
+
176
+ You might like some of my other work:
177
+
178
+ * 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)
179
+ * Defense tools: [IA Defensa](https://iadefensa.com/solutions/)
180
+ * 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,16 +6,22 @@
6
6
  "imagemin-merlin": "bin/image-guard.sh"
7
7
  },
8
8
  "dependencies": {
9
- "globby": "^16.1.0",
9
+ "globby": "^16.2.3",
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/js": "^10.0.0",
17
- "eslint": "^10.0.0"
16
+ "@eslint/js": "^10.0.1",
17
+ "eslint": "^10.8.1"
18
18
  },
19
+ "files": [
20
+ "bin/image-guard.js",
21
+ "bin/image-guard.sh",
22
+ "NOTICE.md",
23
+ "src"
24
+ ],
19
25
  "funding": "https://github.com/j9t/image-guard?sponsor=1",
20
26
  "homepage": "https://github.com/j9t/image-guard",
21
27
  "keywords": [
@@ -44,8 +50,9 @@
44
50
  "scripts": {
45
51
  "lint": "eslint .",
46
52
  "lint:fix": "eslint . --fix",
53
+ "prepare": "git config core.hooksPath .githooks || true",
47
54
  "test": "node --test bin/image-guard.test.js"
48
55
  },
49
56
  "type": "module",
50
- "version": "5.2.5"
57
+ "version": "5.3.0"
51
58
  }
package/src/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { globby, convertPathToPattern } from 'globby'
4
4
  import simpleGit from 'simple-git'
5
5
  import { parseArgs, styleText } from 'node:util'
6
+ import fsSync from 'node:fs'
6
7
  import os from 'node:os'
7
8
  import path from 'node:path'
8
9
  import sharp from 'sharp'
@@ -23,7 +24,21 @@ export async function runImageGuard() {
23
24
  staged: { type: 'boolean', default: false },
24
25
  quiet: { type: 'boolean', default: false }
25
26
  }
26
- const { values: argv } = parseArgs({ options })
27
+ const { values: argv, positionals } = parseArgs({ options, allowPositionals: true })
28
+
29
+ if (positionals.length > 1) {
30
+ throw new Error(`Expected at most one path, got ${positionals.length}: ${positionals.join(', ')}`)
31
+ }
32
+
33
+ const dir = positionals[0] || '.'
34
+
35
+ if (positionals.length && argv.staged) {
36
+ throw new Error('`--staged` takes its files from Git, not from a path—pass one or the other.')
37
+ }
38
+
39
+ if (!fsSync.existsSync(dir) || !fsSync.statSync(dir).isDirectory()) {
40
+ throw new Error(`No such directory: ${dir}`)
41
+ }
27
42
 
28
43
  // Share status
29
44
  const summary = (run, includesConversion = false) => {
@@ -42,7 +57,8 @@ export async function runImageGuard() {
42
57
 
43
58
  const allTypes = argv['heic-to-avif'] ? [...fileTypes, ...convertTypes] : fileTypes
44
59
  if (!argv.quiet) {
45
- console.log(`(Search pattern: ${allTypes.join(', ')})\n`)
60
+ const where = dir === '.' ? '' : `, in ${dir}`
61
+ console.log(`(Search pattern: ${allTypes.join(', ')}${where})\n`)
46
62
  }
47
63
 
48
64
  let savedKB = 0
@@ -129,12 +145,15 @@ export async function runImageGuard() {
129
145
  return hadFailures
130
146
  }
131
147
 
148
+ // Plain patterns also get a “/**” variant so directories are excluded with
149
+ // their contents: “dir/” resolves only where globby can stat it, and a bare
150
+ // “dir” only covers what the walk descends into
132
151
  const getIgnorePatterns = (ignore) => {
133
152
  return (ignore || '')
134
153
  .split(',')
135
- .map(s => s.trim())
154
+ .map(s => s.trim().replace(/^!/, '').replace(/\/+$/, ''))
136
155
  .filter(Boolean)
137
- .map(p => (p.startsWith('!') ? p : `!${p}`))
156
+ .flatMap(p => (/[*?[\]{}]/.test(p) ? [`!${p}`] : [`!${p}`, `!${p}/**`]))
138
157
  }
139
158
 
140
159
  const getFilePattern = (ignore, types = fileTypes) => {
@@ -152,13 +171,18 @@ export async function runImageGuard() {
152
171
  return patterns
153
172
  }
154
173
 
174
+ // Globbing runs with `dir` as its base—so ignore patterns and `.gitignore`
175
+ // lookup are relative to the searched directory, not to the shell’s—and the
176
+ // results are rejoined for display and file access
155
177
  const findFiles = async (patterns, options = {}) => {
156
- return globby(patterns, {
178
+ const files = await globby(patterns, {
179
+ cwd: dir,
157
180
  gitignore: true,
158
181
  onlyFiles: true,
159
182
  caseSensitiveMatch: false,
160
183
  ...options
161
184
  })
185
+ return files.map(file => path.join(dir, file))
162
186
  }
163
187
 
164
188
  const filterStagedFiles = async (stagedFiles, types) => {
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,55 +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.5] - 2026-03-17
8
-
9
- ### Changed
10
-
11
- * Added recommended ESLint rules to config (and fixed resulting warnings)
12
- * Promoted `no-unused-vars` and `eqeqeq` from warnings to errors in ESLint config
13
-
14
- ## [5.2.4] - 2026-03-04
15
-
16
- ### Fixed
17
-
18
- * 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
19
- - **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`.
20
-
21
- ## [5.2.3] - 2026-02-21
22
-
23
- ### Fixed
24
-
25
- * 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
26
- * Warned when a `.bak` file is left behind after a failed file replacement
27
- * Removed a dead code branch in result processing
28
-
29
- ### Changed
30
-
31
- * Moved to compression and conversion running in parallel (non-overlapping file sets), reducing wall-clock time when `--heic-to-avif` is active
32
- * Reduced directory traversals from two to one in non-staged mode when `--heic-to-avif` is active
33
- * Extracted shared `MAX_FILE_SIZE` constant; aligned `node:fs` and `node:path` import style in `utils.js`
34
- * Extended test coverage for corrupt file detection and reporting
35
-
36
- ## [5.2.2] - 2026-02-17
37
-
38
- ### Fixed
39
-
40
- * Updated HEIC dry run to report actual file sizes instead of omitting size data
41
-
42
- ## [5.2.1] - 2026-02-17
43
-
44
- ### Changed
45
-
46
- * 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
47
- * Removed redundant lossless compression pass after HEIC conversion
48
- * Documented Display P3 to sRGB color space mapping during HEIC conversion
49
-
50
- ## [5.2.0] - 2026-02-17
51
-
52
- ### Added
53
-
54
- * Added opt-in HEIC/HEIF-to-AVIF conversion via `--heic-to-avif` (with `--keep-heic` to preserve originals)
55
- * 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,28 +0,0 @@
1
- import js from '@eslint/js';
2
-
3
- export default [
4
- {
5
- ignores: [
6
- 'media/',
7
- 'node_modules/**'
8
- ]
9
- },
10
- js.configs.recommended,
11
- {
12
- languageOptions: {
13
- ecmaVersion: 'latest',
14
- sourceType: 'module',
15
- globals: {
16
- console: 'readonly',
17
- process: 'readonly',
18
- setTimeout: 'readonly'
19
- }
20
- },
21
- rules: {
22
- 'no-console': 'off', // CLI tool—allow console
23
- 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
24
- eqeqeq: ['error', 'smart'],
25
- curly: ['warn', 'multi-line']
26
- }
27
- }
28
- ]
package/media/output.png DELETED
Binary file