image-guard 5.4.0 → 5.4.2

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
@@ -158,11 +158,24 @@ Image Guard is no substitute for image fine-tuning and micro-optimization. That
158
158
 
159
159
  That is, micro-optimization still needs to be taken care of through other means, whether manually or through tools. Image Guard just solves the problem that images are checked in or go live that are not compressed _at all_.
160
160
 
161
- ## What’s Next?
161
+ ## Working on Image Guard
162
162
 
163
- There are a few ideas, like adding light SVG support, or ensuring compatibility with projects in which the project’s .git folder is not at the same level as its package.json (currently, automatic mode doesn’t work in these cases).
163
+ ### Benchmarking
164
164
 
165
- Feedback is appreciated: Please [file an issue](https://github.com/j9t/image-guard/issues/new) or send a pull request. Thank you!
165
+ To check how a change affects speed, memory, and compression, run the benchmark on any local folder with images:
166
+
167
+ ```shell
168
+ npm run benchmark -- ~/Projects
169
+ ```
170
+
171
+ The benchmark copies a deterministic sample of the images (honoring .gitignore) to a temporary folder, runs Image Guard on fresh copies of it several times, and reports the median time, peak memory, and bytes saved. Your images are not modified.
172
+
173
+ * `--runs <n>` sets the number of runs (default: 3).
174
+ * `--sample <n>` sets the approximate sample size (default: 300). Each format is sampled in proportion to its share, with at least 10 files per format where available.
175
+ * `--save` saves the result as the baseline (benchmark/baseline.json, ignored by Git), which later runs on the same sample compare against.
176
+ * `--profile` additionally compresses each image on its own, in dry mode, and reports the time per format and the slowest files.
177
+
178
+ To compare a branch with `main`, run the benchmark with `--save` on `main`, then without it on the branch. Differences of around 10% can be noise; use more runs, and repeat a run on `main`, before relying on smaller ones.
166
179
 
167
180
  ## License
168
181
 
package/package.json CHANGED
@@ -6,16 +6,20 @@
6
6
  "imagemin-merlin": "bin/image-guard.sh"
7
7
  },
8
8
  "dependencies": {
9
- "globby": "^16.2.3",
10
9
  "heic-decode": "^2.1.0",
11
- "sharp": "^0.35.3",
12
- "simple-git": "^3.36.0"
10
+ "ignore": "^7.0.12",
11
+ "sharp": "^0.35.5",
12
+ "simple-git": "^4.0.2",
13
+ "tinyglobby": "^0.2.17"
13
14
  },
14
15
  "description": "Near-lossless image compressor that’s easy to use and easy to automate",
15
16
  "devDependencies": {
16
17
  "@eslint/js": "^10.0.1",
17
- "eslint": "^10.8.1",
18
- "globals": "^17.11.0"
18
+ "eslint": "^10.12.0",
19
+ "globals": "^17.13.0"
20
+ },
21
+ "engines": {
22
+ "node": ">=22.13"
19
23
  },
20
24
  "files": [
21
25
  "bin/image-guard.js",
@@ -49,11 +53,12 @@
49
53
  "url": "git+https://github.com/j9t/image-guard.git"
50
54
  },
51
55
  "scripts": {
56
+ "benchmark": "node benchmark/benchmark.js",
52
57
  "lint": "eslint .",
53
58
  "lint:fix": "eslint . --fix",
54
59
  "prepare": "git config core.hooksPath .githooks || true",
55
- "test": "node --test bin/image-guard.test.js"
60
+ "test": "node --test bin/image-guard.test.js benchmark/benchmark.test.js"
56
61
  },
57
62
  "type": "module",
