avifify 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # avifify
2
+
3
+ Production-ready CLI tool to convert images to AVIF format. Run on demand, in CI/CD pipelines, or as a git pre-push hook.
4
+
5
+ AVIF offers **30-60% better compression** than JPEG and **20-40% better** than WebP at equivalent visual quality. This tool makes adoption painless.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ # Install
11
+ npm install avifify --save-dev
12
+
13
+ # Convert all images in the current directory
14
+ npx avifify
15
+
16
+ # Convert specific paths
17
+ npx avifify src/assets "public/images/**/*.png"
18
+
19
+ # Preview without changing anything
20
+ npx avifify --dry-run --verbose
21
+
22
+ # Install as a git pre-push hook
23
+ npx avifify hook install
24
+ ```
25
+
26
+ ## Features
27
+
28
+ - **Fast** — parallel conversion using [sharp](https://sharp.pixelplumbing.com/) (libvips)
29
+ - **Smart defaults** — quality 50, skips if AVIF is larger, auto-concurrency
30
+ - **Git hook ready** — install as `pre-push` or `pre-commit` hook in one command
31
+ - **Pipeline friendly** — `--json` output, proper exit codes, non-TTY aware
32
+ - **Configurable** — `.avififyrc.json`, `package.json`, or CLI flags
33
+ - **Safe** — dry-run mode, skip-larger protection, preserves originals on demand
34
+ - **Programmatic API** — import and use in Node.js scripts or build tools
35
+
36
+ ## Supported Input Formats
37
+
38
+ JPEG, PNG, WebP, TIFF, GIF, BMP, HEIF/HEIC
39
+
40
+ > SVG is excluded by default (vector format — converting to AVIF is lossy and almost never desirable). You can force it by passing SVG files explicitly.
41
+
42
+ ## CLI Reference
43
+
44
+ ```
45
+ avifify [options] [paths...]
46
+ ```
47
+
48
+ ### Encoding Options
49
+
50
+ | Flag | Default | Description |
51
+ |------|---------|-------------|
52
+ | `-q, --quality <n>` | `50` | AVIF quality (1-100, lower = smaller) |
53
+ | `-s, --speed <n>` | `5` | Encoding speed (0-8, lower = better compression) |
54
+ | `--lossless` | `false` | Lossless encoding |
55
+ | `--chroma <mode>` | `4:2:0` | Chroma subsampling (`4:2:0` or `4:4:4`) |
56
+
57
+ ### Output Options
58
+
59
+ | Flag | Default | Description |
60
+ |------|---------|-------------|
61
+ | `-o, --out-dir <dir>` | in-place | Write AVIF files to a separate directory |
62
+ | `--suffix <str>` | none | Suffix before extension (e.g. `--suffix .min` → `photo.min.avif`) |
63
+ | `--preserve` | `false` | Keep original files after conversion |
64
+
65
+ ### Behavior Options
66
+
67
+ | Flag | Default | Description |
68
+ |------|---------|-------------|
69
+ | `--skip-larger` | `true` | Don't keep AVIF if it's bigger than the original |
70
+ | `--no-skip-larger` | | Always keep AVIF output |
71
+ | `--skip-existing` | `false` | Skip conversion if `.avif` already exists |
72
+ | `--staged-only` | `false` | Only process git-staged images |
73
+ | `--changed-since-push` | `false` | Only process images changed since last push |
74
+ | `-c, --concurrency <n>` | CPU-1 | Number of parallel conversions |
75
+ | `-n, --dry-run` | `false` | Preview changes without writing files |
76
+
77
+ ### Output Options
78
+
79
+ | Flag | Description |
80
+ |------|-------------|
81
+ | `--json` | JSON Lines output (for piping to `jq` etc.) |
82
+ | `-v, --verbose` | Detailed per-file progress |
83
+ | `--debug` | Everything (very noisy) |
84
+ | `--silent` | Suppress all output |
85
+
86
+ ### Hook Commands
87
+
88
+ ```bash
89
+ avifify hook install # Install pre-push hook
90
+ avifify hook install --hook-type pre-commit # Use pre-commit instead
91
+ avifify hook uninstall # Remove the hook
92
+ ```
93
+
94
+ ### Exit Codes
95
+
96
+ | Code | Meaning |
97
+ |------|---------|
98
+ | `0` | All files converted (or nothing to do) |
99
+ | `1` | Some files failed to convert |
100
+ | `2` | Bad arguments or config error |
101
+
102
+ ## Configuration
103
+
104
+ Config is resolved with this precedence: **CLI flags > `.avififyrc.json` > `package.json` > defaults**
105
+
106
+ ### `.avififyrc.json`
107
+
108
+ ```json
109
+ {
110
+ "quality": 40,
111
+ "speed": 4,
112
+ "include": ["src/images/**"],
113
+ "exclude": ["**/thumbnails/**"],
114
+ "preserveOriginal": true,
115
+ "skipLarger": true
116
+ }
117
+ ```
118
+
119
+ ### `package.json`
120
+
121
+ ```json
122
+ {
123
+ "avifify": {
124
+ "quality": 40,
125
+ "include": ["assets/**"]
126
+ }
127
+ }
128
+ ```
129
+
130
+ ## Git Hook Integration
131
+
132
+ ### Standalone
133
+
134
+ ```bash
135
+ npx avifify hook install
136
+ ```
137
+
138
+ This creates a `pre-push` hook that:
139
+ 1. Scans staged image files
140
+ 2. Converts them to AVIF
141
+ 3. Auto-stages the new `.avif` files
142
+ 4. Blocks the push if conversion fails
143
+
144
+ ### With Husky
145
+
146
+ If you're already using Husky, `avifify` detects the `.husky/` directory and installs there:
147
+
148
+ ```bash
149
+ npx avifify hook install
150
+ # Creates .husky/pre-push with avifify block
151
+ ```
152
+
153
+ Or add it manually to an existing Husky hook:
154
+
155
+ ```bash
156
+ # .husky/pre-push
157
+ npx avifify --staged-only
158
+ ```
159
+
160
+ ### CI/CD Pipeline
161
+
162
+ ```yaml
163
+ # GitHub Actions example
164
+ - name: Convert images to AVIF
165
+ run: npx avifify --json --verbose
166
+ ```
167
+
168
+ ```yaml
169
+ # GitLab CI example
170
+ optimize-images:
171
+ script:
172
+ - npx avifify --json | tee avifify-report.json
173
+ artifacts:
174
+ reports:
175
+ dotenv: avifify-report.json
176
+ ```
177
+
178
+ ## Programmatic API
179
+
180
+ ```js
181
+ import { avifify, convertFile } from 'avifify';
182
+
183
+ // Batch convert
184
+ const results = await avifify({
185
+ quality: 40,
186
+ paths: ['src/images'],
187
+ preserveOriginal: true,
188
+ });
189
+
190
+ console.log(`Converted ${results.filter(r => r.status === 'converted').length} files`);
191
+
192
+ // Single file
193
+ const result = await convertFile('photo.jpg', { quality: 50 });
194
+ console.log(result);
195
+ // { file: 'photo.jpg', output: 'photo.avif', status: 'converted', savedBytes: 123456, ... }
196
+ ```
197
+
198
+ ## Why AVIF?
199
+
200
+ | Format | Lossy Compression | Browser Support (2024) |
201
+ |--------|-------------------|----------------------|
202
+ | JPEG | Baseline | 100% |
203
+ | WebP | ~30% better than JPEG | 97% |
204
+ | **AVIF** | **~50% better than JPEG** | **95%+** |
205
+
206
+ AVIF supports transparency, HDR, wide color gamuts, and both lossy and lossless modes. It's the clear successor for web images.
207
+
208
+ ## Quality Guide
209
+
210
+ | Quality | Use Case | Typical Savings |
211
+ |---------|----------|-----------------|
212
+ | 30-40 | Thumbnails, backgrounds | 70-80% smaller |
213
+ | 50 | General web images (default) | 50-65% smaller |
214
+ | 60-70 | Photography, hero images | 40-55% smaller |
215
+ | 80-90 | High-fidelity, print-ready | 20-35% smaller |
216
+ | 100 / `--lossless` | Archival, pixel-perfect | 10-30% smaller |
217
+
218
+ ## License
219
+
220
+ MIT
package/bin/avifify.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * avifify - Convert images to AVIF format
5
+ *
6
+ * Usage:
7
+ * avifify [options] [paths...]
8
+ * avifify hook install
9
+ * avifify hook uninstall
10
+ *
11
+ * Run `avifify --help` for full usage info.
12
+ */
13
+
14
+ import { run } from '../src/cli.js';
15
+
16
+ run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "avifify",
3
+ "version": "1.0.0",
4
+ "description": "Production-ready CLI tool to convert images to AVIF. Run standalone, in CI/CD pipelines, or as a git pre-push hook.",
5
+ "keywords": [
6
+ "avif",
7
+ "image",
8
+ "converter",
9
+ "compression",
10
+ "optimize",
11
+ "git-hook",
12
+ "pre-push",
13
+ "cli",
14
+ "pipeline",
15
+ "sharp"
16
+ ],
17
+ "author": "is-harshul",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ },
23
+ "bin": {
24
+ "avifify": "./bin/avifify.js"
25
+ },
26
+ "main": "./src/index.js",
27
+ "exports": {
28
+ ".": "./src/index.js"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "src/",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "test": "node --test 'tests/**/*.test.js'",
38
+ "lint": "echo 'Add your linter here'",
39
+ "prepublishOnly": "npm test"
40
+ },
41
+ "dependencies": {
42
+ "sharp": "^0.33.0",
43
+ "fast-glob": "^3.3.0"
44
+ },
45
+ "devDependencies": {
46
+ "c8": "^8.0.0"
47
+ }
48
+ }
package/src/cli.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * CLI entry point — parses args, resolves config, orchestrates conversion.
3
+ */
4
+
5
+ import { resolveConfig } from './config.js';
6
+ import { scanFiles, getChangedSinceLastPush } from './scanner.js';
7
+ import { convertBatch } from './converter.js';
8
+ import { createLogger } from './logger.js';
9
+ import { installHook, uninstallHook } from './hooks.js';
10
+
11
+ const VERSION = '1.0.0';
12
+
13
+ const HELP = `
14
+ avifify v${VERSION}
15
+ Convert images to AVIF — on demand, in CI/CD, or as a git hook.
16
+
17
+ USAGE
18
+ avifify [options] [paths...] Convert matching images to AVIF
19
+ avifify hook install [--hook-type] Install git hook (default: pre-push)
20
+ avifify hook uninstall Remove git hook
21
+
22
+ PATHS
23
+ Provide files, directories, or glob patterns.
24
+ Defaults to scanning the current directory using include/exclude config.
25
+
26
+ OPTIONS
27
+ -q, --quality <n> AVIF quality 1-100 (default: 50)
28
+ -s, --speed <n> Encoding speed 0-8 (default: 5)
29
+ --lossless Enable lossless encoding
30
+ --chroma <mode> Chroma subsampling 4:2:0|4:4:4 (default: 4:2:0)
31
+
32
+ -o, --out-dir <dir> Output directory (default: in-place)
33
+ --suffix <str> Add suffix before .avif (e.g. ".min")
34
+ --preserve Keep original files (default: delete)
35
+
36
+ --skip-larger Don't keep AVIF if it's bigger (default: true)
37
+ --no-skip-larger Always keep the AVIF output
38
+ --skip-existing Skip if .avif file exists
39
+ --staged-only Only process git-staged images
40
+ --changed-since-push Only process images changed since last push
41
+
42
+ -c, --concurrency <n> Parallel conversions (default: CPU-1)
43
+ -n, --dry-run Preview changes without writing
44
+ --json Output JSON lines (for pipelines)
45
+
46
+ -v, --verbose Show detailed progress
47
+ --debug Show everything (very noisy)
48
+ --silent Suppress all output
49
+
50
+ --hook-type <type> Hook type: pre-push|pre-commit (default: pre-push)
51
+ -h, --help Show this help
52
+ --version Show version
53
+
54
+ CONFIG FILES
55
+ .avififyrc or .avififyrc.json in project root:
56
+ { "quality": 40, "include": ["src/images/**"], "preserve": true }
57
+
58
+ Or in package.json:
59
+ { "avifify": { "quality": 40 } }
60
+
61
+ CLI args override config files. Config files override defaults.
62
+
63
+ EXAMPLES
64
+ avifify # Convert all images in current dir
65
+ avifify src/assets # Convert images in specific directory
66
+ avifify "**/*.png" # Convert only PNGs
67
+ avifify -q 30 --preserve # Low quality, keep originals
68
+ avifify --dry-run --verbose # Preview what would happen
69
+ avifify --json | jq # Pipe JSON output to jq
70
+ avifify hook install # Install pre-push git hook
71
+ avifify --changed-since-push # Only convert images changed since last push
72
+
73
+ EXIT CODES
74
+ 0 All files converted successfully (or nothing to do)
75
+ 1 Some files failed to convert
76
+ 2 Invalid arguments or configuration error
77
+ `;
78
+
79
+ /**
80
+ * Parse CLI arguments (minimal parser, no dependencies).
81
+ */
82
+ function parseArgs(argv) {
83
+ const args = { _: [] };
84
+ let i = 0;
85
+
86
+ while (i < argv.length) {
87
+ const arg = argv[i];
88
+
89
+ // Flags with values
90
+ const withValue = {
91
+ '-q': 'quality', '--quality': 'quality',
92
+ '-s': 'speed', '--speed': 'speed',
93
+ '-o': 'outDir', '--out-dir': 'outDir',
94
+ '-c': 'concurrency', '--concurrency': 'concurrency',
95
+ '--suffix': 'suffix',
96
+ '--chroma': 'chromaSubsampling',
97
+ '--hook-type': 'hookType',
98
+ };
99
+
100
+ // Boolean flags
101
+ const booleans = {
102
+ '--lossless': 'lossless',
103
+ '--preserve': 'preserveOriginal',
104
+ '--skip-larger': ['skipLarger', true],
105
+ '--no-skip-larger': ['skipLarger', false],
106
+ '--skip-existing': 'skipExisting',
107
+ '--staged-only': 'stagedOnly',
108
+ '--changed-since-push': 'changedSincePush',
109
+ '-n': 'dryRun', '--dry-run': 'dryRun',
110
+ '--json': 'json',
111
+ '-v': 'verbose', '--verbose': 'verbose',
112
+ '--debug': 'debug',
113
+ '--silent': 'silent',
114
+ '-h': 'help', '--help': 'help',
115
+ '--version': 'version',
116
+ };
117
+
118
+ if (withValue[arg]) {
119
+ const key = withValue[arg];
120
+ const val = argv[++i];
121
+ if (val === undefined) {
122
+ console.error(`Missing value for ${arg}`);
123
+ process.exit(2);
124
+ }
125
+ // Convert numeric values
126
+ args[key] = ['quality', 'speed', 'concurrency'].includes(key) ? Number(val) : val;
127
+ } else if (booleans[arg]) {
128
+ const mapping = booleans[arg];
129
+ if (Array.isArray(mapping)) {
130
+ args[mapping[0]] = mapping[1];
131
+ } else {
132
+ args[mapping] = true;
133
+ }
134
+ } else if (arg.startsWith('-')) {
135
+ console.error(`Unknown option: ${arg}\nRun 'avifify --help' for usage.`);
136
+ process.exit(2);
137
+ } else {
138
+ args._.push(arg);
139
+ }
140
+
141
+ i++;
142
+ }
143
+
144
+ return args;
145
+ }
146
+
147
+ /**
148
+ * Main CLI entry point.
149
+ */
150
+ export async function run(argv) {
151
+ const args = parseArgs(argv);
152
+
153
+ // Simple flags
154
+ if (args.help) {
155
+ console.log(HELP);
156
+ process.exit(0);
157
+ }
158
+
159
+ if (args.version) {
160
+ console.log(VERSION);
161
+ process.exit(0);
162
+ }
163
+
164
+ // Sub-commands
165
+ const subcommand = args._[0];
166
+ if (subcommand === 'hook') {
167
+ return handleHookCommand(args);
168
+ }
169
+
170
+ // Main conversion flow
171
+ try {
172
+ const config = await resolveConfig(args);
173
+ const logger = createLogger({ level: config.logLevel, json: config.json });
174
+
175
+ logger.debug('Resolved config:', config);
176
+
177
+ // Discover files
178
+ let files;
179
+ if (config.changedSincePush) {
180
+ logger.info('Scanning for images changed since last push…');
181
+ files = await getChangedSinceLastPush(process.cwd());
182
+ } else {
183
+ logger.info('Scanning for images…');
184
+ files = await scanFiles(config, args._.length > 0 ? args._ : [], process.cwd());
185
+ }
186
+
187
+ if (files.length === 0) {
188
+ logger.info('No images found matching the configured patterns.');
189
+ process.exit(0);
190
+ }
191
+
192
+ logger.info(`Found ${files.length} image${files.length === 1 ? '' : 's'} to process`);
193
+
194
+ if (config.dryRun) {
195
+ logger.info('Dry run — no files will be modified\n');
196
+ }
197
+
198
+ // Convert
199
+ const results = await convertBatch(files, config, logger);
200
+
201
+ // Summarize
202
+ const summary = results.reduce(
203
+ (acc, r) => {
204
+ acc.total++;
205
+ if (r.status === 'converted') {
206
+ acc.converted++;
207
+ acc.savedBytes += r.savedBytes ?? 0;
208
+ } else if (r.status === 'skipped') {
209
+ acc.skipped++;
210
+ } else {
211
+ acc.errors++;
212
+ }
213
+ return acc;
214
+ },
215
+ { total: 0, converted: 0, skipped: 0, errors: 0, savedBytes: 0 }
216
+ );
217
+
218
+ logger.summary(summary);
219
+
220
+ // Exit code: 1 if any errors, 0 otherwise
221
+ process.exit(summary.errors > 0 ? 1 : 0);
222
+ } catch (err) {
223
+ const logger = createLogger({ level: 'error', json: args.json });
224
+ logger.error(err.message);
225
+ if (args.debug) logger.error(err.stack);
226
+ process.exit(2);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Handle `avifify hook install` / `avifify hook uninstall`
232
+ */
233
+ async function handleHookCommand(args) {
234
+ const action = args._[1];
235
+ const hookType = args.hookType ?? 'pre-push';
236
+ const logger = createLogger({ level: args.silent ? 'silent' : 'info', json: args.json });
237
+
238
+ try {
239
+ if (action === 'install') {
240
+ await installHook({ hookType }, logger);
241
+ } else if (action === 'uninstall') {
242
+ await uninstallHook({ hookType }, logger);
243
+ } else {
244
+ console.error(`Unknown hook action: ${action}\nUsage: avifify hook install|uninstall`);
245
+ process.exit(2);
246
+ }
247
+ } catch (err) {
248
+ logger.error(err.message);
249
+ process.exit(2);
250
+ }
251
+ }
package/src/config.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Configuration resolution with layered precedence:
3
+ * defaults < .avififyrc.json < package.json["avifify"] < CLI args
4
+ */
5
+
6
+ import { readFile } from 'node:fs/promises';
7
+ import { resolve, dirname } from 'node:path';
8
+ import { existsSync } from 'node:fs';
9
+
10
+ const DEFAULTS = {
11
+ // Paths / Globs
12
+ include: ['**/*.{jpg,jpeg,png,webp,tiff,tif,gif,bmp,heif,heic}'],
13
+ exclude: ['**/node_modules/**', '**/dist/**', '**/.git/**', '**/vendor/**'],
14
+
15
+ // Output
16
+ outDir: null, // null = convert in-place (same directory)
17
+ suffix: '', // e.g. '.min' → photo.min.avif (empty = photo.avif)
18
+ preserveOriginal: false,
19
+
20
+ // AVIF encoding options
21
+ quality: 50, // 1-100, lower = smaller. 50 is a solid default
22
+ speed: 5, // 0-8, lower = slower but better compression
23
+ effort: 5, // alias for speed (sharp uses this)
24
+ lossless: false,
25
+ chromaSubsampling: '4:2:0', // '4:2:0' or '4:4:4'
26
+
27
+ // Behavior
28
+ concurrency: null, // null = auto (CPU count)
29
+ skipLarger: true, // skip if AVIF is bigger than the original
30
+ skipExisting: false, // skip if .avif already exists
31
+ dryRun: false,
32
+ json: false,
33
+ verbose: false,
34
+ debug: false,
35
+ silent: false,
36
+
37
+ // Git hook specific
38
+ stagedOnly: false, // only process git-staged files
39
+ };
40
+
41
+ const RC_FILENAMES = ['.avififyrc', '.avififyrc.json'];
42
+
43
+ /**
44
+ * Walk up from `cwd` to find the nearest config file.
45
+ */
46
+ async function findRcFile(cwd) {
47
+ let dir = resolve(cwd);
48
+ const root = dirname(dir) === dir ? dir : undefined;
49
+
50
+ while (true) {
51
+ for (const name of RC_FILENAMES) {
52
+ const candidate = resolve(dir, name);
53
+ if (existsSync(candidate)) return candidate;
54
+ }
55
+ const parent = dirname(dir);
56
+ if (parent === dir || dir === root) break;
57
+ dir = parent;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ async function loadJsonFile(path) {
63
+ try {
64
+ const raw = await readFile(path, 'utf-8');
65
+ return JSON.parse(raw);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Resolve the final config object.
73
+ *
74
+ * @param {object} cliArgs - Parsed CLI arguments (only defined keys)
75
+ * @param {string} cwd - Working directory
76
+ * @returns {Promise<object>} Merged configuration
77
+ */
78
+ export async function resolveConfig(cliArgs = {}, cwd = process.cwd()) {
79
+ // Layer 1: RC file
80
+ const rcPath = await findRcFile(cwd);
81
+ const rcConfig = rcPath ? (await loadJsonFile(rcPath)) ?? {} : {};
82
+
83
+ // Layer 2: package.json "avifify" key
84
+ const pkgPath = resolve(cwd, 'package.json');
85
+ const pkg = await loadJsonFile(pkgPath);
86
+ const pkgConfig = pkg?.avifify ?? {};
87
+
88
+ // Merge with precedence
89
+ const merged = { ...DEFAULTS, ...rcConfig, ...pkgConfig, ...stripUndefined(cliArgs) };
90
+
91
+ // Normalize
92
+ merged.quality = clamp(merged.quality, 1, 100);
93
+ merged.speed = clamp(merged.speed, 0, 8);
94
+ merged.effort = merged.speed; // sharp calls it `effort`
95
+
96
+ if (merged.concurrency === null) {
97
+ const { availableParallelism } = await import('node:os');
98
+ merged.concurrency = Math.max(1, (availableParallelism?.() ?? 4) - 1);
99
+ }
100
+
101
+ if (merged.outDir) {
102
+ merged.outDir = resolve(cwd, merged.outDir);
103
+ }
104
+
105
+ // Determine log level
106
+ if (merged.silent) merged.logLevel = 'silent';
107
+ else if (merged.debug) merged.logLevel = 'debug';
108
+ else if (merged.verbose) merged.logLevel = 'verbose';
109
+ else merged.logLevel = 'info';
110
+
111
+ // Source info for debugging
112
+ merged._sources = {
113
+ rc: rcPath ?? null,
114
+ pkg: pkg?.avifify ? pkgPath : null,
115
+ };
116
+
117
+ return merged;
118
+ }
119
+
120
+ function stripUndefined(obj) {
121
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
122
+ }
123
+
124
+ function clamp(val, min, max) {
125
+ return Math.min(max, Math.max(min, Number(val) || min));
126
+ }
127
+
128
+ export { DEFAULTS };
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Core AVIF converter: processes files with concurrency control,
3
+ * progress reporting, and detailed result tracking.
4
+ */
5
+
6
+ import sharp from 'sharp';
7
+ import { stat, mkdir, unlink, access } from 'node:fs/promises';
8
+ import { dirname, basename, extname, join, relative, resolve } from 'node:path';
9
+ import { formatBytes } from './logger.js';
10
+
11
+ /**
12
+ * @typedef {Object} ConvertResult
13
+ * @property {string} file - Original file path
14
+ * @property {string} output - Output file path
15
+ * @property {'converted'|'skipped'|'error'} status
16
+ * @property {number} [inputSize] - Original file size in bytes
17
+ * @property {number} [outputSize] - AVIF file size in bytes
18
+ * @property {number} [savedBytes] - Bytes saved (positive = smaller)
19
+ * @property {string} [reason] - Why it was skipped or errored
20
+ * @property {number} [duration] - Conversion time in ms
21
+ */
22
+
23
+ /**
24
+ * Convert a batch of image files to AVIF.
25
+ *
26
+ * @param {string[]} files - Absolute paths of source images
27
+ * @param {object} config - Resolved configuration
28
+ * @param {object} logger - Logger instance
29
+ * @returns {Promise<ConvertResult[]>}
30
+ */
31
+ export async function convertBatch(files, config, logger) {
32
+ const results = [];
33
+ const { concurrency } = config;
34
+ let completed = 0;
35
+
36
+ // Process files in chunks for controlled concurrency
37
+ for (let i = 0; i < files.length; i += concurrency) {
38
+ const chunk = files.slice(i, i + concurrency);
39
+
40
+ const chunkResults = await Promise.allSettled(
41
+ chunk.map(file => convertSingle(file, config, logger))
42
+ );
43
+
44
+ for (const result of chunkResults) {
45
+ const res = result.status === 'fulfilled'
46
+ ? result.value
47
+ : { file: 'unknown', status: 'error', reason: result.reason?.message ?? String(result.reason) };
48
+
49
+ results.push(res);
50
+ completed++;
51
+ logger.progress(completed, files.length, res.file);
52
+ }
53
+ }
54
+
55
+ return results;
56
+ }
57
+
58
+ /**
59
+ * Convert a single image file to AVIF.
60
+ *
61
+ * @param {string} filePath - Absolute path to the source image
62
+ * @param {object} config - Resolved configuration
63
+ * @param {object} logger - Logger instance
64
+ * @returns {Promise<ConvertResult>}
65
+ */
66
+ export async function convertSingle(filePath, config, logger) {
67
+ const start = Date.now();
68
+ const cwd = process.cwd();
69
+ const relPath = relative(cwd, filePath);
70
+
71
+ try {
72
+ // Determine output path
73
+ const outputPath = getOutputPath(filePath, config);
74
+ const relOutput = relative(cwd, outputPath);
75
+
76
+ // Check if output already exists
77
+ if (config.skipExisting) {
78
+ try {
79
+ await access(outputPath);
80
+ logger.verbose(`Skipped (exists): ${relPath}`);
81
+ return { file: relPath, output: relOutput, status: 'skipped', reason: 'already exists' };
82
+ } catch {
83
+ // File doesn't exist, proceed
84
+ }
85
+ }
86
+
87
+ // Get input file size
88
+ const inputStat = await stat(filePath);
89
+ const inputSize = inputStat.size;
90
+
91
+ // Dry run — report what would happen
92
+ if (config.dryRun) {
93
+ logger.info(`[dry-run] Would convert: ${relPath} → ${relOutput}`);
94
+ return { file: relPath, output: relOutput, status: 'skipped', reason: 'dry run', inputSize };
95
+ }
96
+
97
+ // Ensure output directory exists
98
+ const outDir = dirname(outputPath);
99
+ await mkdir(outDir, { recursive: true });
100
+
101
+ // Build the sharp pipeline
102
+ let pipeline = sharp(filePath, {
103
+ failOn: 'none', // Don't fail on minor issues
104
+ sequentialRead: true, // Better memory usage for large images
105
+ });
106
+
107
+ // Optionally get metadata for logging
108
+ const metadata = logger.isVerbose ? await pipeline.metadata() : null;
109
+
110
+ // Convert to AVIF
111
+ pipeline = pipeline.avif({
112
+ quality: config.lossless ? 100 : config.quality,
113
+ effort: config.effort,
114
+ lossless: config.lossless,
115
+ chromaSubsampling: config.chromaSubsampling,
116
+ });
117
+
118
+ // Write output
119
+ const outputInfo = await pipeline.toFile(outputPath);
120
+ const outputSize = outputInfo.size;
121
+ const savedBytes = inputSize - outputSize;
122
+ const duration = Date.now() - start;
123
+
124
+ // Skip if AVIF is larger and config says so
125
+ if (config.skipLarger && outputSize >= inputSize) {
126
+ // Remove the larger AVIF file
127
+ try { await unlink(outputPath); } catch { /* ignore */ }
128
+ logger.verbose(`Skipped (AVIF larger): ${relPath} — ${formatBytes(inputSize)} → ${formatBytes(outputSize)}`);
129
+ return {
130
+ file: relPath,
131
+ output: relOutput,
132
+ status: 'skipped',
133
+ reason: 'avif is larger',
134
+ inputSize,
135
+ outputSize,
136
+ savedBytes,
137
+ duration,
138
+ };
139
+ }
140
+
141
+ // Delete original if not preserving
142
+ if (!config.preserveOriginal && outputPath !== filePath) {
143
+ try { await unlink(filePath); } catch { /* ignore */ }
144
+ }
145
+
146
+ const pct = inputSize > 0 ? Math.round((1 - outputSize / inputSize) * 100) : 0;
147
+ logger.verbose(
148
+ `Converted: ${relPath} → ${formatBytes(inputSize)} → ${formatBytes(outputSize)} (${pct}% smaller, ${duration}ms)`,
149
+ metadata ? { dimensions: `${metadata.width}×${metadata.height}` } : undefined
150
+ );
151
+
152
+ return {
153
+ file: relPath,
154
+ output: relOutput,
155
+ status: 'converted',
156
+ inputSize,
157
+ outputSize,
158
+ savedBytes,
159
+ duration,
160
+ };
161
+ } catch (err) {
162
+ const duration = Date.now() - start;
163
+ logger.error(`Failed: ${relPath} — ${err.message}`);
164
+ logger.debug(err.stack);
165
+ return {
166
+ file: relPath,
167
+ output: null,
168
+ status: 'error',
169
+ reason: err.message,
170
+ duration,
171
+ };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Compute the output .avif path for a given input file.
177
+ */
178
+ function getOutputPath(filePath, config) {
179
+ const ext = extname(filePath);
180
+ const base = basename(filePath, ext);
181
+ const dir = config.outDir
182
+ ? resolve(config.outDir, relative(process.cwd(), dirname(filePath)))
183
+ : dirname(filePath);
184
+
185
+ const suffix = config.suffix || '';
186
+ return join(dir, `${base}${suffix}.avif`);
187
+ }
188
+
189
+ export { getOutputPath };
package/src/hooks.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Git hook management — install/uninstall avifify as a git hook.
3
+ *
4
+ * Supports:
5
+ * - Direct .git/hooks/ installation
6
+ * - Husky v4+ integration (detects .husky/ directory)
7
+ * - Custom hooks directory (core.hooksPath)
8
+ */
9
+
10
+ import { readFile, writeFile, chmod, mkdir, unlink } from 'node:fs/promises';
11
+ import { existsSync } from 'node:fs';
12
+ import { resolve, join } from 'node:path';
13
+ import { execFile } from 'node:child_process';
14
+ import { promisify } from 'node:util';
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ const HOOK_MARKER_START = '# >>> avifify pre-push hook >>>';
19
+ const HOOK_MARKER_END = '# <<< avifify pre-push hook <<<';
20
+
21
+ const HOOK_TYPES = ['pre-push', 'pre-commit'];
22
+
23
+ /**
24
+ * Generate the hook script content.
25
+ */
26
+ function hookScript(hookType) {
27
+ return `${HOOK_MARKER_START}
28
+ # Auto-generated by avifify — do not edit between markers
29
+ npx avifify --staged-only
30
+ AVIFIFY_EXIT=$?
31
+ if [ $AVIFIFY_EXIT -ne 0 ]; then
32
+ echo "avifify: image conversion failed or found unconverted images"
33
+ echo "Run 'npx avifify' to convert images before pushing."
34
+ exit 1
35
+ fi
36
+ # If images were converted, auto-stage them
37
+ git diff --name-only | grep '\\.avif$' | xargs -r git add
38
+ ${HOOK_MARKER_END}`;
39
+ }
40
+
41
+ /**
42
+ * Detect the git hooks directory.
43
+ */
44
+ async function getHooksDir(cwd) {
45
+ // Check for husky directory
46
+ const huskyDir = resolve(cwd, '.husky');
47
+ if (existsSync(huskyDir)) {
48
+ return { type: 'husky', path: huskyDir };
49
+ }
50
+
51
+ // Check for custom hooksPath in git config
52
+ try {
53
+ const { stdout } = await execFileAsync('git', ['config', 'core.hooksPath'], { cwd });
54
+ const customPath = stdout.trim();
55
+ if (customPath) {
56
+ return { type: 'custom', path: resolve(cwd, customPath) };
57
+ }
58
+ } catch {
59
+ // No custom hooks path set
60
+ }
61
+
62
+ // Default to .git/hooks
63
+ const gitHooksDir = resolve(cwd, '.git', 'hooks');
64
+ if (existsSync(resolve(cwd, '.git'))) {
65
+ return { type: 'git', path: gitHooksDir };
66
+ }
67
+
68
+ throw new Error('Not a git repository (no .git directory found)');
69
+ }
70
+
71
+ /**
72
+ * Install the git hook.
73
+ *
74
+ * @param {object} options
75
+ * @param {string} [options.hookType='pre-push'] - Hook type
76
+ * @param {string} [options.cwd=process.cwd()] - Working directory
77
+ * @param {object} logger - Logger instance
78
+ */
79
+ export async function installHook({ hookType = 'pre-push', cwd = process.cwd() } = {}, logger) {
80
+ if (!HOOK_TYPES.includes(hookType)) {
81
+ throw new Error(`Unsupported hook type: ${hookType}. Supported: ${HOOK_TYPES.join(', ')}`);
82
+ }
83
+
84
+ const { type, path: hooksDir } = await getHooksDir(cwd);
85
+ await mkdir(hooksDir, { recursive: true });
86
+
87
+ const hookPath = join(hooksDir, hookType);
88
+ const script = hookScript(hookType);
89
+
90
+ if (existsSync(hookPath)) {
91
+ const existing = await readFile(hookPath, 'utf-8');
92
+
93
+ // Already installed?
94
+ if (existing.includes(HOOK_MARKER_START)) {
95
+ // Replace existing avifify block
96
+ const regex = new RegExp(
97
+ `${escapeRegex(HOOK_MARKER_START)}[\\s\\S]*?${escapeRegex(HOOK_MARKER_END)}`,
98
+ 'm'
99
+ );
100
+ const updated = existing.replace(regex, script);
101
+ await writeFile(hookPath, updated, 'utf-8');
102
+ logger.success(`Updated avifify in existing ${hookType} hook (${type})`);
103
+ } else {
104
+ // Append to existing hook
105
+ const updated = existing.trimEnd() + '\n\n' + script + '\n';
106
+ await writeFile(hookPath, updated, 'utf-8');
107
+ logger.success(`Appended avifify to existing ${hookType} hook (${type})`);
108
+ }
109
+ } else {
110
+ // Create new hook file
111
+ const content = `#!/usr/bin/env sh\n\n${script}\n`;
112
+ await writeFile(hookPath, content, 'utf-8');
113
+ logger.success(`Created ${hookType} hook at ${hookPath} (${type})`);
114
+ }
115
+
116
+ // Make executable
117
+ await chmod(hookPath, 0o755);
118
+ logger.info(`Hook installed via ${type} at ${hookPath}`);
119
+ }
120
+
121
+ /**
122
+ * Uninstall the git hook (removes only the avifify block).
123
+ */
124
+ export async function uninstallHook({ hookType = 'pre-push', cwd = process.cwd() } = {}, logger) {
125
+ const { type, path: hooksDir } = await getHooksDir(cwd);
126
+ const hookPath = join(hooksDir, hookType);
127
+
128
+ if (!existsSync(hookPath)) {
129
+ logger.warn(`No ${hookType} hook found at ${hookPath}`);
130
+ return;
131
+ }
132
+
133
+ const existing = await readFile(hookPath, 'utf-8');
134
+
135
+ if (!existing.includes(HOOK_MARKER_START)) {
136
+ logger.warn(`No avifify block found in ${hookType} hook`);
137
+ return;
138
+ }
139
+
140
+ const regex = new RegExp(
141
+ `\\n?${escapeRegex(HOOK_MARKER_START)}[\\s\\S]*?${escapeRegex(HOOK_MARKER_END)}\\n?`,
142
+ 'm'
143
+ );
144
+ const cleaned = existing.replace(regex, '\n').trim();
145
+
146
+ // If the hook only had our content, remove the file entirely
147
+ const isShebangOnly = /^#!\/usr\/bin\/env\s+sh\s*$/m.test(cleaned) && cleaned.split('\n').filter(l => l.trim()).length <= 1;
148
+
149
+ if (!cleaned || isShebangOnly) {
150
+ await unlink(hookPath);
151
+ logger.success(`Removed ${hookType} hook entirely (was only avifify)`);
152
+ } else {
153
+ await writeFile(hookPath, cleaned + '\n', 'utf-8');
154
+ await chmod(hookPath, 0o755);
155
+ logger.success(`Removed avifify block from ${hookType} hook`);
156
+ }
157
+ }
158
+
159
+ function escapeRegex(str) {
160
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
161
+ }
package/src/index.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * avifify — Programmatic API
3
+ *
4
+ * Usage:
5
+ * import { avifify, convertFile } from 'avifify';
6
+ *
7
+ * // Convert all images in a directory
8
+ * const results = await avifify({ quality: 40, include: ['src/**'] });
9
+ *
10
+ * // Convert a single file
11
+ * const result = await convertFile('photo.jpg', { quality: 50 });
12
+ */
13
+
14
+ import { resolveConfig } from './config.js';
15
+ import { scanFiles } from './scanner.js';
16
+ import { convertBatch, convertSingle, getOutputPath } from './converter.js';
17
+ import { createLogger } from './logger.js';
18
+ import { installHook, uninstallHook } from './hooks.js';
19
+
20
+ /**
21
+ * Convert images to AVIF.
22
+ *
23
+ * @param {object} [options] - Override any config option
24
+ * @param {string[]} [options.paths] - Explicit file/glob paths to process
25
+ * @param {string} [options.cwd] - Working directory (default: process.cwd())
26
+ * @returns {Promise<import('./converter.js').ConvertResult[]>}
27
+ */
28
+ export async function avifify(options = {}) {
29
+ const { paths = [], cwd = process.cwd(), ...rest } = options;
30
+ const config = await resolveConfig({ ...rest, silent: rest.silent ?? true }, cwd);
31
+ const logger = createLogger({ level: config.logLevel, json: config.json });
32
+
33
+ const files = await scanFiles(config, paths, cwd);
34
+ if (files.length === 0) return [];
35
+
36
+ return convertBatch(files, config, logger);
37
+ }
38
+
39
+ /**
40
+ * Convert a single file to AVIF.
41
+ *
42
+ * @param {string} filePath - Path to the source image
43
+ * @param {object} [options] - Override any config option
44
+ * @returns {Promise<import('./converter.js').ConvertResult>}
45
+ */
46
+ export async function convertFile(filePath, options = {}) {
47
+ const config = await resolveConfig({ ...options, silent: options.silent ?? true });
48
+ const logger = createLogger({ level: 'silent' });
49
+ return convertSingle(filePath, config, logger);
50
+ }
51
+
52
+ export { installHook, uninstallHook, getOutputPath, resolveConfig };
package/src/logger.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Logger with TTY-aware formatting.
3
+ * - Interactive terminal: colored, human-readable output
4
+ * - Piped / CI: structured JSON lines (--json) or plain text
5
+ */
6
+
7
+ const LEVELS = { silent: 0, error: 1, warn: 2, info: 3, verbose: 4, debug: 5 };
8
+
9
+ // ANSI codes — stripped automatically when not a TTY
10
+ const RESET = '\x1b[0m';
11
+ const BOLD = '\x1b[1m';
12
+ const DIM = '\x1b[2m';
13
+ const RED = '\x1b[31m';
14
+ const GREEN = '\x1b[32m';
15
+ const YELLOW = '\x1b[33m';
16
+ const CYAN = '\x1b[36m';
17
+ const MAGENTA = '\x1b[35m';
18
+
19
+ class Logger {
20
+ #level;
21
+ #json;
22
+ #isTTY;
23
+ #startTime;
24
+
25
+ constructor({ level = 'info', json = false } = {}) {
26
+ this.#level = LEVELS[level] ?? LEVELS.info;
27
+ this.#json = json;
28
+ this.#isTTY = process.stdout.isTTY && !json;
29
+ this.#startTime = Date.now();
30
+ }
31
+
32
+ get isVerbose() {
33
+ return this.#level >= LEVELS.verbose;
34
+ }
35
+
36
+ get isDebug() {
37
+ return this.#level >= LEVELS.debug;
38
+ }
39
+
40
+ #elapsed() {
41
+ return `${((Date.now() - this.#startTime) / 1000).toFixed(1)}s`;
42
+ }
43
+
44
+ #write(level, color, label, message, data) {
45
+ if (LEVELS[level] > this.#level) return;
46
+
47
+ if (this.#json) {
48
+ const entry = { level, message, timestamp: new Date().toISOString(), ...data };
49
+ process.stdout.write(JSON.stringify(entry) + '\n');
50
+ return;
51
+ }
52
+
53
+ const prefix = this.#isTTY ? `${color}${BOLD}${label}${RESET}` : label;
54
+ const dim = this.#isTTY ? DIM : '';
55
+ const reset = this.#isTTY ? RESET : '';
56
+
57
+ let line = `${prefix} ${message}`;
58
+ if (data?.file) line += ` ${dim}${data.file}${reset}`;
59
+ if (data?.saved) line += ` ${dim}(${data.saved})${reset}`;
60
+
61
+ const stream = level === 'error' ? process.stderr : process.stdout;
62
+ stream.write(line + '\n');
63
+ }
64
+
65
+ error(message, data) { this.#write('error', RED, '✖', message, data); }
66
+ warn(message, data) { this.#write('warn', YELLOW, '⚠', message, data); }
67
+ info(message, data) { this.#write('info', CYAN, '●', message, data); }
68
+ success(message, data) { this.#write('info', GREEN, '✔', message, data); }
69
+ verbose(message, data) { this.#write('verbose', MAGENTA, '…', message, data); }
70
+ debug(message, data) { this.#write('debug', DIM, '⊡', message, data); }
71
+
72
+ /** Print a summary table at the end */
73
+ summary({ total, converted, skipped, errors, savedBytes }) {
74
+ if (this.#json) {
75
+ process.stdout.write(JSON.stringify({
76
+ level: 'summary',
77
+ total,
78
+ converted,
79
+ skipped,
80
+ errors,
81
+ savedBytes,
82
+ elapsed: this.#elapsed(),
83
+ }) + '\n');
84
+ return;
85
+ }
86
+
87
+ const saved = formatBytes(savedBytes);
88
+ const c = this.#isTTY;
89
+ console.log('');
90
+ console.log(c ? `${BOLD}${GREEN} avifify complete${RESET}` : ' avifify complete');
91
+ console.log(` ─────────────────────────────`);
92
+ console.log(` Files scanned ${total}`);
93
+ console.log(` Converted ${c ? GREEN : ''}${converted}${c ? RESET : ''}`);
94
+ console.log(` Skipped ${skipped}`);
95
+ if (errors > 0) {
96
+ console.log(` Errors ${c ? RED : ''}${errors}${c ? RESET : ''}`);
97
+ }
98
+ console.log(` Space saved ${c ? CYAN : ''}${saved}${c ? RESET : ''}`);
99
+ console.log(` Elapsed ${this.#elapsed()}`);
100
+ console.log('');
101
+ }
102
+
103
+ /** Inline progress for TTY */
104
+ progress(current, total, file) {
105
+ if (!this.#isTTY || this.#level < LEVELS.info) return;
106
+ const pct = Math.round((current / total) * 100);
107
+ const bar = '█'.repeat(Math.floor(pct / 4)) + '░'.repeat(25 - Math.floor(pct / 4));
108
+ process.stdout.write(`\r${DIM} ${bar} ${pct}% (${current}/${total})${RESET} `);
109
+ if (current === total) process.stdout.write('\n');
110
+ }
111
+ }
112
+
113
+ export function formatBytes(bytes) {
114
+ if (bytes === 0) return '0 B';
115
+ const neg = bytes < 0;
116
+ const abs = Math.abs(bytes);
117
+ const units = ['B', 'KB', 'MB', 'GB'];
118
+ const i = Math.min(Math.floor(Math.log(abs) / Math.log(1024)), units.length - 1);
119
+ const val = (abs / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1);
120
+ return `${neg ? '-' : ''}${val} ${units[i]}`;
121
+ }
122
+
123
+ export function createLogger(opts) {
124
+ return new Logger(opts);
125
+ }
package/src/scanner.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * File discovery: glob-based scanning or git-staged file detection.
3
+ */
4
+
5
+ import fg from 'fast-glob';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { resolve, extname } from 'node:path';
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ const IMAGE_EXTENSIONS = new Set([
13
+ '.jpg', '.jpeg', '.png', '.webp', '.tiff', '.tif',
14
+ '.gif', '.bmp', '.heif', '.heic',
15
+ ]);
16
+
17
+ /**
18
+ * Find image files matching the config patterns.
19
+ *
20
+ * @param {object} config - Resolved config
21
+ * @param {string[]} explicitPaths - Explicit file/dir paths from CLI positional args
22
+ * @param {string} cwd - Working directory
23
+ * @returns {Promise<string[]>} Absolute paths of image files
24
+ */
25
+ export async function scanFiles(config, explicitPaths = [], cwd = process.cwd()) {
26
+ // Mode 1: git-staged files only (for pre-push hook)
27
+ if (config.stagedOnly) {
28
+ return getStagedImages(cwd);
29
+ }
30
+
31
+ // Mode 2: explicit paths provided
32
+ if (explicitPaths.length > 0) {
33
+ const { statSync } = await import('node:fs');
34
+ const patterns = explicitPaths.map(p => {
35
+ // If it looks like a glob, use as-is
36
+ if (p.includes('*') || p.includes('{')) return p;
37
+ // If it's a directory, append recursive glob
38
+ try {
39
+ if (statSync(resolve(cwd, p)).isDirectory()) return `${p}/**/*`;
40
+ } catch { /* not a dir, treat as file path */ }
41
+ return p;
42
+ });
43
+
44
+ const files = await fg(patterns, {
45
+ cwd,
46
+ absolute: true,
47
+ onlyFiles: true,
48
+ ignore: config.exclude,
49
+ followSymbolicLinks: false,
50
+ });
51
+
52
+ return files.filter(f => IMAGE_EXTENSIONS.has(extname(f).toLowerCase()));
53
+ }
54
+
55
+ // Mode 3: scan using include/exclude globs
56
+ const files = await fg(config.include, {
57
+ cwd,
58
+ absolute: true,
59
+ onlyFiles: true,
60
+ ignore: config.exclude,
61
+ followSymbolicLinks: false,
62
+ dot: false,
63
+ });
64
+
65
+ return files.filter(f => IMAGE_EXTENSIONS.has(extname(f).toLowerCase()));
66
+ }
67
+
68
+ /**
69
+ * Get image files staged in git (for hook usage).
70
+ */
71
+ async function getStagedImages(cwd) {
72
+ try {
73
+ // Get files staged for commit (including ones that are added/modified)
74
+ const { stdout } = await execFileAsync(
75
+ 'git',
76
+ ['diff', '--cached', '--name-only', '--diff-filter=ACMR'],
77
+ { cwd, maxBuffer: 10 * 1024 * 1024 }
78
+ );
79
+
80
+ return stdout
81
+ .split('\n')
82
+ .map(f => f.trim())
83
+ .filter(f => f && IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
84
+ .map(f => resolve(cwd, f));
85
+ } catch (err) {
86
+ // Not a git repo or git not available
87
+ throw new Error(`Failed to get staged files: ${err.message}`);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Get image files changed since the last push (for pre-push hook).
93
+ */
94
+ export async function getChangedSinceLastPush(cwd) {
95
+ try {
96
+ const { stdout } = await execFileAsync(
97
+ 'git',
98
+ ['diff', '--name-only', '@{push}..HEAD', '--diff-filter=ACMR'],
99
+ { cwd, maxBuffer: 10 * 1024 * 1024 }
100
+ );
101
+
102
+ return stdout
103
+ .split('\n')
104
+ .map(f => f.trim())
105
+ .filter(f => f && IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
106
+ .map(f => resolve(cwd, f));
107
+ } catch {
108
+ // Fallback: if no upstream, scan all tracked images
109
+ try {
110
+ const { stdout } = await execFileAsync(
111
+ 'git',
112
+ ['ls-files', '--cached'],
113
+ { cwd, maxBuffer: 10 * 1024 * 1024 }
114
+ );
115
+
116
+ return stdout
117
+ .split('\n')
118
+ .map(f => f.trim())
119
+ .filter(f => f && IMAGE_EXTENSIONS.has(extname(f).toLowerCase()))
120
+ .map(f => resolve(cwd, f));
121
+ } catch (err) {
122
+ throw new Error(`Failed to list git files: ${err.message}`);
123
+ }
124
+ }
125
+ }
126
+
127
+ export { IMAGE_EXTENSIONS };