@assetopt/core 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) 2026 Nath
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,132 @@
1
+ # @assetopt/core
2
+
3
+ Pure asset optimization functions for images (sharp), CSS (lightningcss), JS (esbuild), and SVG (svgo). The engine behind the [`@assetopt/cli`](https://www.npmjs.com/package/@assetopt/cli) command-line tool, exported as a standalone library for programmatic use.
4
+
5
+ Open source, MIT.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @assetopt/core
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```ts
16
+ import { optimizeImage, optimizeCss, optimizeJs, optimizeSvg } from '@assetopt/core';
17
+ import { readFile } from 'node:fs/promises';
18
+
19
+ const buf = await readFile('photo.jpg');
20
+ const result = await optimizeImage(buf, { quality: { jpeg: 80 } });
21
+
22
+ console.log(result.format); // 'jpeg'
23
+ console.log(result.originalSize); // 824133
24
+ console.log(result.outputSize); // 245912
25
+ console.log(result.buffer); // Buffer with optimized bytes
26
+ ```
27
+
28
+ Every optimizer is a pure function: a buffer in, an optimized buffer out. No hidden I/O, no global state.
29
+
30
+ ## API
31
+
32
+ ### Optimizers
33
+
34
+ | Function | Underlying lib | Returns |
35
+ | --------------------------------- | -------------- | ---------------------------- |
36
+ | `optimizeImage(buffer, options?)` | `sharp` | `Promise<ImageBufferResult>` |
37
+ | `optimizeCss(buffer, options?)` | `lightningcss` | `CssBufferResult` |
38
+ | `optimizeJs(buffer, options?)` | `esbuild` | `Promise<JsBufferResult>` |
39
+ | `optimizeSvg(buffer, options?)` | `svgo` | `SvgBufferResult` |
40
+
41
+ All option types are optional with sensible defaults:
42
+
43
+ - `ImageOptimizeOptions` — `{ outputFormat?, formatMatrix?, quality?, stripMetadata?, skip? }`
44
+ - `CssOptimizeOptions` — `{ minify? }`
45
+ - `JsOptimizeOptions` — `{ minify? }`
46
+ - `SvgOptimizeOptions` — `{ multipass?, minifyIds? }`
47
+
48
+ ### Pipeline
49
+
50
+ For folder-level orchestration:
51
+
52
+ ```ts
53
+ import { runPipeline, buildReport, loadConfig } from '@assetopt/core';
54
+
55
+ const { config } = await loadConfig(); // walk-up .assetoptrc
56
+ const assets = await runPipeline('./public', config);
57
+ const report = buildReport(assets, /* durationMs */ 0);
58
+ ```
59
+
60
+ ### Format matrix with smart routing
61
+
62
+ `formatMatrix` accepts function values for content-aware routing:
63
+
64
+ ```ts
65
+ import { optimizeImage } from '@assetopt/core';
66
+
67
+ await optimizeImage(buf, {
68
+ formatMatrix: {
69
+ // AVIF for transparent PNGs, WebP for opaque ones
70
+ png: (ctx) => (ctx.hasAlpha ? 'avif' : 'webp'),
71
+ jpeg: 'webp',
72
+ webp: 'keep',
73
+ avif: 'keep',
74
+ },
75
+ });
76
+ ```
77
+
78
+ This is exactly how the built-in `web-perf` preset works.
79
+
80
+ ### Exports
81
+
82
+ ```ts
83
+ // Optimizers (pure functions)
84
+ export { optimizeImage, optimizeCss, optimizeJs, optimizeSvg };
85
+
86
+ // Pipeline / orchestration
87
+ export { dispatch, runPipeline, buildReport };
88
+
89
+ // Config
90
+ export { loadConfig, mergeConfig, resolveConfig, DEFAULTS, PRESETS };
91
+ export type { LoadedConfig };
92
+
93
+ // Utilities
94
+ export { scanDirectory, getAssetType, getFileSize };
95
+ export { CACHE_FILE }; // name of the on-disk cache manifest, relative to output.dir
96
+
97
+ // Types
98
+ export type {
99
+ AssetType,
100
+ AssetResult,
101
+ OptimizeResult,
102
+ ImageQualityOptions,
103
+ ImageOptimizeOptions,
104
+ ImageBufferResult,
105
+ ImageSourceFormat,
106
+ ImageMatrixTarget,
107
+ FormatMatrixValue,
108
+ FormatMatrixResolver,
109
+ FormatRoutingContext,
110
+ PresetName,
111
+ CssOptimizeOptions,
112
+ CssBufferResult,
113
+ JsOptimizeOptions,
114
+ JsBufferResult,
115
+ SvgOptimizeOptions,
116
+ SvgBufferResult,
117
+ DispatchResult,
118
+ AssetoptConfig,
119
+ };
120
+ ```
121
+
122
+ ## Documentation
123
+
124
+ - [Configuration reference](https://github.com/Nathmaxx/assetopt/blob/main/docs/config.md) — every option, every default, programmatic API for `formatMatrix` functions
125
+ - [Feature catalog](https://github.com/Nathmaxx/assetopt/blob/main/docs/features.md) — behavior of each optimizer in plain English
126
+ - [Project README](https://github.com/Nathmaxx/assetopt#readme)
127
+
128
+ For the command-line interface, see [`@assetopt/cli`](https://www.npmjs.com/package/@assetopt/cli).
129
+
130
+ ## License
131
+
132
+ MIT.
@@ -0,0 +1,185 @@
1
+ declare const IMAGE_SOURCE_FORMATS: readonly ["jpeg", "png", "webp", "avif"];
2
+ declare const IMAGE_MATRIX_TARGETS: readonly ["jpeg", "png", "webp", "avif", "keep"];
3
+ declare const IMAGE_OUTPUT_FORMATS: readonly ["keep", "webp", "avif"];
4
+ declare const PRESET_NAMES: readonly ["web-perf", "max-compression", "quality", "compatibility"];
5
+ type ImageSourceFormat = (typeof IMAGE_SOURCE_FORMATS)[number];
6
+ type ImageMatrixTarget = (typeof IMAGE_MATRIX_TARGETS)[number];
7
+ type ImageOutputFormat = (typeof IMAGE_OUTPUT_FORMATS)[number];
8
+ type PresetName = (typeof PRESET_NAMES)[number];
9
+
10
+ type AssetType = 'image' | 'css' | 'js' | 'svg';
11
+ interface AssetResult {
12
+ inputPath: string;
13
+ outputPath: string;
14
+ inputSize: number;
15
+ outputSize: number;
16
+ savedBytes: number;
17
+ savedPercent: number;
18
+ assetType: AssetType;
19
+ durationMs: number;
20
+ cached: boolean;
21
+ }
22
+ interface OptimizeResult {
23
+ assets: AssetResult[];
24
+ totalInputSize: number;
25
+ totalOutputSize: number;
26
+ totalSavedBytes: number;
27
+ totalSavedPercent: number;
28
+ durationMs: number;
29
+ cachedCount: number;
30
+ }
31
+ interface ImageQualityOptions {
32
+ jpeg?: number;
33
+ png?: number;
34
+ webp?: number;
35
+ avif?: number;
36
+ }
37
+ interface FormatRoutingContext {
38
+ hasAlpha: boolean;
39
+ }
40
+ type FormatMatrixResolver = (ctx: FormatRoutingContext) => ImageMatrixTarget;
41
+ type FormatMatrixValue = ImageMatrixTarget | FormatMatrixResolver;
42
+ interface ImageOptimizeOptions {
43
+ outputFormat?: ImageOutputFormat;
44
+ formatMatrix?: Partial<Record<ImageSourceFormat, FormatMatrixValue>>;
45
+ quality?: ImageQualityOptions;
46
+ stripMetadata?: boolean;
47
+ skip?: ImageSourceFormat[];
48
+ }
49
+ interface ImageBufferResult {
50
+ buffer: Buffer;
51
+ format: ImageSourceFormat;
52
+ originalSize: number;
53
+ outputSize: number;
54
+ }
55
+ interface CssOptimizeOptions {
56
+ minify?: boolean;
57
+ }
58
+ interface CssBufferResult {
59
+ buffer: Buffer;
60
+ originalSize: number;
61
+ outputSize: number;
62
+ }
63
+ interface JsOptimizeOptions {
64
+ minify?: boolean;
65
+ }
66
+ interface JsBufferResult {
67
+ buffer: Buffer;
68
+ originalSize: number;
69
+ outputSize: number;
70
+ }
71
+ interface SvgOptimizeOptions {
72
+ multipass?: boolean;
73
+ minifyIds?: boolean;
74
+ }
75
+ interface SvgBufferResult {
76
+ buffer: Buffer;
77
+ originalSize: number;
78
+ outputSize: number;
79
+ }
80
+ type DispatchResult = (ImageBufferResult & {
81
+ assetType: 'image';
82
+ }) | (CssBufferResult & {
83
+ assetType: 'css';
84
+ }) | (JsBufferResult & {
85
+ assetType: 'js';
86
+ }) | (SvgBufferResult & {
87
+ assetType: 'svg';
88
+ });
89
+ interface AssetoptConfig {
90
+ preset?: PresetName;
91
+ images?: ImageOptimizeOptions;
92
+ css?: CssOptimizeOptions;
93
+ js?: JsOptimizeOptions;
94
+ svg?: SvgOptimizeOptions;
95
+ output?: {
96
+ dir?: string;
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Optimize an image buffer using sharp.
102
+ *
103
+ * The output format is resolved with this priority:
104
+ * 1. `options.formatMatrix[inputFormat]` if defined (target format, `'keep'`, or a function)
105
+ * 2. `options.outputFormat` if not `'keep'`
106
+ * 3. The input format (no conversion)
107
+ *
108
+ * Function values in `formatMatrix` receive a `FormatRoutingContext` built from sharp
109
+ * metadata (currently exposes `hasAlpha`). This is how the `web-perf` preset routes
110
+ * PNG to WebP (opaque) or AVIF (transparent) automatically.
111
+ *
112
+ * Defaults come from `IMAGE_DEFAULTS` (quality 85/80/82/75 for jpeg/png/webp/avif,
113
+ * `stripMetadata: true`, `outputFormat: 'keep'`).
114
+ *
115
+ * @param input - Image buffer. Supported source formats: JPEG, PNG, WebP, AVIF.
116
+ * Unrecognized formats fall back to JPEG handling.
117
+ * @param options - All fields optional. See `ImageOptimizeOptions`.
118
+ * @returns Resolved buffer with output format and size metadata.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * // Simple global conversion
123
+ * await optimizeImage(buf, { outputFormat: 'webp' });
124
+ *
125
+ * // Per-format rules (JSON-serializable, usable in .assetoptrc)
126
+ * await optimizeImage(buf, {
127
+ * formatMatrix: { jpeg: 'webp', png: 'avif', webp: 'keep', avif: 'keep' },
128
+ * });
129
+ *
130
+ * // Smart routing (programmatic only — functions can't live in JSON)
131
+ * await optimizeImage(buf, {
132
+ * formatMatrix: { png: (ctx) => ctx.hasAlpha ? 'avif' : 'webp' },
133
+ * });
134
+ * ```
135
+ */
136
+ declare function optimizeImage(input: Buffer, options?: ImageOptimizeOptions): Promise<ImageBufferResult>;
137
+
138
+ declare function optimizeCss(input: Buffer, options?: CssOptimizeOptions): CssBufferResult;
139
+
140
+ declare function optimizeJs(input: Buffer, options?: JsOptimizeOptions): Promise<JsBufferResult>;
141
+
142
+ declare function optimizeSvg(input: Buffer, options?: SvgOptimizeOptions): SvgBufferResult;
143
+
144
+ declare function dispatch(filePath: string, buffer: Buffer, config?: AssetoptConfig): Promise<DispatchResult>;
145
+
146
+ declare function runPipeline(inputDir: string, config?: AssetoptConfig, options?: {
147
+ dryRun?: boolean;
148
+ useCache?: boolean;
149
+ onProgress?: (current: number, total: number, filePath: string) => void;
150
+ }): Promise<AssetResult[]>;
151
+
152
+ declare function buildReport(assets: AssetResult[], durationMs: number): OptimizeResult;
153
+
154
+ /** Conventional file name for the on-disk manifest, relative to `output.dir`. */
155
+ declare const CACHE_FILE = ".assetopt-cache.json";
156
+
157
+ type Config = AssetoptConfig;
158
+
159
+ declare const DEFAULTS: Config;
160
+ /**
161
+ * Result of {@link loadConfig}: the resolved config plus the absolute path of
162
+ * the `.assetoptrc` it came from, or `null` if no config file was found and
163
+ * `DEFAULTS` are being used. Callers (CLI) use `source` to surface a clear
164
+ * indicator about whether the user's config took effect.
165
+ */
166
+ interface LoadedConfig {
167
+ config: Config;
168
+ source: string | null;
169
+ }
170
+ /**
171
+ * Walks up from `cwd` (default `process.cwd()`) looking for `.assetoptrc` or
172
+ * `.assetoptrc.json`. Returns the resolved config plus the absolute path it
173
+ * was loaded from, or `DEFAULTS` with `source: null` if nothing was found.
174
+ */
175
+ declare function loadConfig(cwd?: string): Promise<LoadedConfig>;
176
+ declare function resolveConfig(userConfig: Config): Config;
177
+ declare function mergeConfig(base: Config, override: Partial<Config>): Config;
178
+
179
+ declare const PRESETS: Record<PresetName, Partial<AssetoptConfig>>;
180
+
181
+ declare function getAssetType(filePath: string): AssetType | null;
182
+ declare function scanDirectory(dirPath: string, recursive?: boolean): Promise<string[]>;
183
+ declare function getFileSize(filePath: string): Promise<number>;
184
+
185
+ export { type AssetResult, type AssetType, type AssetoptConfig, CACHE_FILE, type CssBufferResult, type CssOptimizeOptions, DEFAULTS, type DispatchResult, type FormatMatrixResolver, type FormatMatrixValue, type FormatRoutingContext, type ImageBufferResult, type ImageMatrixTarget, type ImageOptimizeOptions, type ImageQualityOptions, type ImageSourceFormat, type JsBufferResult, type JsOptimizeOptions, type LoadedConfig, type OptimizeResult, PRESETS, type PresetName, type SvgBufferResult, type SvgOptimizeOptions, buildReport, dispatch, getAssetType, getFileSize, loadConfig, mergeConfig, optimizeCss, optimizeImage, optimizeJs, optimizeSvg, resolveConfig, runPipeline, scanDirectory };
package/dist/index.js ADDED
@@ -0,0 +1,582 @@
1
+ // src/optimizers/images.ts
2
+ import sharp from "sharp";
3
+
4
+ // src/types/formats.ts
5
+ var IMAGE_SOURCE_FORMATS = ["jpeg", "png", "webp", "avif"];
6
+ var IMAGE_MATRIX_TARGETS = ["jpeg", "png", "webp", "avif", "keep"];
7
+ var IMAGE_OUTPUT_FORMATS = ["keep", "webp", "avif"];
8
+ var PRESET_NAMES = ["web-perf", "max-compression", "quality", "compatibility"];
9
+
10
+ // src/optimizers/images.ts
11
+ var SUPPORTED_INPUT_FORMATS = new Set(IMAGE_SOURCE_FORMATS);
12
+ var IMAGE_DEFAULTS = {
13
+ outputFormat: "keep",
14
+ quality: { jpeg: 85, png: 80, webp: 82, avif: 75 },
15
+ stripMetadata: true
16
+ };
17
+ function normalizeInputFormat(format) {
18
+ if (format === "heif") return "avif";
19
+ if (SUPPORTED_INPUT_FORMATS.has(format)) return format;
20
+ return "jpeg";
21
+ }
22
+ function resolveOutputFormat(inputFormat, outputFormat, formatMatrix, context) {
23
+ const matrixValue = formatMatrix?.[inputFormat];
24
+ if (matrixValue !== void 0) {
25
+ const resolved = typeof matrixValue === "function" ? matrixValue(context) : matrixValue;
26
+ return resolved === "keep" ? inputFormat : resolved;
27
+ }
28
+ if (outputFormat !== "keep") return outputFormat;
29
+ return inputFormat;
30
+ }
31
+ async function optimizeImage(input, options = {}) {
32
+ const outputFormat = options.outputFormat ?? IMAGE_DEFAULTS.outputFormat;
33
+ const quality = { ...IMAGE_DEFAULTS.quality, ...options.quality };
34
+ const stripMetadata = options.stripMetadata ?? IMAGE_DEFAULTS.stripMetadata;
35
+ const image = sharp(input);
36
+ const metadata = await image.metadata();
37
+ const inputFormat = normalizeInputFormat(metadata.format ?? "jpeg");
38
+ const targetFormat = resolveOutputFormat(inputFormat, outputFormat, options.formatMatrix, {
39
+ hasAlpha: metadata.hasAlpha ?? false
40
+ });
41
+ let pipeline = stripMetadata ? image : image.withMetadata();
42
+ switch (targetFormat) {
43
+ case "jpeg":
44
+ pipeline = pipeline.jpeg({ quality: quality.jpeg });
45
+ break;
46
+ case "png":
47
+ pipeline = pipeline.png({ quality: quality.png });
48
+ break;
49
+ case "webp":
50
+ pipeline = pipeline.webp({ quality: quality.webp });
51
+ break;
52
+ case "avif":
53
+ pipeline = pipeline.avif({ quality: quality.avif });
54
+ break;
55
+ }
56
+ const outputBuffer = await pipeline.toBuffer();
57
+ return {
58
+ buffer: outputBuffer,
59
+ format: targetFormat,
60
+ originalSize: input.length,
61
+ outputSize: outputBuffer.length
62
+ };
63
+ }
64
+
65
+ // src/optimizers/css.ts
66
+ import { transform } from "lightningcss";
67
+ var CSS_DEFAULTS = {
68
+ minify: true
69
+ };
70
+ function optimizeCss(input, options = {}) {
71
+ const minify = options.minify ?? CSS_DEFAULTS.minify;
72
+ const { code } = transform({
73
+ filename: "style.css",
74
+ code: input,
75
+ minify
76
+ });
77
+ return {
78
+ buffer: Buffer.from(code),
79
+ originalSize: input.length,
80
+ outputSize: code.length
81
+ };
82
+ }
83
+
84
+ // src/optimizers/js.ts
85
+ import { transform as transform2 } from "esbuild";
86
+ var JS_DEFAULTS = {
87
+ minify: true
88
+ };
89
+ async function optimizeJs(input, options = {}) {
90
+ const minify = options.minify ?? JS_DEFAULTS.minify;
91
+ const { code } = await transform2(input.toString("utf-8"), {
92
+ minifyWhitespace: minify,
93
+ minifyIdentifiers: minify,
94
+ minifySyntax: minify
95
+ });
96
+ const outputBuffer = Buffer.from(code, "utf-8");
97
+ return {
98
+ buffer: outputBuffer,
99
+ originalSize: input.length,
100
+ outputSize: outputBuffer.length
101
+ };
102
+ }
103
+
104
+ // src/optimizers/svg.ts
105
+ import { optimize } from "svgo";
106
+ var SVG_DEFAULTS = {
107
+ multipass: true,
108
+ minifyIds: false
109
+ };
110
+ function optimizeSvg(input, options = {}) {
111
+ const multipass = options.multipass ?? SVG_DEFAULTS.multipass;
112
+ const minifyIds = options.minifyIds ?? SVG_DEFAULTS.minifyIds;
113
+ const result = optimize(input.toString("utf-8"), {
114
+ multipass,
115
+ plugins: [
116
+ {
117
+ name: "preset-default",
118
+ params: {
119
+ overrides: minifyIds ? {} : { cleanupIds: { minify: false } }
120
+ }
121
+ }
122
+ ]
123
+ });
124
+ const outputBuffer = Buffer.from(result.data, "utf-8");
125
+ return {
126
+ buffer: outputBuffer,
127
+ originalSize: input.length,
128
+ outputSize: outputBuffer.length
129
+ };
130
+ }
131
+
132
+ // src/utils/fs.ts
133
+ import { readdir, stat, readFile, writeFile, mkdir } from "fs/promises";
134
+ import { extname, resolve, dirname } from "path";
135
+ var EXTENSION_TO_TYPE = {
136
+ ".jpg": "image",
137
+ ".jpeg": "image",
138
+ ".png": "image",
139
+ ".webp": "image",
140
+ ".avif": "image",
141
+ ".css": "css",
142
+ ".js": "js",
143
+ ".mjs": "js",
144
+ ".svg": "svg"
145
+ };
146
+ var EXTENSION_TO_IMAGE_FORMAT = {
147
+ ".jpg": "jpeg",
148
+ ".jpeg": "jpeg",
149
+ ".png": "png",
150
+ ".webp": "webp",
151
+ ".avif": "avif"
152
+ };
153
+ function getAssetType(filePath) {
154
+ return EXTENSION_TO_TYPE[extname(filePath).toLowerCase()] ?? null;
155
+ }
156
+ function getImageSourceFormat(filePath) {
157
+ return EXTENSION_TO_IMAGE_FORMAT[extname(filePath).toLowerCase()] ?? null;
158
+ }
159
+ async function scanDirectory(dirPath, recursive = true) {
160
+ const entries = await readdir(dirPath, { withFileTypes: true });
161
+ const files = [];
162
+ for (const entry of entries) {
163
+ const fullPath = resolve(dirPath, entry.name);
164
+ if (entry.isDirectory() && recursive) {
165
+ files.push(...await scanDirectory(fullPath, recursive));
166
+ } else if (entry.isFile() && getAssetType(fullPath) !== null) {
167
+ files.push(fullPath);
168
+ }
169
+ }
170
+ return files;
171
+ }
172
+ async function getFileSize(filePath) {
173
+ const stats = await stat(filePath);
174
+ return stats.size;
175
+ }
176
+ async function ensureDir(dirPath) {
177
+ await mkdir(dirPath, { recursive: true });
178
+ }
179
+ async function readBuffer(filePath) {
180
+ return readFile(filePath);
181
+ }
182
+ async function writeBuffer(filePath, data) {
183
+ await ensureDir(dirname(filePath));
184
+ await writeFile(filePath, data);
185
+ }
186
+
187
+ // src/pipeline/dispatch.ts
188
+ async function dispatch(filePath, buffer, config = {}) {
189
+ const assetType = getAssetType(filePath);
190
+ switch (assetType) {
191
+ case "image": {
192
+ const result = await optimizeImage(buffer, config.images);
193
+ return { ...result, assetType: "image" };
194
+ }
195
+ case "css": {
196
+ const result = optimizeCss(buffer, config.css);
197
+ return { ...result, assetType: "css" };
198
+ }
199
+ case "js": {
200
+ const result = await optimizeJs(buffer, config.js);
201
+ return { ...result, assetType: "js" };
202
+ }
203
+ case "svg": {
204
+ const result = optimizeSvg(buffer, config.svg);
205
+ return { ...result, assetType: "svg" };
206
+ }
207
+ default:
208
+ throw new Error(`Unsupported asset type for file: ${filePath}`);
209
+ }
210
+ }
211
+
212
+ // src/pipeline/pipeline.ts
213
+ import path from "path";
214
+ import { existsSync, readFileSync } from "fs";
215
+ import { fileURLToPath } from "url";
216
+
217
+ // src/utils/savings.ts
218
+ function computeSavedPercent(savedBytes, totalBytes) {
219
+ if (totalBytes <= 0) return 0;
220
+ return Math.round(savedBytes / totalBytes * 1e4) / 100;
221
+ }
222
+
223
+ // src/cache/manifest.ts
224
+ import { createHash } from "crypto";
225
+ import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
226
+ import { dirname as dirname2, sep } from "path";
227
+ function toPosixPath(p) {
228
+ return sep === "/" ? p : p.split(sep).join("/");
229
+ }
230
+ var CACHE_FILE = ".assetopt-cache.json";
231
+ function stableStringify(value) {
232
+ if (typeof value === "function") {
233
+ return JSON.stringify(value.toString());
234
+ }
235
+ if (value === null || typeof value !== "object") {
236
+ const serialized = JSON.stringify(value);
237
+ return serialized === void 0 ? "null" : serialized;
238
+ }
239
+ if (Array.isArray(value)) {
240
+ return "[" + value.map(stableStringify).join(",") + "]";
241
+ }
242
+ const obj = value;
243
+ const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
244
+ const parts = keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k]));
245
+ return "{" + parts.join(",") + "}";
246
+ }
247
+ function computeCacheKey(buffer, options, version) {
248
+ const hash = createHash("sha256");
249
+ hash.update(buffer);
250
+ hash.update("\0");
251
+ hash.update(stableStringify(options));
252
+ hash.update("\0");
253
+ hash.update(version);
254
+ return hash.digest("hex");
255
+ }
256
+ async function readManifest(path2) {
257
+ let content;
258
+ try {
259
+ content = await readFile2(path2, "utf-8");
260
+ } catch (err) {
261
+ if (err.code === "ENOENT") return {};
262
+ throw err;
263
+ }
264
+ return JSON.parse(content);
265
+ }
266
+ async function writeManifest(path2, manifest) {
267
+ await ensureDir(dirname2(path2));
268
+ await writeFile2(path2, JSON.stringify(manifest, null, 2));
269
+ }
270
+
271
+ // src/pipeline/pipeline.ts
272
+ function findCoreVersion() {
273
+ let dir = path.dirname(fileURLToPath(import.meta.url));
274
+ for (let i = 0; i < 10; i++) {
275
+ const candidate = path.join(dir, "package.json");
276
+ try {
277
+ const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
278
+ if (pkg.name === "@assetopt/core" && pkg.version) return pkg.version;
279
+ } catch {
280
+ }
281
+ const parent = path.dirname(dir);
282
+ if (parent === dir) break;
283
+ dir = parent;
284
+ }
285
+ return "0.0.0-unknown";
286
+ }
287
+ var CORE_VERSION = findCoreVersion();
288
+ var FORMAT_TO_EXT = {
289
+ jpeg: ".jpg",
290
+ png: ".png",
291
+ webp: ".webp",
292
+ avif: ".avif"
293
+ };
294
+ function resolveOutputPath(inputPath, inputDir, outputDir, newExt) {
295
+ const relative = path.relative(inputDir, inputPath);
296
+ const ext = path.extname(relative);
297
+ const renamed = newExt && newExt !== ext ? relative.slice(0, -ext.length) + newExt : relative;
298
+ return path.resolve(outputDir, renamed);
299
+ }
300
+ function configForHash(config) {
301
+ const { output: _output, ...rest } = config;
302
+ return rest;
303
+ }
304
+ async function runPipeline(inputDir, config = {}, options = {}) {
305
+ const { dryRun = false, useCache = true, onProgress } = options;
306
+ const outputDir = config.output?.dir ?? "./optimized";
307
+ const skip = new Set(config.images?.skip ?? []);
308
+ const manifestPath = path.resolve(outputDir, CACHE_FILE);
309
+ const hashableConfig = configForHash(config);
310
+ const manifest = useCache ? await readManifest(manifestPath) : {};
311
+ const allFiles = await scanDirectory(inputDir);
312
+ const files = allFiles.filter((filePath) => {
313
+ const sourceFormat = getImageSourceFormat(filePath);
314
+ return sourceFormat === null || !skip.has(sourceFormat);
315
+ });
316
+ const results = [];
317
+ for (let i = 0; i < files.length; i++) {
318
+ const filePath = files[i];
319
+ onProgress?.(i + 1, files.length, filePath);
320
+ const start = Date.now();
321
+ const buffer = await readBuffer(filePath);
322
+ const cacheKey = useCache ? computeCacheKey(buffer, hashableConfig, CORE_VERSION) : null;
323
+ if (cacheKey !== null) {
324
+ const entry = manifest[cacheKey];
325
+ const assetType = getAssetType(filePath);
326
+ if (entry && assetType !== null) {
327
+ const absoluteOutputPath = path.resolve(outputDir, entry.outputPath);
328
+ if (existsSync(absoluteOutputPath)) {
329
+ const savedBytes2 = entry.inputSize - entry.outputSize;
330
+ results.push({
331
+ inputPath: filePath,
332
+ outputPath: absoluteOutputPath,
333
+ inputSize: entry.inputSize,
334
+ outputSize: entry.outputSize,
335
+ savedBytes: savedBytes2,
336
+ savedPercent: computeSavedPercent(savedBytes2, entry.inputSize),
337
+ assetType,
338
+ durationMs: Date.now() - start,
339
+ cached: true
340
+ });
341
+ continue;
342
+ }
343
+ }
344
+ }
345
+ const dispatched = await dispatch(filePath, buffer, config);
346
+ const newExt = dispatched.assetType === "image" ? FORMAT_TO_EXT[dispatched.format] : void 0;
347
+ const outputPath = resolveOutputPath(filePath, inputDir, outputDir, newExt);
348
+ if (!dryRun) {
349
+ await writeBuffer(outputPath, dispatched.buffer);
350
+ }
351
+ if (cacheKey !== null && !dryRun) {
352
+ manifest[cacheKey] = {
353
+ inputSize: dispatched.originalSize,
354
+ outputSize: dispatched.outputSize,
355
+ outputPath: toPosixPath(path.relative(outputDir, outputPath)),
356
+ outputFormat: dispatched.assetType === "image" ? dispatched.format : void 0,
357
+ timestamp: Date.now()
358
+ };
359
+ }
360
+ const savedBytes = dispatched.originalSize - dispatched.outputSize;
361
+ results.push({
362
+ inputPath: filePath,
363
+ outputPath,
364
+ inputSize: dispatched.originalSize,
365
+ outputSize: dispatched.outputSize,
366
+ savedBytes,
367
+ savedPercent: computeSavedPercent(savedBytes, dispatched.originalSize),
368
+ assetType: dispatched.assetType,
369
+ durationMs: Date.now() - start,
370
+ cached: false
371
+ });
372
+ }
373
+ if (useCache && !dryRun) {
374
+ await writeManifest(manifestPath, manifest);
375
+ }
376
+ return results;
377
+ }
378
+
379
+ // src/analyzers/report.ts
380
+ function buildReport(assets, durationMs) {
381
+ const totalInputSize = assets.reduce((sum, a) => sum + a.inputSize, 0);
382
+ const totalOutputSize = assets.reduce((sum, a) => sum + a.outputSize, 0);
383
+ const totalSavedBytes = totalInputSize - totalOutputSize;
384
+ const cachedCount = assets.reduce((n, a) => n + (a.cached ? 1 : 0), 0);
385
+ return {
386
+ assets,
387
+ totalInputSize,
388
+ totalOutputSize,
389
+ totalSavedBytes,
390
+ totalSavedPercent: computeSavedPercent(totalSavedBytes, totalInputSize),
391
+ durationMs,
392
+ cachedCount
393
+ };
394
+ }
395
+
396
+ // src/config/loader.ts
397
+ import { readFile as readFile3 } from "fs/promises";
398
+ import { dirname as dirname3, resolve as resolve2 } from "path";
399
+
400
+ // src/config/schema.ts
401
+ import { z } from "zod";
402
+ var imageQualitySchema = z.object({
403
+ jpeg: z.number().min(1).max(100).optional(),
404
+ png: z.number().min(1).max(100).optional(),
405
+ webp: z.number().min(1).max(100).optional(),
406
+ avif: z.number().min(1).max(100).optional()
407
+ });
408
+ var matrixTargetSchema = z.enum(IMAGE_MATRIX_TARGETS);
409
+ var formatMatrixSchema = z.object({
410
+ jpeg: matrixTargetSchema.optional(),
411
+ png: matrixTargetSchema.optional(),
412
+ webp: matrixTargetSchema.optional(),
413
+ avif: matrixTargetSchema.optional()
414
+ }).strict();
415
+ var imageOptionsSchema = z.object({
416
+ outputFormat: z.enum(IMAGE_OUTPUT_FORMATS).optional(),
417
+ formatMatrix: formatMatrixSchema.optional(),
418
+ quality: imageQualitySchema.optional(),
419
+ stripMetadata: z.boolean().optional(),
420
+ skip: z.array(z.enum(IMAGE_SOURCE_FORMATS)).optional()
421
+ });
422
+ var cssOptionsSchema = z.object({
423
+ minify: z.boolean().optional()
424
+ });
425
+ var jsOptionsSchema = z.object({
426
+ minify: z.boolean().optional()
427
+ });
428
+ var svgOptionsSchema = z.object({
429
+ multipass: z.boolean().optional(),
430
+ minifyIds: z.boolean().optional()
431
+ });
432
+ var configSchema = z.object({
433
+ preset: z.enum(PRESET_NAMES).optional(),
434
+ images: imageOptionsSchema.optional(),
435
+ css: cssOptionsSchema.optional(),
436
+ js: jsOptionsSchema.optional(),
437
+ svg: svgOptionsSchema.optional(),
438
+ output: z.object({
439
+ dir: z.string().optional()
440
+ }).optional()
441
+ });
442
+
443
+ // src/config/presets.ts
444
+ var PRESETS = {
445
+ // PNG smart routing: AVIF preserves transparency at smaller size than PNG,
446
+ // but for opaque images WebP is lighter than AVIF. So we route based on
447
+ // the actual presence of an alpha channel rather than the PNG container.
448
+ "web-perf": {
449
+ images: {
450
+ formatMatrix: {
451
+ jpeg: "webp",
452
+ png: (ctx) => ctx.hasAlpha ? "avif" : "webp",
453
+ webp: "keep",
454
+ avif: "keep"
455
+ }
456
+ }
457
+ },
458
+ // Smallest possible output: everything converges to AVIF (the densest format
459
+ // we support) at aggressive quality, and SVGs get the full svgo treatment.
460
+ // Quality is keyed by the OUTPUT format, so only `avif` matters here.
461
+ "max-compression": {
462
+ images: {
463
+ formatMatrix: {
464
+ jpeg: "avif",
465
+ png: "avif",
466
+ webp: "avif",
467
+ avif: "keep"
468
+ },
469
+ quality: { avif: 50 },
470
+ stripMetadata: true
471
+ },
472
+ svg: { multipass: true, minifyIds: true }
473
+ },
474
+ // Fidelity-first: keep every source format (no lossy format switch) and
475
+ // re-encode at high quality. Metadata is preserved (e.g. color profiles,
476
+ // EXIF) — useful for photography or print workflows. Not bit-for-bit
477
+ // lossless: JPEG/WebP/AVIF stay lossy codecs, just at a high quality floor.
478
+ quality: {
479
+ images: {
480
+ formatMatrix: {
481
+ jpeg: "keep",
482
+ png: "keep",
483
+ webp: "keep",
484
+ avif: "keep"
485
+ },
486
+ quality: { jpeg: 95, png: 95, webp: 95, avif: 90 },
487
+ stripMetadata: false
488
+ }
489
+ },
490
+ // Maximum target compatibility: never produce a format more modern than the
491
+ // source. Every format is kept as-is (only re-compressed at defaults), so the
492
+ // output is guaranteed to be as widely supported as the input.
493
+ compatibility: {
494
+ images: {
495
+ formatMatrix: {
496
+ jpeg: "keep",
497
+ png: "keep",
498
+ webp: "keep",
499
+ avif: "keep"
500
+ }
501
+ }
502
+ }
503
+ };
504
+
505
+ // src/config/loader.ts
506
+ var CONFIG_FILENAMES = [".assetoptrc", ".assetoptrc.json"];
507
+ var DEFAULTS = {
508
+ images: IMAGE_DEFAULTS,
509
+ css: CSS_DEFAULTS,
510
+ js: JS_DEFAULTS,
511
+ svg: SVG_DEFAULTS,
512
+ output: { dir: "./optimized" }
513
+ };
514
+ async function findConfigFile(startDir) {
515
+ let dir = startDir;
516
+ while (true) {
517
+ for (const filename of CONFIG_FILENAMES) {
518
+ const candidate = resolve2(dir, filename);
519
+ try {
520
+ await readFile3(candidate, "utf-8");
521
+ return candidate;
522
+ } catch (err) {
523
+ if (err.code !== "ENOENT") throw err;
524
+ }
525
+ }
526
+ const parent = dirname3(dir);
527
+ if (parent === dir) return null;
528
+ dir = parent;
529
+ }
530
+ }
531
+ async function loadConfig(cwd = process.cwd()) {
532
+ const filePath = await findConfigFile(cwd);
533
+ if (filePath === null) return { config: DEFAULTS, source: null };
534
+ const raw = await readFile3(filePath, "utf-8");
535
+ try {
536
+ const parsed = configSchema.parse(JSON.parse(raw));
537
+ return { config: resolveConfig(parsed), source: filePath };
538
+ } catch (err) {
539
+ throw new Error(`Invalid ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
540
+ }
541
+ }
542
+ function resolveConfig(userConfig) {
543
+ const presetConfig = userConfig.preset ? PRESETS[userConfig.preset] : {};
544
+ return mergeConfig(mergeConfig(DEFAULTS, presetConfig), userConfig);
545
+ }
546
+ function mergeConfig(base, override) {
547
+ const baseMatrix = base.images?.formatMatrix;
548
+ const overrideMatrix = override.images?.formatMatrix;
549
+ const mergedMatrix = baseMatrix || overrideMatrix ? { ...baseMatrix, ...overrideMatrix } : void 0;
550
+ return {
551
+ ...base,
552
+ ...override,
553
+ images: {
554
+ ...base.images,
555
+ ...override.images,
556
+ quality: { ...base.images?.quality, ...override.images?.quality },
557
+ ...mergedMatrix && { formatMatrix: mergedMatrix }
558
+ },
559
+ css: { ...base.css, ...override.css },
560
+ js: { ...base.js, ...override.js },
561
+ svg: { ...base.svg, ...override.svg },
562
+ output: { ...base.output, ...override.output }
563
+ };
564
+ }
565
+ export {
566
+ CACHE_FILE,
567
+ DEFAULTS,
568
+ PRESETS,
569
+ buildReport,
570
+ dispatch,
571
+ getAssetType,
572
+ getFileSize,
573
+ loadConfig,
574
+ mergeConfig,
575
+ optimizeCss,
576
+ optimizeImage,
577
+ optimizeJs,
578
+ optimizeSvg,
579
+ resolveConfig,
580
+ runPipeline,
581
+ scanDirectory
582
+ };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@assetopt/core",
3
+ "version": "1.0.0",
4
+ "description": "Pure asset optimization functions for images (sharp), CSS (lightningcss), JS (esbuild), and SVG (svgo). Core engine behind the assetopt CLI.",
5
+ "license": "MIT",
6
+ "author": "Nath",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/index.js",
13
+ "types": "./dist/index.d.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20.0.0"
22
+ },
23
+ "keywords": [
24
+ "asset-optimization",
25
+ "image-optimization",
26
+ "css-minification",
27
+ "js-minification",
28
+ "svg-optimization",
29
+ "sharp",
30
+ "lightningcss",
31
+ "esbuild",
32
+ "svgo",
33
+ "web-performance"
34
+ ],
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/Nathmaxx/assetopt.git",
38
+ "directory": "packages/core"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/Nathmaxx/assetopt/issues"
42
+ },
43
+ "homepage": "https://github.com/Nathmaxx/assetopt#readme",
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "build": "tsup src/index.ts --format esm --dts --clean",
49
+ "dev": "tsup src/index.ts --format esm --dts --watch",
50
+ "prepack": "npm run build"
51
+ },
52
+ "dependencies": {
53
+ "esbuild": "^0.28.1",
54
+ "lightningcss": "^1.28.0",
55
+ "sharp": "^0.33.0",
56
+ "svgo": "^3.3.0",
57
+ "zod": "^3.24.0"
58
+ },
59
+ "devDependencies": {
60
+ "tsup": "^8.0.0"
61
+ }
62
+ }