58
- "version": "5.4.0"
63
+ "version": "5.4.2"
59
64
  }
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import ignore from 'ignore'
4
+
5
+ function findGitRoot(dir) {
6
+ for (let current = dir; ; current = path.dirname(current)) {
7
+ if (fs.existsSync(path.join(current, '.git'))) return current
8
+ if (path.dirname(current) === current) return undefined
9
+ }
10
+ }
11
+
12
+ // Returns a predicate for absolute paths under `cwd` that applies .gitignore
13
+ // files from the repository root (or `cwd` outside a repository) down to each
14
+ // path, as Git does: Deeper rules take precedence, and nothing inside an
15
+ // ignored directory can be re-included
16
+ export function createGitignoreFilter(cwd) {
17
+ const base = path.resolve(cwd)
18
+ const top = findGitRoot(base) ?? base
19
+ const rulesByDir = new Map()
20
+
21
+ const getRules = (dir) => {
22
+ if (!rulesByDir.has(dir)) {
23
+ let rules
24
+ try {
25
+ rules = ignore().add(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'))
26
+ } catch (err) {
27
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err
28
+ }
29
+ rulesByDir.set(dir, rules)
30
+ }
31
+ return rulesByDir.get(dir)
32
+ }
33
+
34
+ return (file) => {
35
+ const segments = path.relative(top, file).split(path.sep)
36
+ for (let i = 1; i <= segments.length; i++) {
37
+ const suffix = i < segments.length ? '/' : ''
38
+ let ignored = false
39
+ for (let j = 0; j < i; j++) {
40
+ const rules = getRules(path.join(top, ...segments.slice(0, j)))
41
+ if (!rules) continue
42
+ const result = rules.test(segments.slice(j, i).join('/') + suffix)
43
+ if (result.ignored) ignored = true
44
+ else if (result.unignored) ignored = false
45
+ }
46
+ if (ignored) return true
47
+ }
48
+ return false
49
+ }
50
+ }
package/src/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  // This file, which had been forked from imagemin-merlin, was modified for image-guard: https://github.com/sumcumo/imagemin-merlin/compare/master...j9t:master
2
2
 
3
- import { globby, convertPathToPattern } from 'globby'
4
- import simpleGit from 'simple-git'
3
+ import { glob, convertPathToPattern } from 'tinyglobby'
4
+ import { simpleGit } from 'simple-git'
5
5
  import { parseArgs, styleText } from 'node:util'
6
6
  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 { createGitignoreFilter } from './gitignore.js'
10
11
  import { styleStderr, utils } from './utils.js'
11
12
 
12
13
  // Files to be compressed
@@ -147,8 +148,14 @@ Options:
147
148
  })
148
149
  }
149
150
 
150
- const desiredFileConcurrency = Math.min(os.cpus().length, 4)
151
- const perTaskThreads = Math.max(1, Math.floor(os.cpus().length / Math.max(1, desiredFileConcurrency)))
151
+ // Capped to bound memory, as each file in flight holds its decoded pixels
152
+ const desiredFileConcurrency = Math.min(os.availableParallelism(), 8)
153
+ const perTaskThreads = Math.max(1, Math.floor(os.availableParallelism() / desiredFileConcurrency))
154
+
155
+ // sharp runs on libuv’s thread pool (4 threads by default), which caps files
156
+ // in flight regardless of the limiter; this must happen before the first
157
+ // asynchronous I/O and respects a user setting
158
+ process.env.UV_THREADPOOL_SIZE ??= String(desiredFileConcurrency)
152
159
  try {
153
160
  sharp.concurrency(perTaskThreads)
154
161
  } catch {
@@ -194,7 +201,7 @@ Options:
194
201
  }
195
202
 
196
203
  // Plain patterns also get a “/**” variant so directories are excluded with
197
- // their contents: “dir/” resolves only where globby can stat it, and a bare
204
+ // their contents: “dir/” resolves only where tinyglobby can stat it, and a bare
198
205
  // “dir” only covers what the walk descends into
199
206
  const getIgnorePatterns = (ignore) => {
200
207
  return (ignore || '')
@@ -223,14 +230,14 @@ Options:
223
230
  // lookup are relative to the searched directory, not to the shell’s—and the
224
231
  // results are rejoined for display and file access
225
232
  const findFiles = async (patterns, options = {}) => {
226
- const files = await globby(patterns, {
233
+ const files = await glob(patterns, {
227
234
  cwd: dir,
228
- gitignore: true,
229
235
  onlyFiles: true,
230
236
  caseSensitiveMatch: false,
231
237
  ...options
232
238
  })
233
- return files.map(file => path.join(dir, file))
239
+ const isGitignored = createGitignoreFilter(dir)
240
+ return files.map(file => path.join(dir, file)).filter(file => !isGitignored(path.resolve(file)))
234
241
  }
235
242
 
236
243
  const filterStagedFiles = async (stagedFiles, types) => {
@@ -240,12 +247,13 @@ Options:
240
247
 
241
248
  if (ignoreList.length > 0) {
242
249
  const escapedPaths = byExt.map(p => convertPathToPattern(p))
243
- return globby([...escapedPaths, ...ignoreList], {
244
- gitignore: true,
250
+ const files = await glob([...escapedPaths, ...ignoreList], {
245
251
  expandDirectories: false,
246
252
  onlyFiles: true,
247
253
  caseSensitiveMatch: false
248
254
  })
255
+ const isGitignored = createGitignoreFilter('.')
256
+ return files.filter(file => !isGitignored(path.resolve(file)))
249
257
  }
250
258
  return byExt
251
259
  }