@binarynoir/vite-plugin-optimize-images 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BinaryNoir
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,110 @@
1
+ # vite-plugin-optimize-images
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@binarynoir/vite-plugin-optimize-images.svg)](https://www.npmjs.com/package/@binarynoir/vite-plugin-optimize-images)
4
+ [![CI](https://github.com/binarynoir/vite-plugin-optimize-images/actions/workflows/ci.yml/badge.svg)](https://github.com/binarynoir/vite-plugin-optimize-images/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/@binarynoir/vite-plugin-optimize-images.svg)](LICENSE)
6
+
7
+ Re-encodes PNG, JPEG, and WebP assets with [sharp](https://sharp.pixelplumbing.com)
8
+ as [Vite](https://vite.dev) writes them to the dist bundle — smaller images in
9
+ your build output with no change to your source files, no separate
10
+ build-your-own-assets step, and no manual re-compression before every commit.
11
+
12
+ - **Never modifies source files** — only the copies written to `dist/` are
13
+ touched.
14
+ - **Only optimizes what actually ships** — images that never make it into the
15
+ bundle are never processed.
16
+ - **Keeps the original if optimization doesn't help** — a re-encode is only
17
+ applied when it actually shrinks the file by a meaningful amount (see
18
+ `minSavings`).
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ npm install --save-dev @binarynoir/vite-plugin-optimize-images
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```ts
29
+ // vite.config.ts
30
+ import { defineConfig } from "vite";
31
+ import { optimizeImagesPlugin } from "@binarynoir/vite-plugin-optimize-images";
32
+
33
+ export default defineConfig({
34
+ plugins: [optimizeImagesPlugin()],
35
+ });
36
+ ```
37
+
38
+ That's it — `.png`, `.jpg`/`.jpeg`, and `.webp` assets in your build output
39
+ get re-encoded automatically. Run a build with `VITE_OPTIMIZE_VERBOSE`-style
40
+ visibility by passing `verbose: true` (see below) to see what was optimized
41
+ and by how much.
42
+
43
+ ## Options
44
+
45
+ | Option | Default | Description |
46
+ | ------------ | ------- | -------------------------------------------------------------------------------------- |
47
+ | `minSize` | `10240` | Minimum source size (bytes) before an image is considered for optimization (10 KiB). |
48
+ | `minSavings` | `1024` | Minimum bytes an optimization must save before it's applied (1 KiB). |
49
+ | `png` | — | sharp [PNG encode options](https://sharp.pixelplumbing.com/api-output#png) override. |
50
+ | `jpeg` | — | sharp [JPEG encode options](https://sharp.pixelplumbing.com/api-output#jpeg) override. |
51
+ | `webp` | — | sharp [WebP encode options](https://sharp.pixelplumbing.com/api-output#webp) override. |
52
+ | `verbose` | `false` | Log progress and per-file savings to the console. |
53
+
54
+ Defaults, before any override:
55
+
56
+ ```ts
57
+ {
58
+ png: { quality: 80, compressionLevel: 9, adaptiveFiltering: true },
59
+ jpeg: { quality: 85, progressive: true, mozjpeg: true },
60
+ webp: { quality: 85 },
61
+ }
62
+ ```
63
+
64
+ ```ts
65
+ optimizeImagesPlugin({
66
+ minSize: 5 * 1024,
67
+ minSavings: 512,
68
+ jpeg: { quality: 75 },
69
+ verbose: true,
70
+ });
71
+ ```
72
+
73
+ ## Why not `vite-plugin-imagemin` / `vite-imagetools` / etc.?
74
+
75
+ Those are great, more general options if you want format conversion, resizing,
76
+ or a wider codec set. This plugin is intentionally small: it re-encodes the
77
+ three formats sharp handles fastest, only ever operates on what's already in
78
+ the output bundle (so it composes cleanly with any other asset pipeline you
79
+ have), and never risks producing a _larger_ file than it started with.
80
+
81
+ ## Releasing
82
+
83
+ Releases are tag-triggered. To ship a new version, from a clean `main` that's
84
+ in sync with `origin/main`:
85
+
86
+ ```sh
87
+ npm run release:patch # or release:minor / release:major
88
+ ```
89
+
90
+ This runs typecheck/lint/test/build locally, then `npm version <bump>`
91
+ (bumps `package.json`, commits, and creates a matching `vX.Y.Z` tag) and
92
+ `git push --follow-tags`. Pushing that tag triggers
93
+ [`.github/workflows/release.yml`](.github/workflows/release.yml), which
94
+ re-runs the checks, publishes to npm (with
95
+ [provenance](https://docs.npmjs.com/generating-provenance-statements)), and
96
+ creates a GitHub release with auto-generated notes.
97
+
98
+ For a prerelease or an explicit version, use `npm run release -- <arg>`
99
+ (e.g. `npm run release -- 1.2.3` or `npm run release -- prerelease`) — see
100
+ [`npm version`](https://docs.npmjs.com/cli/v10/commands/npm-version) for the
101
+ full list of accepted values.
102
+
103
+ This requires an `NPM_TOKEN` repository secret (an npm
104
+ [automation token](https://docs.npmjs.com/creating-and-viewing-access-tokens)
105
+ with publish access) — set it under Settings → Secrets and variables →
106
+ Actions. First time publishing this package? See [PUBLISHING.md](PUBLISHING.md).
107
+
108
+ ## License
109
+
110
+ [MIT](LICENSE)
package/dist/index.cjs ADDED
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ optimizeImagesPlugin: () => optimizeImagesPlugin
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+
37
+ // src/plugin.ts
38
+ var import_sharp = __toESM(require("sharp"), 1);
39
+ var import_node_path = __toESM(require("path"), 1);
40
+ var DEFAULT_MIN_SIZE = 10 * 1024;
41
+ var DEFAULT_MIN_SAVINGS = 1024;
42
+ function optimizeImagesPlugin(options = {}) {
43
+ const minSize = options.minSize ?? DEFAULT_MIN_SIZE;
44
+ const minSavings = options.minSavings ?? DEFAULT_MIN_SAVINGS;
45
+ const verbose = options.verbose ?? false;
46
+ const pngOptions = { quality: 80, compressionLevel: 9, adaptiveFiltering: true, ...options.png };
47
+ const jpegOptions = { quality: 85, progressive: true, mozjpeg: true, ...options.jpeg };
48
+ const webpOptions = { quality: 85, ...options.webp };
49
+ return {
50
+ name: "vite-plugin-optimize-images",
51
+ enforce: "post",
52
+ // Run after other plugins
53
+ async generateBundle(_outputOptions, bundle) {
54
+ let optimizedCount = 0;
55
+ let totalSavings = 0;
56
+ let totalOriginalSize = 0;
57
+ const imageAssets = Object.entries(bundle).filter(
58
+ ([fileName, asset]) => asset && asset.type === "asset" && typeof asset.source !== "string" && fileName.match(/\.(jpg|jpeg|png|webp)$/i) && asset.source.length >= minSize
59
+ );
60
+ if (imageAssets.length === 0) {
61
+ if (verbose) {
62
+ console.log("[vite-plugin-optimize-images] No images found in bundle");
63
+ }
64
+ return;
65
+ }
66
+ if (verbose) {
67
+ console.log(
68
+ `[vite-plugin-optimize-images] Found ${imageAssets.length} images to process...`
69
+ );
70
+ }
71
+ for (const [fileName, bundleAsset] of imageAssets) {
72
+ if (bundleAsset.type !== "asset" || typeof bundleAsset.source === "string") {
73
+ continue;
74
+ }
75
+ const asset = bundleAsset;
76
+ const originalSize = asset.source.length;
77
+ const originalBuffer = Buffer.from(asset.source);
78
+ try {
79
+ const ext = import_node_path.default.extname(fileName).toLowerCase();
80
+ let optimizedBuffer;
81
+ if (ext === ".png") {
82
+ optimizedBuffer = await (0, import_sharp.default)(originalBuffer).png(pngOptions).toBuffer();
83
+ } else if (ext === ".jpg" || ext === ".jpeg") {
84
+ optimizedBuffer = await (0, import_sharp.default)(originalBuffer).jpeg(jpegOptions).toBuffer();
85
+ } else if (ext === ".webp") {
86
+ optimizedBuffer = await (0, import_sharp.default)(originalBuffer).webp(webpOptions).toBuffer();
87
+ } else {
88
+ continue;
89
+ }
90
+ const optimizedSize = optimizedBuffer.length;
91
+ const savings = originalSize - optimizedSize;
92
+ const savingsPercent = (savings / originalSize * 100).toFixed(1);
93
+ if (savings > minSavings) {
94
+ asset.source = optimizedBuffer;
95
+ optimizedCount++;
96
+ totalSavings += savings;
97
+ totalOriginalSize += originalSize;
98
+ if (verbose) {
99
+ const originalKB = (originalSize / 1024).toFixed(2);
100
+ const optimizedKB = (optimizedSize / 1024).toFixed(2);
101
+ console.log(
102
+ `[vite-plugin-optimize-images] \u2713 ${fileName}: ${originalKB} KB \u2192 ${optimizedKB} KB (-${savingsPercent}%)`
103
+ );
104
+ }
105
+ }
106
+ } catch (error) {
107
+ const errorMsg = error instanceof Error ? error.message : String(error);
108
+ console.warn(`[vite-plugin-optimize-images] Failed to optimize ${fileName}:`, errorMsg);
109
+ }
110
+ }
111
+ if (optimizedCount > 0) {
112
+ const totalOriginalMB = (totalOriginalSize / (1024 * 1024)).toFixed(2);
113
+ const totalOptimizedSize = totalOriginalSize - totalSavings;
114
+ const totalOptimizedMB = (totalOptimizedSize / (1024 * 1024)).toFixed(2);
115
+ const totalSavingsMB = (totalSavings / (1024 * 1024)).toFixed(2);
116
+ const savingsPercent = (totalSavings / totalOriginalSize * 100).toFixed(1);
117
+ console.log(`[vite-plugin-optimize-images] Optimization complete:`);
118
+ console.log(` Optimized: ${optimizedCount} images`);
119
+ console.log(` Original size: ${totalOriginalMB} MB`);
120
+ console.log(` Optimized size: ${totalOptimizedMB} MB`);
121
+ console.log(` Total savings: ${totalSavingsMB} MB (-${savingsPercent}%)`);
122
+ } else {
123
+ console.log("[vite-plugin-optimize-images] No images required optimization");
124
+ }
125
+ }
126
+ };
127
+ }
128
+ // Annotate the CommonJS export names for ESM import in node:
129
+ 0 && (module.exports = {
130
+ optimizeImagesPlugin
131
+ });
132
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/plugin.ts"],"sourcesContent":["export { optimizeImagesPlugin } from \"./plugin.js\";\nexport type {\n OptimizeImagesOptions,\n PngEncodeOptions,\n JpegEncodeOptions,\n WebpEncodeOptions,\n} from \"./types.js\";\n","import type { Plugin } from \"vite\";\nimport sharp from \"sharp\";\nimport path from \"node:path\";\nimport type { OptimizeImagesOptions } from \"./types.js\";\n\nconst DEFAULT_MIN_SIZE = 10 * 1024;\nconst DEFAULT_MIN_SAVINGS = 1024;\n\n/**\n * Vite plugin that optimizes images as they're written to the dist bundle.\n * - Never modifies source files\n * - Optimizes only images that actually make it to dist\n */\nexport function optimizeImagesPlugin(options: OptimizeImagesOptions = {}): Plugin {\n const minSize = options.minSize ?? DEFAULT_MIN_SIZE;\n const minSavings = options.minSavings ?? DEFAULT_MIN_SAVINGS;\n const verbose = options.verbose ?? false;\n const pngOptions = { quality: 80, compressionLevel: 9, adaptiveFiltering: true, ...options.png };\n const jpegOptions = { quality: 85, progressive: true, mozjpeg: true, ...options.jpeg };\n const webpOptions = { quality: 85, ...options.webp };\n\n return {\n name: \"vite-plugin-optimize-images\",\n enforce: \"post\", // Run after other plugins\n\n async generateBundle(_outputOptions, bundle) {\n let optimizedCount = 0;\n let totalSavings = 0;\n let totalOriginalSize = 0;\n\n // Count image assets first to avoid premature \"no images\" message\n const imageAssets = Object.entries(bundle).filter(\n ([fileName, asset]) =>\n asset &&\n asset.type === \"asset\" &&\n typeof asset.source !== \"string\" &&\n fileName.match(/\\.(jpg|jpeg|png|webp)$/i) &&\n asset.source.length >= minSize,\n );\n\n // Skip processing if no eligible images\n if (imageAssets.length === 0) {\n if (verbose) {\n console.log(\"[vite-plugin-optimize-images] No images found in bundle\");\n }\n return;\n }\n\n if (verbose) {\n console.log(\n `[vite-plugin-optimize-images] Found ${imageAssets.length} images to process...`,\n );\n }\n\n // Process all eligible image assets\n for (const [fileName, bundleAsset] of imageAssets) {\n // Type guard: we already filtered for assets with source\n if (bundleAsset.type !== \"asset\" || typeof bundleAsset.source === \"string\") {\n continue;\n }\n\n const asset = bundleAsset;\n const originalSize = asset.source.length;\n const originalBuffer = Buffer.from(asset.source);\n\n try {\n // Optimize based on file type\n const ext = path.extname(fileName).toLowerCase();\n let optimizedBuffer: Buffer;\n\n if (ext === \".png\") {\n optimizedBuffer = await sharp(originalBuffer).png(pngOptions).toBuffer();\n } else if (ext === \".jpg\" || ext === \".jpeg\") {\n optimizedBuffer = await sharp(originalBuffer).jpeg(jpegOptions).toBuffer();\n } else if (ext === \".webp\") {\n optimizedBuffer = await sharp(originalBuffer).webp(webpOptions).toBuffer();\n } else {\n continue;\n }\n\n // Only use optimized version if it's actually smaller\n const optimizedSize = optimizedBuffer.length;\n const savings = originalSize - optimizedSize;\n const savingsPercent = ((savings / originalSize) * 100).toFixed(1);\n\n if (savings > minSavings) {\n asset.source = optimizedBuffer;\n optimizedCount++;\n totalSavings += savings;\n totalOriginalSize += originalSize;\n\n if (verbose) {\n const originalKB = (originalSize / 1024).toFixed(2);\n const optimizedKB = (optimizedSize / 1024).toFixed(2);\n console.log(\n `[vite-plugin-optimize-images] ✓ ${fileName}: ${originalKB} KB → ${optimizedKB} KB (-${savingsPercent}%)`,\n );\n }\n }\n } catch (error) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n console.warn(`[vite-plugin-optimize-images] Failed to optimize ${fileName}:`, errorMsg);\n }\n }\n\n // Show final summary after processing all images\n if (optimizedCount > 0) {\n const totalOriginalMB = (totalOriginalSize / (1024 * 1024)).toFixed(2);\n const totalOptimizedSize = totalOriginalSize - totalSavings;\n const totalOptimizedMB = (totalOptimizedSize / (1024 * 1024)).toFixed(2);\n const totalSavingsMB = (totalSavings / (1024 * 1024)).toFixed(2);\n const savingsPercent = ((totalSavings / totalOriginalSize) * 100).toFixed(1);\n\n console.log(`[vite-plugin-optimize-images] Optimization complete:`);\n console.log(` Optimized: ${optimizedCount} images`);\n console.log(` Original size: ${totalOriginalMB} MB`);\n console.log(` Optimized size: ${totalOptimizedMB} MB`);\n console.log(` Total savings: ${totalSavingsMB} MB (-${savingsPercent}%)`);\n } else {\n console.log(\"[vite-plugin-optimize-images] No images required optimization\");\n }\n },\n };\n}\n"],"mappings":"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","names":["path","sharp"]}
@@ -0,0 +1,50 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ interface PngEncodeOptions {
4
+ quality?: number;
5
+ compressionLevel?: number;
6
+ adaptiveFiltering?: boolean;
7
+ }
8
+ interface JpegEncodeOptions {
9
+ quality?: number;
10
+ progressive?: boolean;
11
+ mozjpeg?: boolean;
12
+ }
13
+ interface WebpEncodeOptions {
14
+ quality?: number;
15
+ }
16
+ interface OptimizeImagesOptions {
17
+ /**
18
+ * Minimum source size (bytes) before an image is considered for
19
+ * optimization.
20
+ * @default 10240 (10 KiB)
21
+ */
22
+ minSize?: number;
23
+ /**
24
+ * Minimum bytes an optimization must save before it's applied — a
25
+ * re-encode that doesn't clear this bar is discarded and the original
26
+ * bytes are kept.
27
+ * @default 1024 (1 KiB)
28
+ */
29
+ minSavings?: number;
30
+ /** sharp PNG encode options. */
31
+ png?: PngEncodeOptions;
32
+ /** sharp JPEG encode options. */
33
+ jpeg?: JpegEncodeOptions;
34
+ /** sharp WebP encode options. */
35
+ webp?: WebpEncodeOptions;
36
+ /**
37
+ * Log progress and per-file savings to the console.
38
+ * @default false
39
+ */
40
+ verbose?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Vite plugin that optimizes images as they're written to the dist bundle.
45
+ * - Never modifies source files
46
+ * - Optimizes only images that actually make it to dist
47
+ */
48
+ declare function optimizeImagesPlugin(options?: OptimizeImagesOptions): Plugin;
49
+
50
+ export { type JpegEncodeOptions, type OptimizeImagesOptions, type PngEncodeOptions, type WebpEncodeOptions, optimizeImagesPlugin };
@@ -0,0 +1,50 @@
1
+ import { Plugin } from 'vite';
2
+
3
+ interface PngEncodeOptions {
4
+ quality?: number;
5
+ compressionLevel?: number;
6
+ adaptiveFiltering?: boolean;
7
+ }
8
+ interface JpegEncodeOptions {
9
+ quality?: number;
10
+ progressive?: boolean;
11
+ mozjpeg?: boolean;
12
+ }
13
+ interface WebpEncodeOptions {
14
+ quality?: number;
15
+ }
16
+ interface OptimizeImagesOptions {
17
+ /**
18
+ * Minimum source size (bytes) before an image is considered for
19
+ * optimization.
20
+ * @default 10240 (10 KiB)
21
+ */
22
+ minSize?: number;
23
+ /**
24
+ * Minimum bytes an optimization must save before it's applied — a
25
+ * re-encode that doesn't clear this bar is discarded and the original
26
+ * bytes are kept.
27
+ * @default 1024 (1 KiB)
28
+ */
29
+ minSavings?: number;
30
+ /** sharp PNG encode options. */
31
+ png?: PngEncodeOptions;
32
+ /** sharp JPEG encode options. */
33
+ jpeg?: JpegEncodeOptions;
34
+ /** sharp WebP encode options. */
35
+ webp?: WebpEncodeOptions;
36
+ /**
37
+ * Log progress and per-file savings to the console.
38
+ * @default false
39
+ */
40
+ verbose?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Vite plugin that optimizes images as they're written to the dist bundle.
45
+ * - Never modifies source files
46
+ * - Optimizes only images that actually make it to dist
47
+ */
48
+ declare function optimizeImagesPlugin(options?: OptimizeImagesOptions): Plugin;
49
+
50
+ export { type JpegEncodeOptions, type OptimizeImagesOptions, type PngEncodeOptions, type WebpEncodeOptions, optimizeImagesPlugin };
package/dist/index.js ADDED
@@ -0,0 +1,95 @@
1
+ // src/plugin.ts
2
+ import sharp from "sharp";
3
+ import path from "path";
4
+ var DEFAULT_MIN_SIZE = 10 * 1024;
5
+ var DEFAULT_MIN_SAVINGS = 1024;
6
+ function optimizeImagesPlugin(options = {}) {
7
+ const minSize = options.minSize ?? DEFAULT_MIN_SIZE;
8
+ const minSavings = options.minSavings ?? DEFAULT_MIN_SAVINGS;
9
+ const verbose = options.verbose ?? false;
10
+ const pngOptions = { quality: 80, compressionLevel: 9, adaptiveFiltering: true, ...options.png };
11
+ const jpegOptions = { quality: 85, progressive: true, mozjpeg: true, ...options.jpeg };
12
+ const webpOptions = { quality: 85, ...options.webp };
13
+ return {
14
+ name: "vite-plugin-optimize-images",
15
+ enforce: "post",
16
+ // Run after other plugins
17
+ async generateBundle(_outputOptions, bundle) {
18
+ let optimizedCount = 0;
19
+ let totalSavings = 0;
20
+ let totalOriginalSize = 0;
21
+ const imageAssets = Object.entries(bundle).filter(
22
+ ([fileName, asset]) => asset && asset.type === "asset" && typeof asset.source !== "string" && fileName.match(/\.(jpg|jpeg|png|webp)$/i) && asset.source.length >= minSize
23
+ );
24
+ if (imageAssets.length === 0) {
25
+ if (verbose) {
26
+ console.log("[vite-plugin-optimize-images] No images found in bundle");
27
+ }
28
+ return;
29
+ }
30
+ if (verbose) {
31
+ console.log(
32
+ `[vite-plugin-optimize-images] Found ${imageAssets.length} images to process...`
33
+ );
34
+ }
35
+ for (const [fileName, bundleAsset] of imageAssets) {
36
+ if (bundleAsset.type !== "asset" || typeof bundleAsset.source === "string") {
37
+ continue;
38
+ }
39
+ const asset = bundleAsset;
40
+ const originalSize = asset.source.length;
41
+ const originalBuffer = Buffer.from(asset.source);
42
+ try {
43
+ const ext = path.extname(fileName).toLowerCase();
44
+ let optimizedBuffer;
45
+ if (ext === ".png") {
46
+ optimizedBuffer = await sharp(originalBuffer).png(pngOptions).toBuffer();
47
+ } else if (ext === ".jpg" || ext === ".jpeg") {
48
+ optimizedBuffer = await sharp(originalBuffer).jpeg(jpegOptions).toBuffer();
49
+ } else if (ext === ".webp") {
50
+ optimizedBuffer = await sharp(originalBuffer).webp(webpOptions).toBuffer();
51
+ } else {
52
+ continue;
53
+ }
54
+ const optimizedSize = optimizedBuffer.length;
55
+ const savings = originalSize - optimizedSize;
56
+ const savingsPercent = (savings / originalSize * 100).toFixed(1);
57
+ if (savings > minSavings) {
58
+ asset.source = optimizedBuffer;
59
+ optimizedCount++;
60
+ totalSavings += savings;
61
+ totalOriginalSize += originalSize;
62
+ if (verbose) {
63
+ const originalKB = (originalSize / 1024).toFixed(2);
64
+ const optimizedKB = (optimizedSize / 1024).toFixed(2);
65
+ console.log(
66
+ `[vite-plugin-optimize-images] \u2713 ${fileName}: ${originalKB} KB \u2192 ${optimizedKB} KB (-${savingsPercent}%)`
67
+ );
68
+ }
69
+ }
70
+ } catch (error) {
71
+ const errorMsg = error instanceof Error ? error.message : String(error);
72
+ console.warn(`[vite-plugin-optimize-images] Failed to optimize ${fileName}:`, errorMsg);
73
+ }
74
+ }
75
+ if (optimizedCount > 0) {
76
+ const totalOriginalMB = (totalOriginalSize / (1024 * 1024)).toFixed(2);
77
+ const totalOptimizedSize = totalOriginalSize - totalSavings;
78
+ const totalOptimizedMB = (totalOptimizedSize / (1024 * 1024)).toFixed(2);
79
+ const totalSavingsMB = (totalSavings / (1024 * 1024)).toFixed(2);
80
+ const savingsPercent = (totalSavings / totalOriginalSize * 100).toFixed(1);
81
+ console.log(`[vite-plugin-optimize-images] Optimization complete:`);
82
+ console.log(` Optimized: ${optimizedCount} images`);
83
+ console.log(` Original size: ${totalOriginalMB} MB`);
84
+ console.log(` Optimized size: ${totalOptimizedMB} MB`);
85
+ console.log(` Total savings: ${totalSavingsMB} MB (-${savingsPercent}%)`);
86
+ } else {
87
+ console.log("[vite-plugin-optimize-images] No images required optimization");
88
+ }
89
+ }
90
+ };
91
+ }
92
+ export {
93
+ optimizeImagesPlugin
94
+ };
95
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Plugin } from \"vite\";\nimport sharp from \"sharp\";\nimport path from \"node:path\";\nimport type { OptimizeImagesOptions } from \"./types.js\";\n\nconst DEFAULT_MIN_SIZE = 10 * 1024;\nconst DEFAULT_MIN_SAVINGS = 1024;\n\n/**\n * Vite plugin that optimizes images as they're written to the dist bundle.\n * - Never modifies source files\n * - Optimizes only images that actually make it to dist\n */\nexport function optimizeImagesPlugin(options: OptimizeImagesOptions = {}): Plugin {\n const minSize = options.minSize ?? DEFAULT_MIN_SIZE;\n const minSavings = options.minSavings ?? DEFAULT_MIN_SAVINGS;\n const verbose = options.verbose ?? false;\n const pngOptions = { quality: 80, compressionLevel: 9, adaptiveFiltering: true, ...options.png };\n const jpegOptions = { quality: 85, progressive: true, mozjpeg: true, ...options.jpeg };\n const webpOptions = { quality: 85, ...options.webp };\n\n return {\n name: \"vite-plugin-optimize-images\",\n enforce: \"post\", // Run after other plugins\n\n async generateBundle(_outputOptions, bundle) {\n let optimizedCount = 0;\n let totalSavings = 0;\n let totalOriginalSize = 0;\n\n // Count image assets first to avoid premature \"no images\" message\n const imageAssets = Object.entries(bundle).filter(\n ([fileName, asset]) =>\n asset &&\n asset.type === \"asset\" &&\n typeof asset.source !== \"string\" &&\n fileName.match(/\\.(jpg|jpeg|png|webp)$/i) &&\n asset.source.length >= minSize,\n );\n\n // Skip processing if no eligible images\n if (imageAssets.length === 0) {\n if (verbose) {\n console.log(\"[vite-plugin-optimize-images] No images found in bundle\");\n }\n return;\n }\n\n if (verbose) {\n console.log(\n `[vite-plugin-optimize-images] Found ${imageAssets.length} images to process...`,\n );\n }\n\n // Process all eligible image assets\n for (const [fileName, bundleAsset] of imageAssets) {\n // Type guard: we already filtered for assets with source\n if (bundleAsset.type !== \"asset\" || typeof bundleAsset.source === \"string\") {\n continue;\n }\n\n const asset = bundleAsset;\n const originalSize = asset.source.length;\n const originalBuffer = Buffer.from(asset.source);\n\n try {\n // Optimize based on file type\n const ext = path.extname(fileName).toLowerCase();\n let optimizedBuffer: Buffer;\n\n if (ext === \".png\") {\n optimizedBuffer = await sharp(originalBuffer).png(pngOptions).toBuffer();\n } else if (ext === \".jpg\" || ext === \".jpeg\") {\n optimizedBuffer = await sharp(originalBuffer).jpeg(jpegOptions).toBuffer();\n } else if (ext === \".webp\") {\n optimizedBuffer = await sharp(originalBuffer).webp(webpOptions).toBuffer();\n } else {\n continue;\n }\n\n // Only use optimized version if it's actually smaller\n const optimizedSize = optimizedBuffer.length;\n const savings = originalSize - optimizedSize;\n const savingsPercent = ((savings / originalSize) * 100).toFixed(1);\n\n if (savings > minSavings) {\n asset.source = optimizedBuffer;\n optimizedCount++;\n totalSavings += savings;\n totalOriginalSize += originalSize;\n\n if (verbose) {\n const originalKB = (originalSize / 1024).toFixed(2);\n const optimizedKB = (optimizedSize / 1024).toFixed(2);\n console.log(\n `[vite-plugin-optimize-images] ✓ ${fileName}: ${originalKB} KB → ${optimizedKB} KB (-${savingsPercent}%)`,\n );\n }\n }\n } catch (error) {\n const errorMsg = error instanceof Error ? error.message : String(error);\n console.warn(`[vite-plugin-optimize-images] Failed to optimize ${fileName}:`, errorMsg);\n }\n }\n\n // Show final summary after processing all images\n if (optimizedCount > 0) {\n const totalOriginalMB = (totalOriginalSize / (1024 * 1024)).toFixed(2);\n const totalOptimizedSize = totalOriginalSize - totalSavings;\n const totalOptimizedMB = (totalOptimizedSize / (1024 * 1024)).toFixed(2);\n const totalSavingsMB = (totalSavings / (1024 * 1024)).toFixed(2);\n const savingsPercent = ((totalSavings / totalOriginalSize) * 100).toFixed(1);\n\n console.log(`[vite-plugin-optimize-images] Optimization complete:`);\n console.log(` Optimized: ${optimizedCount} images`);\n console.log(` Original size: ${totalOriginalMB} MB`);\n console.log(` Optimized size: ${totalOptimizedMB} MB`);\n console.log(` Total savings: ${totalSavingsMB} MB (-${savingsPercent}%)`);\n } else {\n console.log(\"[vite-plugin-optimize-images] No images required optimization\");\n }\n },\n };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "@binarynoir/vite-plugin-optimize-images",
3
+ "version": "0.1.0",
4
+ "description": "A Vite plugin that optimizes PNG, JPEG, and WebP images as they're written to the dist bundle, using sharp — without ever touching your source files.",
5
+ "keywords": [
6
+ "vite",
7
+ "vite-plugin",
8
+ "images",
9
+ "image-optimization",
10
+ "sharp",
11
+ "build",
12
+ "performance"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "BinaryNoir",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/binarynoir/vite-plugin-optimize-images.git"
19
+ },
20
+ "homepage": "https://github.com/binarynoir/vite-plugin-optimize-images#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/binarynoir/vite-plugin-optimize-images/issues"
23
+ },
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "main": "./dist/index.cjs",
27
+ "module": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js",
33
+ "require": "./dist/index.cjs"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "scripts": {
47
+ "build": "tsup",
48
+ "dev": "tsup --watch",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest",
51
+ "typecheck": "tsc --noEmit",
52
+ "lint": "eslint .",
53
+ "lint:fix": "eslint . --fix",
54
+ "format": "prettier --write .",
55
+ "format:check": "prettier --check .",
56
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build",
57
+ "release": "node scripts/release.js",
58
+ "release:patch": "node scripts/release.js patch",
59
+ "release:minor": "node scripts/release.js minor",
60
+ "release:major": "node scripts/release.js major"
61
+ },
62
+ "peerDependencies": {
63
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
64
+ },
65
+ "dependencies": {
66
+ "sharp": "^0.35.4"
67
+ },
68
+ "devDependencies": {
69
+ "@types/node": "^26.5.1",
70
+ "eslint": "^10.10.0",
71
+ "eslint-config-prettier": "^10.1.8",
72
+ "prettier": "^3.9.6",
73
+ "tsup": "^8.3.5",
74
+ "typescript": "^5.9.2",
75
+ "typescript-eslint": "^8.19.0",
76
+ "vite": "^8.3.0",
77
+ "vitest": "^5.0.1"
78
+ },
79
+ "allowScripts": {
80
+ "esbuild@0.27.7": true
81
+ }
82
+ }