react-webpify 0.1.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 ADDED
@@ -0,0 +1,72 @@
1
+ # react-webpify
2
+
3
+ A zero-config CLI that converts every image asset in your React project to **WebP** and rewrites all references — `import` statements, JSX `src`, CSS `url(...)`, HTML, and JSON manifests.
4
+
5
+ Run once at the root of any React app (CRA, Vite, Next, etc.) and ship smaller images instantly.
6
+
7
+ ```bash
8
+ npx react-webpify
9
+ ```
10
+
11
+ ## What it does
12
+
13
+ 1. **Scans** your project (`src/`, `public/`, `assets/`, `static/` by default) for `.png`, `.jpg`, `.jpeg`, and static `.gif` files.
14
+ 2. **Converts** each to `.webp` using [`sharp`](https://github.com/lovell/sharp).
15
+ 3. **Rewrites** every reference in your code — across `.js / .jsx / .ts / .tsx / .css / .scss / .less / .html / .json` — to point at the new `.webp`.
16
+ 4. **Deletes** the originals (override with `--keep-original`).
17
+ 5. Prints a summary with bytes saved.
18
+
19
+ It works on every common ref shape: relative imports, root-absolute paths (`/logo.png`), CSS `url(...)`, HTML attributes, and JSON manifest entries. References are only modified inside string/URL contexts, so identifiers, comments, and unrelated substrings are never touched.
20
+
21
+ ## Install / Run
22
+
23
+ ```bash
24
+ # one-shot, no install
25
+ npx react-webpify
26
+
27
+ # or add as a project script
28
+ npm i -D react-webpify
29
+ ```
30
+
31
+ `package.json`:
32
+
33
+ ```json
34
+ {
35
+ "scripts": {
36
+ "webpify": "react-webpify"
37
+ }
38
+ }
39
+ ```
40
+
41
+ ## Options
42
+
43
+ ```
44
+ npx react-webpify [options]
45
+
46
+ --dry-run show what would change, write nothing
47
+ --keep-original don't delete source images after conversion
48
+ --quality <n> webp quality, 1-100 (default: 80)
49
+ --include <globs...> extra dirs/globs to scan (default: src, public, assets, static)
50
+ --ignore <globs...> additional paths to skip
51
+ --force overwrite existing .webp files of the same basename
52
+ --no-update-refs convert images only, leave code untouched
53
+ --verbose print per-file logs
54
+ --cwd <path> project root (default: current working directory)
55
+ ```
56
+
57
+ Try a dry run first:
58
+
59
+ ```bash
60
+ npx react-webpify --dry-run --verbose
61
+ ```
62
+
63
+ ## Caveats
64
+
65
+ - **Dynamic refs**: paths built at runtime (e.g. `` `${base}/${name}.png` `` or `name + '.png'`) cannot be rewritten safely. The tool detects these and prints them as warnings so you can fix manually.
66
+ - **Animated GIFs**: skipped by default — animated WebP is supported by `sharp` but lossy/risky enough that we leave them alone.
67
+ - **Path aliases**: bundler aliases like `@/` are resolved heuristically (we try common forms). If you use a custom alias, double-check the resulting diff or run `--dry-run` first.
68
+ - **Idempotent**: running twice is a no-op. Safe to wire into CI.
69
+
70
+ ## License
71
+
72
+ MIT
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+
4
+ run(process.argv).catch((err) => {
5
+ console.error(err?.stack || err?.message || err);
6
+ process.exit(1);
7
+ });
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "react-webpify",
3
+ "version": "0.1.0",
4
+ "description": "Convert all image assets in a React project to WebP and rewrite every code/CSS/HTML/JSON reference automatically.",
5
+ "type": "module",
6
+ "bin": {
7
+ "react-webpify": "./bin/react-webpify.js"
8
+ },
9
+ "main": "./src/index.js",
10
+ "exports": {
11
+ ".": "./src/index.js"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "test": "vitest run",
20
+ "test:watch": "vitest"
21
+ },
22
+ "keywords": [
23
+ "react",
24
+ "webp",
25
+ "image",
26
+ "optimization",
27
+ "cli",
28
+ "performance"
29
+ ],
30
+ "author": {
31
+ "name": "mizanxali",
32
+ "email": "mizanalip786@gmail.com"
33
+ },
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/mizanxali/react-webpify.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/mizanxali/react-webpify/issues"
41
+ },
42
+ "homepage": "https://github.com/mizanxali/react-webpify#readme",
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
46
+ "dependencies": {
47
+ "chalk": "^5.3.0",
48
+ "commander": "^12.1.0",
49
+ "fast-glob": "^3.3.2",
50
+ "ora": "^8.1.0",
51
+ "sharp": "^0.33.5"
52
+ },
53
+ "devDependencies": {
54
+ "vitest": "^2.1.4"
55
+ }
56
+ }
package/src/cli.js ADDED
@@ -0,0 +1,48 @@
1
+ import { Command } from 'commander';
2
+ import path from 'node:path';
3
+ import { webpify } from './index.js';
4
+
5
+ export async function run(argv) {
6
+ const program = new Command();
7
+
8
+ program
9
+ .name('react-webpify')
10
+ .description(
11
+ 'Convert every image asset in a React project to .webp and rewrite all references in JS/TS/CSS/HTML/JSON.'
12
+ )
13
+ .version('0.1.0')
14
+ .option('--dry-run', 'show what would change, write nothing', false)
15
+ .option('--keep-original', "don't delete source images after conversion", false)
16
+ .option('--quality <n>', 'webp quality (1-100)', (v) => parseInt(v, 10), 80)
17
+ .option(
18
+ '--include <globs...>',
19
+ 'directories/globs to scan (default: src, public, assets, static)'
20
+ )
21
+ .option('--ignore <globs...>', 'additional paths to skip')
22
+ .option('--force', 'overwrite existing .webp files of the same basename', false)
23
+ .option('--no-update-refs', 'convert images only, do not rewrite code refs')
24
+ .option('--verbose', 'print per-file logs', false)
25
+ .option('--cwd <path>', 'project root (default: current working directory)');
26
+
27
+ program.parse(argv);
28
+ const opts = program.opts();
29
+
30
+ const cwd = path.resolve(opts.cwd || process.cwd());
31
+
32
+ await webpify({
33
+ cwd,
34
+ dryRun: !!opts.dryRun,
35
+ keepOriginal: !!opts.keepOriginal,
36
+ quality: clampQuality(opts.quality),
37
+ include: opts.include,
38
+ ignore: opts.ignore,
39
+ force: !!opts.force,
40
+ updateRefs: opts.updateRefs !== false,
41
+ verbose: !!opts.verbose,
42
+ });
43
+ }
44
+
45
+ function clampQuality(q) {
46
+ if (!Number.isFinite(q)) return 80;
47
+ return Math.max(1, Math.min(100, q));
48
+ }
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import sharp from 'sharp';
4
+ import { isAnimatedGif } from './gif.js';
5
+
6
+ export async function convertAll({ imageFiles, quality, force, dryRun, verbose, logger }) {
7
+ const mapping = new Map(); // oldAbsPath -> newAbsPath
8
+ const skippedAnimatedGifs = [];
9
+ let bytesBefore = 0;
10
+ let bytesAfter = 0;
11
+ let failed = 0;
12
+
13
+ for (const input of imageFiles) {
14
+ const ext = path.extname(input).toLowerCase();
15
+ const output = input.slice(0, -ext.length) + '.webp';
16
+
17
+ if (ext === '.gif' && (await isAnimatedGif(input))) {
18
+ skippedAnimatedGifs.push(input);
19
+ logger?.verbose(`skip animated gif: ${input}`);
20
+ continue;
21
+ }
22
+
23
+ if (!force) {
24
+ const exists = await pathExists(output);
25
+ if (exists) {
26
+ logger?.verbose(`exists, skipping: ${output}`);
27
+ continue;
28
+ }
29
+ }
30
+
31
+ try {
32
+ const beforeStat = await fs.stat(input);
33
+ bytesBefore += beforeStat.size;
34
+
35
+ if (!dryRun) {
36
+ await sharp(input, { animated: false }).webp({ quality }).toFile(output);
37
+ const afterStat = await fs.stat(output);
38
+ bytesAfter += afterStat.size;
39
+ } else {
40
+ // For dry-run we can't know the post-conversion size; just count input size.
41
+ bytesAfter += beforeStat.size;
42
+ }
43
+
44
+ mapping.set(input, output);
45
+ logger?.verbose(`converted: ${input} -> ${output}`);
46
+ } catch (err) {
47
+ failed++;
48
+ logger?.warn(`failed to convert ${input}: ${err.message}`);
49
+ }
50
+ }
51
+
52
+ return { mapping, skippedAnimatedGifs, bytesBefore, bytesAfter, failed };
53
+ }
54
+
55
+ export async function deleteOriginals(mapping, { dryRun, logger }) {
56
+ let deleted = 0;
57
+ for (const oldPath of mapping.keys()) {
58
+ try {
59
+ if (!dryRun) await fs.unlink(oldPath);
60
+ deleted++;
61
+ logger?.verbose(`deleted original: ${oldPath}`);
62
+ } catch (err) {
63
+ logger?.warn(`failed to delete ${oldPath}: ${err.message}`);
64
+ }
65
+ }
66
+ return deleted;
67
+ }
68
+
69
+ async function pathExists(p) {
70
+ try {
71
+ await fs.access(p);
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
package/src/gif.js ADDED
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ // A GIF is animated when it contains more than one image descriptor (0x2C).
4
+ // We walk the file byte-by-byte, respecting GIF block structure, and stop
5
+ // as soon as we've counted a second frame.
6
+ export async function isAnimatedGif(filePath) {
7
+ let buf;
8
+ try {
9
+ buf = await fs.readFile(filePath);
10
+ } catch {
11
+ return false;
12
+ }
13
+
14
+ // GIF89a / GIF87a header is 6 bytes; logical screen descriptor is 7 bytes.
15
+ if (buf.length < 13) return false;
16
+ if (buf.toString('ascii', 0, 3) !== 'GIF') return false;
17
+
18
+ let i = 13;
19
+ // Skip global color table if present.
20
+ const packed = buf[10];
21
+ if (packed & 0x80) {
22
+ const gctSize = 3 * (1 << ((packed & 0x07) + 1));
23
+ i += gctSize;
24
+ }
25
+
26
+ let frames = 0;
27
+
28
+ while (i < buf.length) {
29
+ const b = buf[i];
30
+
31
+ if (b === 0x3b) {
32
+ // Trailer.
33
+ break;
34
+ }
35
+
36
+ if (b === 0x21) {
37
+ // Extension block: 0x21, label, then sub-blocks until 0x00.
38
+ i += 2;
39
+ i = skipSubBlocks(buf, i);
40
+ continue;
41
+ }
42
+
43
+ if (b === 0x2c) {
44
+ frames++;
45
+ if (frames > 1) return true;
46
+
47
+ // Image descriptor: 9 bytes after the 0x2C separator.
48
+ i += 1 + 9;
49
+ const imgPacked = buf[i - 1];
50
+ if (imgPacked & 0x80) {
51
+ const lctSize = 3 * (1 << ((imgPacked & 0x07) + 1));
52
+ i += lctSize;
53
+ }
54
+ // LZW minimum code size byte.
55
+ i += 1;
56
+ i = skipSubBlocks(buf, i);
57
+ continue;
58
+ }
59
+
60
+ // Unknown byte — abort to avoid infinite loop.
61
+ break;
62
+ }
63
+
64
+ return false;
65
+ }
66
+
67
+ function skipSubBlocks(buf, i) {
68
+ while (i < buf.length) {
69
+ const size = buf[i++];
70
+ if (size === 0) break;
71
+ i += size;
72
+ }
73
+ return i;
74
+ }
package/src/index.js ADDED
@@ -0,0 +1,113 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { scan } from './scanner.js';
4
+ import { convertAll, deleteOriginals } from './converter.js';
5
+ import { rewriteAll } from './rewriter.js';
6
+ import { createLogger, formatBytes } from './logger.js';
7
+
8
+ export async function webpify(options) {
9
+ const {
10
+ cwd,
11
+ dryRun = false,
12
+ keepOriginal = false,
13
+ quality = 80,
14
+ include,
15
+ ignore,
16
+ force = false,
17
+ updateRefs = true,
18
+ verbose = false,
19
+ } = options;
20
+
21
+ const logger = createLogger({ verbose });
22
+
23
+ if (dryRun) {
24
+ logger.info(chalk.cyan('Dry run — no files will be written.'));
25
+ }
26
+
27
+ const scanSpin = ora('Scanning project…').start();
28
+ const { imageFiles, codeFiles } = await scan({ cwd, include, ignore });
29
+ scanSpin.succeed(
30
+ `Found ${chalk.bold(imageFiles.length)} image(s), ${chalk.bold(codeFiles.length)} code file(s).`
31
+ );
32
+
33
+ if (imageFiles.length === 0) {
34
+ logger.info(chalk.gray('Nothing to convert.'));
35
+ return { converted: 0, filesUpdated: 0 };
36
+ }
37
+
38
+ const convSpin = ora('Converting to WebP…').start();
39
+ const { mapping, skippedAnimatedGifs, bytesBefore, bytesAfter, failed } = await convertAll({
40
+ imageFiles,
41
+ quality,
42
+ force,
43
+ dryRun,
44
+ verbose,
45
+ logger,
46
+ });
47
+ convSpin.succeed(`Converted ${chalk.bold(mapping.size)} image(s).`);
48
+
49
+ let filesUpdated = 0;
50
+ let dynamicWarnings = [];
51
+ if (updateRefs && mapping.size > 0) {
52
+ const refSpin = ora('Updating code references…').start();
53
+ const result = await rewriteAll({
54
+ codeFiles,
55
+ mapping,
56
+ cwd,
57
+ dryRun,
58
+ verbose,
59
+ logger,
60
+ });
61
+ filesUpdated = result.filesUpdated;
62
+ dynamicWarnings = result.dynamicWarnings;
63
+ refSpin.succeed(`Updated refs in ${chalk.bold(filesUpdated)} file(s).`);
64
+ }
65
+
66
+ let deleted = 0;
67
+ if (!keepOriginal && mapping.size > 0) {
68
+ const delSpin = ora('Removing originals…').start();
69
+ deleted = await deleteOriginals(mapping, { dryRun, logger });
70
+ delSpin.succeed(`Removed ${chalk.bold(deleted)} original file(s).`);
71
+ }
72
+
73
+ // Summary
74
+ const savedBytes = Math.max(0, bytesBefore - bytesAfter);
75
+ console.log('');
76
+ console.log(chalk.bold('Summary'));
77
+ console.log(` Converted: ${mapping.size}`);
78
+ if (failed) console.log(` Failed: ${chalk.red(failed)}`);
79
+ if (skippedAnimatedGifs.length)
80
+ console.log(` Skipped (gif): ${skippedAnimatedGifs.length} animated`);
81
+ console.log(` Refs updated in: ${filesUpdated} file(s)`);
82
+ if (!keepOriginal) console.log(` Originals removed: ${deleted}`);
83
+ console.log(
84
+ ` Bytes saved: ${chalk.green(formatBytes(savedBytes))} ` +
85
+ chalk.gray(`(${formatBytes(bytesBefore)} → ${formatBytes(bytesAfter)})`)
86
+ );
87
+
88
+ if (dynamicWarnings.length) {
89
+ console.log('');
90
+ console.log(chalk.yellow.bold(`! ${dynamicWarnings.length} dynamic image ref(s) need manual review:`));
91
+ for (const w of dynamicWarnings.slice(0, 10)) {
92
+ console.log(chalk.yellow(` ${w.file}: ${w.match} (${w.reason})`));
93
+ }
94
+ if (dynamicWarnings.length > 10) {
95
+ console.log(chalk.yellow(` …and ${dynamicWarnings.length - 10} more`));
96
+ }
97
+ }
98
+
99
+ if (dryRun) {
100
+ console.log('');
101
+ console.log(chalk.cyan('Dry run complete. Re-run without --dry-run to apply.'));
102
+ }
103
+
104
+ return {
105
+ converted: mapping.size,
106
+ filesUpdated,
107
+ skippedAnimatedGifs: skippedAnimatedGifs.length,
108
+ bytesBefore,
109
+ bytesAfter,
110
+ failed,
111
+ dynamicWarnings,
112
+ };
113
+ }
package/src/logger.js ADDED
@@ -0,0 +1,24 @@
1
+ import chalk from 'chalk';
2
+
3
+ export function createLogger({ verbose }) {
4
+ return {
5
+ info: (msg) => console.log(msg),
6
+ verbose: (msg) => {
7
+ if (verbose) console.log(chalk.gray(' ' + msg));
8
+ },
9
+ warn: (msg) => console.warn(chalk.yellow('! ' + msg)),
10
+ error: (msg) => console.error(chalk.red('✖ ' + msg)),
11
+ };
12
+ }
13
+
14
+ export function formatBytes(n) {
15
+ if (!Number.isFinite(n) || n <= 0) return '0 B';
16
+ const units = ['B', 'KB', 'MB', 'GB'];
17
+ let i = 0;
18
+ let v = n;
19
+ while (v >= 1024 && i < units.length - 1) {
20
+ v /= 1024;
21
+ i++;
22
+ }
23
+ return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
24
+ }
Binary file
package/src/scanner.js ADDED
@@ -0,0 +1,52 @@
1
+ import fg from 'fast-glob';
2
+ import path from 'node:path';
3
+
4
+ const DEFAULT_INCLUDE = ['src', 'public', 'assets', 'static', 'app', 'pages', 'components'];
5
+ const DEFAULT_IGNORE = [
6
+ '**/node_modules/**',
7
+ '**/dist/**',
8
+ '**/build/**',
9
+ '**/.git/**',
10
+ '**/.next/**',
11
+ '**/.turbo/**',
12
+ '**/.cache/**',
13
+ '**/coverage/**',
14
+ '**/out/**',
15
+ ];
16
+
17
+ const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif'];
18
+ const CODE_EXTS = ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'css', 'scss', 'sass', 'less', 'html', 'htm', 'json'];
19
+
20
+ export async function scan({ cwd, include, ignore }) {
21
+ const roots = include && include.length ? include : DEFAULT_INCLUDE;
22
+ const ignoreList = [...DEFAULT_IGNORE, ...(ignore || [])];
23
+
24
+ const existingRoots = await filterExistingRoots(cwd, roots);
25
+ const searchRoots = existingRoots.length ? existingRoots : ['.'];
26
+
27
+ const imageGlobs = searchRoots.map((r) => `${r}/**/*.{${IMAGE_EXTS.join(',')}}`);
28
+ const codeGlobs = searchRoots.map((r) => `${r}/**/*.{${CODE_EXTS.join(',')}}`);
29
+
30
+ const [imageFiles, codeFiles] = await Promise.all([
31
+ fg(imageGlobs, { cwd, ignore: ignoreList, absolute: true, caseSensitiveMatch: false, dot: false }),
32
+ fg(codeGlobs, { cwd, ignore: ignoreList, absolute: true, caseSensitiveMatch: false, dot: false }),
33
+ ]);
34
+
35
+ return { imageFiles, codeFiles };
36
+ }
37
+
38
+ async function filterExistingRoots(cwd, roots) {
39
+ const fs = await import('node:fs/promises');
40
+ const out = [];
41
+ for (const r of roots) {
42
+ try {
43
+ const stat = await fs.stat(path.join(cwd, r));
44
+ if (stat.isDirectory()) out.push(r);
45
+ } catch {
46
+ // skip missing dir
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ export const __test = { DEFAULT_INCLUDE, DEFAULT_IGNORE, IMAGE_EXTS, CODE_EXTS };