viewfit 0.0.1 → 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 +21 -0
- package/README.md +141 -3
- package/dist/cli.js +1451 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +1333 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -6
- package/index.js +0 -1
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1451 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import chalk3 from "chalk";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
|
|
8
|
+
// src/config/devices.ts
|
|
9
|
+
var DEVICE_PRESETS = {
|
|
10
|
+
desktop: {
|
|
11
|
+
name: "Desktop",
|
|
12
|
+
viewport: { width: 1440, height: 900 },
|
|
13
|
+
deviceScaleFactor: 1,
|
|
14
|
+
isMobile: false
|
|
15
|
+
},
|
|
16
|
+
mobile: {
|
|
17
|
+
name: "Mobile",
|
|
18
|
+
viewport: { width: 390, height: 844 },
|
|
19
|
+
deviceScaleFactor: 3,
|
|
20
|
+
isMobile: true
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
function isDeviceMode(value) {
|
|
24
|
+
return value === "desktop" || value === "mobile";
|
|
25
|
+
}
|
|
26
|
+
function parseViewport(value) {
|
|
27
|
+
const match = /^(\d+)x(\d+)$/i.exec(value.trim());
|
|
28
|
+
if (!match) {
|
|
29
|
+
throw new Error(`Invalid viewport "${value}". Expected a value like 1440x900.`);
|
|
30
|
+
}
|
|
31
|
+
const width = Number(match[1]);
|
|
32
|
+
const height = Number(match[2]);
|
|
33
|
+
if (width <= 0 || height <= 0) {
|
|
34
|
+
throw new Error("Viewport dimensions must be positive.");
|
|
35
|
+
}
|
|
36
|
+
return { width, height };
|
|
37
|
+
}
|
|
38
|
+
function getDevicePreset(mode, dpr, viewport) {
|
|
39
|
+
const base = DEVICE_PRESETS[mode];
|
|
40
|
+
return {
|
|
41
|
+
...base,
|
|
42
|
+
viewport: viewport ?? base.viewport,
|
|
43
|
+
deviceScaleFactor: dpr ?? base.deviceScaleFactor
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/config/presets.ts
|
|
48
|
+
var PRESETS = {
|
|
49
|
+
web: {
|
|
50
|
+
description: "Mobile \xB7 WebP \xB7 90% tier \xB7 quality 80",
|
|
51
|
+
device: "mobile",
|
|
52
|
+
tier: "safe",
|
|
53
|
+
format: "webp",
|
|
54
|
+
quality: 80
|
|
55
|
+
},
|
|
56
|
+
hq: {
|
|
57
|
+
description: "Desktop \xB7 AVIF \xB7 exact tier \xB7 quality 85",
|
|
58
|
+
device: "desktop",
|
|
59
|
+
tier: "exact",
|
|
60
|
+
format: "avif",
|
|
61
|
+
quality: 85
|
|
62
|
+
},
|
|
63
|
+
fast: {
|
|
64
|
+
description: "Mobile \xB7 WebP \xB7 75% tier \xB7 quality 70",
|
|
65
|
+
device: "mobile",
|
|
66
|
+
tier: "aggressive",
|
|
67
|
+
format: "webp",
|
|
68
|
+
quality: 70
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
function getPreset(name) {
|
|
72
|
+
const preset = PRESETS[name.toLowerCase()];
|
|
73
|
+
if (!preset) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Unknown preset "${name}". Use ${Object.keys(PRESETS).join(", ")}.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return preset;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/core/tiers.ts
|
|
82
|
+
var TIER_PRESETS = {
|
|
83
|
+
exact: 1,
|
|
84
|
+
safe: 0.9,
|
|
85
|
+
aggressive: 0.75,
|
|
86
|
+
extreme: 0.5
|
|
87
|
+
};
|
|
88
|
+
var TIER_DESCRIPTIONS = {
|
|
89
|
+
exact: "Exact rendered size (100%)",
|
|
90
|
+
safe: "Slightly below rendered size (90%)",
|
|
91
|
+
aggressive: "Clearly below rendered size (75%)",
|
|
92
|
+
extreme: "Half of the rendered size (50%)"
|
|
93
|
+
};
|
|
94
|
+
function isTierName(value) {
|
|
95
|
+
return Object.prototype.hasOwnProperty.call(TIER_PRESETS, value);
|
|
96
|
+
}
|
|
97
|
+
function resolveTierScale(tier) {
|
|
98
|
+
if (typeof tier === "number") {
|
|
99
|
+
const scale = tier > 1 ? tier / 100 : tier;
|
|
100
|
+
if (!Number.isFinite(scale) || scale <= 0) {
|
|
101
|
+
throw new Error("Tier number must be a positive percentage or ratio.");
|
|
102
|
+
}
|
|
103
|
+
return Math.min(scale, 1);
|
|
104
|
+
}
|
|
105
|
+
const preset = TIER_PRESETS[tier];
|
|
106
|
+
if (preset === void 0) {
|
|
107
|
+
throw new Error(`Unknown tier "${tier}".`);
|
|
108
|
+
}
|
|
109
|
+
return preset;
|
|
110
|
+
}
|
|
111
|
+
function parseTier(value) {
|
|
112
|
+
const normalized = value.trim().toLowerCase();
|
|
113
|
+
if (isTierName(normalized)) {
|
|
114
|
+
return normalized;
|
|
115
|
+
}
|
|
116
|
+
const numeric = Number(normalized);
|
|
117
|
+
if (Number.isFinite(numeric) && numeric > 0) {
|
|
118
|
+
return numeric > 1 ? numeric / 100 : numeric;
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Invalid tier "${value}". Use exact, safe, aggressive, extreme or a number between 1 and 100.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/index.ts
|
|
126
|
+
import { existsSync as existsSync3, statSync } from "fs";
|
|
127
|
+
import { dirname as dirname3, relative as relative3, resolve as resolve4, sep as sep4 } from "path";
|
|
128
|
+
import chalk2 from "chalk";
|
|
129
|
+
import ora from "ora";
|
|
130
|
+
|
|
131
|
+
// src/browser/inspector.ts
|
|
132
|
+
import { chromium } from "playwright";
|
|
133
|
+
|
|
134
|
+
// src/browser/collector.ts
|
|
135
|
+
function collectImagesInPage() {
|
|
136
|
+
const results = [];
|
|
137
|
+
const absolute = (value) => {
|
|
138
|
+
try {
|
|
139
|
+
return new URL(value, document.baseURI).href;
|
|
140
|
+
} catch {
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const selectorFor = (element) => {
|
|
145
|
+
const tag = element.tagName.toLowerCase();
|
|
146
|
+
if (element.id) {
|
|
147
|
+
return `#${CSS.escape(element.id)}`;
|
|
148
|
+
}
|
|
149
|
+
const className = element.getAttribute("class");
|
|
150
|
+
if (className) {
|
|
151
|
+
const classes = className.trim().split(/\s+/).filter(Boolean).map((name) => `.${CSS.escape(name)}`).join("");
|
|
152
|
+
if (classes) {
|
|
153
|
+
return `${tag}${classes}`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return tag;
|
|
157
|
+
};
|
|
158
|
+
const normalizeFit = (value) => {
|
|
159
|
+
switch (value) {
|
|
160
|
+
case "cover":
|
|
161
|
+
case "contain":
|
|
162
|
+
case "fill":
|
|
163
|
+
case "none":
|
|
164
|
+
case "scale-down":
|
|
165
|
+
return value;
|
|
166
|
+
default:
|
|
167
|
+
return "fill";
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const normalizeBackgroundFit = (value) => {
|
|
171
|
+
const normalized = value.trim().toLowerCase();
|
|
172
|
+
if (normalized === "cover" || normalized === "contain") {
|
|
173
|
+
return normalized;
|
|
174
|
+
}
|
|
175
|
+
if (normalized === "auto" || normalized === "") {
|
|
176
|
+
return "auto";
|
|
177
|
+
}
|
|
178
|
+
return "fill";
|
|
179
|
+
};
|
|
180
|
+
const backgroundUrls = (value) => {
|
|
181
|
+
const urls = [];
|
|
182
|
+
const pattern = /url\((['"]?)(.*?)\1\)/g;
|
|
183
|
+
let match;
|
|
184
|
+
while ((match = pattern.exec(value)) !== null) {
|
|
185
|
+
const url = match[2];
|
|
186
|
+
if (url && !url.startsWith("data:")) {
|
|
187
|
+
urls.push(url);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return urls;
|
|
191
|
+
};
|
|
192
|
+
for (const image of Array.from(document.images)) {
|
|
193
|
+
const rect = image.getBoundingClientRect();
|
|
194
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const src = image.currentSrc || image.src;
|
|
198
|
+
if (!src || src.startsWith("data:")) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
results.push({
|
|
202
|
+
src: absolute(src),
|
|
203
|
+
kind: "img",
|
|
204
|
+
cssWidth: rect.width,
|
|
205
|
+
cssHeight: rect.height,
|
|
206
|
+
fit: normalizeFit(getComputedStyle(image).objectFit),
|
|
207
|
+
selector: selectorFor(image)
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const elements = Array.from(document.querySelectorAll("*"));
|
|
211
|
+
const addBackground = (element, pseudo, style) => {
|
|
212
|
+
if (!style.backgroundImage || style.backgroundImage === "none") {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const rect = element.getBoundingClientRect();
|
|
216
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const fit = normalizeBackgroundFit(style.backgroundSize);
|
|
220
|
+
const selector = `${selectorFor(element)}${pseudo}`;
|
|
221
|
+
for (const url of backgroundUrls(style.backgroundImage)) {
|
|
222
|
+
results.push({
|
|
223
|
+
src: absolute(url),
|
|
224
|
+
kind: "background",
|
|
225
|
+
cssWidth: rect.width,
|
|
226
|
+
cssHeight: rect.height,
|
|
227
|
+
fit,
|
|
228
|
+
selector
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
for (const element of elements) {
|
|
233
|
+
addBackground(element, "", getComputedStyle(element));
|
|
234
|
+
addBackground(element, "::before", getComputedStyle(element, "::before"));
|
|
235
|
+
addBackground(element, "::after", getComputedStyle(element, "::after"));
|
|
236
|
+
}
|
|
237
|
+
return results;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/browser/inspector.ts
|
|
241
|
+
async function launchBrowser(channel) {
|
|
242
|
+
const requested = channel === "chromium" ? void 0 : channel;
|
|
243
|
+
try {
|
|
244
|
+
return await chromium.launch({ channel: requested, headless: true });
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (requested) {
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return await chromium.launch({ channel: "chrome", headless: true });
|
|
251
|
+
} catch {
|
|
252
|
+
throw new Error(
|
|
253
|
+
"Unable to launch Chromium. Install it with `npx playwright install chromium` or use `--browser chrome` if Google Chrome is installed."
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function autoScroll(page) {
|
|
259
|
+
await page.evaluate(async () => {
|
|
260
|
+
const delay = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
261
|
+
const distance = Math.max(window.innerHeight, 400);
|
|
262
|
+
const height = document.body?.scrollHeight ?? document.documentElement.scrollHeight;
|
|
263
|
+
for (let y = 0; y < height; y += distance) {
|
|
264
|
+
window.scrollTo(0, y);
|
|
265
|
+
await delay(60);
|
|
266
|
+
}
|
|
267
|
+
window.scrollTo(0, 0);
|
|
268
|
+
await delay(100);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
async function decodeImages(page) {
|
|
272
|
+
await page.evaluate(async () => {
|
|
273
|
+
await Promise.all(
|
|
274
|
+
Array.from(document.images).map(
|
|
275
|
+
(image) => image.decode().catch(() => void 0)
|
|
276
|
+
)
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async function inspectPageInContext(page, pageUrl, timeout) {
|
|
281
|
+
await page.goto(pageUrl, {
|
|
282
|
+
waitUntil: "load",
|
|
283
|
+
timeout
|
|
284
|
+
});
|
|
285
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => void 0);
|
|
286
|
+
await autoScroll(page);
|
|
287
|
+
await decodeImages(page);
|
|
288
|
+
return await page.evaluate(collectImagesInPage);
|
|
289
|
+
}
|
|
290
|
+
async function inspectPages(pageUrls, options) {
|
|
291
|
+
const preset = getDevicePreset(options.device, options.dpr, options.viewport);
|
|
292
|
+
const browser = await launchBrowser(options.browser);
|
|
293
|
+
try {
|
|
294
|
+
const context = await browser.newContext({
|
|
295
|
+
viewport: preset.viewport,
|
|
296
|
+
deviceScaleFactor: preset.deviceScaleFactor,
|
|
297
|
+
isMobile: preset.isMobile,
|
|
298
|
+
hasTouch: preset.isMobile
|
|
299
|
+
});
|
|
300
|
+
const images = [];
|
|
301
|
+
const failures = [];
|
|
302
|
+
try {
|
|
303
|
+
for (const pageUrl of pageUrls) {
|
|
304
|
+
const page = await context.newPage();
|
|
305
|
+
try {
|
|
306
|
+
images.push(...await inspectPageInContext(page, pageUrl, options.timeout));
|
|
307
|
+
} catch (error) {
|
|
308
|
+
failures.push({
|
|
309
|
+
pageUrl,
|
|
310
|
+
message: error instanceof Error ? error.message : String(error)
|
|
311
|
+
});
|
|
312
|
+
} finally {
|
|
313
|
+
await page.close();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
await context.close();
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
images,
|
|
321
|
+
pageUrl: pageUrls[0] ?? "",
|
|
322
|
+
pages: pageUrls,
|
|
323
|
+
failures,
|
|
324
|
+
deviceName: preset.name,
|
|
325
|
+
viewport: preset.viewport,
|
|
326
|
+
dpr: preset.deviceScaleFactor
|
|
327
|
+
};
|
|
328
|
+
} finally {
|
|
329
|
+
await browser.close();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/core/compress.ts
|
|
334
|
+
import { mkdir, rename, writeFile } from "fs/promises";
|
|
335
|
+
import { basename, dirname, join, relative } from "path";
|
|
336
|
+
|
|
337
|
+
// src/core/encode.ts
|
|
338
|
+
import sharp from "sharp";
|
|
339
|
+
function extensionFor(format, originalFormat) {
|
|
340
|
+
const target = format === "original" ? originalFormat ?? "jpg" : format;
|
|
341
|
+
return target === "jpeg" ? "jpg" : target;
|
|
342
|
+
}
|
|
343
|
+
function applyFormat(pipeline, options) {
|
|
344
|
+
const target = options.format === "original" ? options.originalFormat : options.format;
|
|
345
|
+
const quality = options.quality;
|
|
346
|
+
switch (target) {
|
|
347
|
+
case "jpeg":
|
|
348
|
+
case "jpg":
|
|
349
|
+
return pipeline.jpeg({ quality, mozjpeg: true });
|
|
350
|
+
case "png":
|
|
351
|
+
return pipeline.png({ compressionLevel: 9 });
|
|
352
|
+
case "webp":
|
|
353
|
+
return pipeline.webp({ quality });
|
|
354
|
+
case "avif":
|
|
355
|
+
return pipeline.avif({ quality });
|
|
356
|
+
case "gif":
|
|
357
|
+
return pipeline.gif();
|
|
358
|
+
default:
|
|
359
|
+
return pipeline;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function encodeImage(input, width, height, options) {
|
|
363
|
+
const pipeline = sharp(input).rotate().resize({
|
|
364
|
+
width,
|
|
365
|
+
height,
|
|
366
|
+
fit: "inside",
|
|
367
|
+
withoutEnlargement: true
|
|
368
|
+
});
|
|
369
|
+
return applyFormat(pipeline, options).toBuffer();
|
|
370
|
+
}
|
|
371
|
+
async function estimateEncodedSize(input, width, height, options) {
|
|
372
|
+
try {
|
|
373
|
+
const buffer = await encodeImage(input, width, height, options);
|
|
374
|
+
return buffer.length;
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// src/core/compress.ts
|
|
381
|
+
function outputPathFor(analysis, options) {
|
|
382
|
+
const source = analysis.filePath;
|
|
383
|
+
const relativePath = options.rootDir ? relative(options.rootDir, source) : basename(source);
|
|
384
|
+
const extension = extensionFor(options.format, analysis.originalFormat);
|
|
385
|
+
const withoutExtension = relativePath.replace(/\.[^./\\]+$/, "");
|
|
386
|
+
return join(options.outputDir, `${withoutExtension}.${extension}`);
|
|
387
|
+
}
|
|
388
|
+
async function compressImage(analysis, options) {
|
|
389
|
+
if (!analysis.filePath || !analysis.recommended) {
|
|
390
|
+
return {
|
|
391
|
+
input: analysis.filePath ?? analysis.url,
|
|
392
|
+
output: "",
|
|
393
|
+
bytes: 0,
|
|
394
|
+
mode: "skipped"
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const buffer = await encodeImage(
|
|
398
|
+
analysis.filePath,
|
|
399
|
+
analysis.targetWidth,
|
|
400
|
+
analysis.targetHeight,
|
|
401
|
+
{
|
|
402
|
+
format: options.format,
|
|
403
|
+
quality: options.quality,
|
|
404
|
+
originalFormat: analysis.originalFormat
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
if (options.write) {
|
|
408
|
+
const temporaryPath = `${analysis.filePath}.viewfit-${process.pid}.tmp`;
|
|
409
|
+
await writeFile(temporaryPath, buffer);
|
|
410
|
+
await rename(temporaryPath, analysis.filePath);
|
|
411
|
+
return {
|
|
412
|
+
input: analysis.filePath,
|
|
413
|
+
output: analysis.filePath,
|
|
414
|
+
bytes: buffer.length,
|
|
415
|
+
mode: "overwrite"
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
const outputPath = outputPathFor(analysis, options);
|
|
419
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
420
|
+
await writeFile(outputPath, buffer);
|
|
421
|
+
return {
|
|
422
|
+
input: analysis.filePath,
|
|
423
|
+
output: outputPath,
|
|
424
|
+
bytes: buffer.length,
|
|
425
|
+
mode: "output"
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/core/pages.ts
|
|
430
|
+
import { readdir } from "fs/promises";
|
|
431
|
+
import { extname, join as join2, resolve, sep } from "path";
|
|
432
|
+
var HTML_EXTENSIONS = /* @__PURE__ */ new Set([".html", ".htm"]);
|
|
433
|
+
var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
434
|
+
async function findHtmlFiles(rootDir, options = {}) {
|
|
435
|
+
const root = resolve(rootDir);
|
|
436
|
+
const excluded = (options.exclude ?? []).map((path) => resolve(path)).filter((path) => path !== root);
|
|
437
|
+
const results = [];
|
|
438
|
+
async function walk2(directory) {
|
|
439
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
440
|
+
for (const entry of entries) {
|
|
441
|
+
const fullPath = join2(directory, entry.name);
|
|
442
|
+
if (entry.isSymbolicLink()) {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (entry.isDirectory()) {
|
|
446
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const resolved = resolve(fullPath);
|
|
450
|
+
if (excluded.some(
|
|
451
|
+
(path) => resolved === path || resolved.startsWith(path + sep)
|
|
452
|
+
)) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
await walk2(fullPath);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (entry.isFile() && HTML_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
459
|
+
results.push(fullPath);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
await walk2(root);
|
|
464
|
+
return results.sort();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/utils/url.ts
|
|
468
|
+
import { existsSync } from "fs";
|
|
469
|
+
import { resolve as resolve2, sep as sep2 } from "path";
|
|
470
|
+
function urlToFilePath(url, rootDir, pageUrl) {
|
|
471
|
+
let parsed;
|
|
472
|
+
let base;
|
|
473
|
+
try {
|
|
474
|
+
parsed = new URL(url);
|
|
475
|
+
base = new URL(pageUrl);
|
|
476
|
+
} catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
if (parsed.origin !== base.origin) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
const root = resolve2(rootDir);
|
|
483
|
+
const relativePath = decodeURIComponent(parsed.pathname).replace(/^\/+/, "");
|
|
484
|
+
if (relativePath === "" || relativePath.includes("\0")) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
const filePath = resolve2(root, relativePath);
|
|
488
|
+
if (filePath !== root && !filePath.startsWith(root + sep2)) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
return existsSync(filePath) ? filePath : null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/core/metadata.ts
|
|
495
|
+
import { stat } from "fs/promises";
|
|
496
|
+
import sharp2 from "sharp";
|
|
497
|
+
async function readImageMetadata(filePath) {
|
|
498
|
+
const [metadata, fileInfo] = await Promise.all([
|
|
499
|
+
sharp2(filePath).metadata(),
|
|
500
|
+
stat(filePath)
|
|
501
|
+
]);
|
|
502
|
+
if (!metadata.width || !metadata.height) {
|
|
503
|
+
throw new Error("Unable to read image dimensions.");
|
|
504
|
+
}
|
|
505
|
+
const swapped = typeof metadata.orientation === "number" && metadata.orientation >= 5;
|
|
506
|
+
return {
|
|
507
|
+
width: swapped ? metadata.height : metadata.width,
|
|
508
|
+
height: swapped ? metadata.width : metadata.height,
|
|
509
|
+
format: metadata.format ?? null,
|
|
510
|
+
bytes: fileInfo.size
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// src/core/recommend.ts
|
|
515
|
+
function requiredScale(fit, cssWidth, cssHeight, naturalWidth, naturalHeight, dpr) {
|
|
516
|
+
const widthRatio = cssWidth * dpr / naturalWidth;
|
|
517
|
+
const heightRatio = cssHeight * dpr / naturalHeight;
|
|
518
|
+
switch (fit) {
|
|
519
|
+
case "contain":
|
|
520
|
+
case "scale-down":
|
|
521
|
+
return Math.min(widthRatio, heightRatio, 1);
|
|
522
|
+
case "none":
|
|
523
|
+
case "auto":
|
|
524
|
+
return 1;
|
|
525
|
+
case "cover":
|
|
526
|
+
case "fill":
|
|
527
|
+
default:
|
|
528
|
+
return Math.min(Math.max(widthRatio, heightRatio), 1);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
function groupByUrl(images) {
|
|
532
|
+
const groups = /* @__PURE__ */ new Map();
|
|
533
|
+
for (const image of images) {
|
|
534
|
+
const contexts = groups.get(image.src) ?? [];
|
|
535
|
+
contexts.push({
|
|
536
|
+
kind: image.kind,
|
|
537
|
+
cssWidth: image.cssWidth,
|
|
538
|
+
cssHeight: image.cssHeight,
|
|
539
|
+
fit: image.fit,
|
|
540
|
+
selector: image.selector
|
|
541
|
+
});
|
|
542
|
+
groups.set(image.src, contexts);
|
|
543
|
+
}
|
|
544
|
+
return groups;
|
|
545
|
+
}
|
|
546
|
+
function emptyAnalysis(url, contexts, options, reason) {
|
|
547
|
+
const renderedWidth = Math.max(...contexts.map((context) => context.cssWidth));
|
|
548
|
+
const renderedHeight = Math.max(
|
|
549
|
+
...contexts.map((context) => context.cssHeight)
|
|
550
|
+
);
|
|
551
|
+
return {
|
|
552
|
+
url,
|
|
553
|
+
filePath: null,
|
|
554
|
+
originalWidth: 0,
|
|
555
|
+
originalHeight: 0,
|
|
556
|
+
originalBytes: 0,
|
|
557
|
+
originalFormat: null,
|
|
558
|
+
renderedWidth,
|
|
559
|
+
renderedHeight,
|
|
560
|
+
contexts,
|
|
561
|
+
targetWidth: Math.max(
|
|
562
|
+
1,
|
|
563
|
+
Math.round(renderedWidth * options.dpr * options.tierScale)
|
|
564
|
+
),
|
|
565
|
+
targetHeight: Math.max(
|
|
566
|
+
1,
|
|
567
|
+
Math.round(renderedHeight * options.dpr * options.tierScale)
|
|
568
|
+
),
|
|
569
|
+
estimatedBytes: null,
|
|
570
|
+
savingsBytes: null,
|
|
571
|
+
savingsPercent: null,
|
|
572
|
+
transformable: false,
|
|
573
|
+
recommended: false,
|
|
574
|
+
reason
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
async function analyzeImages(images, options) {
|
|
578
|
+
const groups = groupByUrl(images);
|
|
579
|
+
const analyses = [];
|
|
580
|
+
for (const [url, contexts] of groups) {
|
|
581
|
+
const filePath = options.rootDir ? urlToFilePath(url, options.rootDir, options.pageUrl) : null;
|
|
582
|
+
if (!filePath) {
|
|
583
|
+
analyses.push(
|
|
584
|
+
emptyAnalysis(
|
|
585
|
+
url,
|
|
586
|
+
contexts,
|
|
587
|
+
options,
|
|
588
|
+
options.rootDir ? "File not found under the project root (remote or generated asset)." : "No local root provided; reporting only."
|
|
589
|
+
)
|
|
590
|
+
);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
let metadata;
|
|
594
|
+
try {
|
|
595
|
+
metadata = await readImageMetadata(filePath);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
const message = error instanceof Error ? error.message : "Unable to read image.";
|
|
598
|
+
analyses.push(emptyAnalysis(url, contexts, options, message));
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const scale = contexts.reduce(
|
|
602
|
+
(current, context) => Math.max(
|
|
603
|
+
current,
|
|
604
|
+
requiredScale(
|
|
605
|
+
context.fit,
|
|
606
|
+
context.cssWidth,
|
|
607
|
+
context.cssHeight,
|
|
608
|
+
metadata.width,
|
|
609
|
+
metadata.height,
|
|
610
|
+
options.dpr
|
|
611
|
+
)
|
|
612
|
+
),
|
|
613
|
+
0
|
|
614
|
+
);
|
|
615
|
+
const targetWidth = Math.max(
|
|
616
|
+
1,
|
|
617
|
+
Math.min(
|
|
618
|
+
metadata.width,
|
|
619
|
+
Math.round(metadata.width * scale * options.tierScale)
|
|
620
|
+
)
|
|
621
|
+
);
|
|
622
|
+
const targetHeight = Math.max(
|
|
623
|
+
1,
|
|
624
|
+
Math.min(
|
|
625
|
+
metadata.height,
|
|
626
|
+
Math.round(metadata.height * scale * options.tierScale)
|
|
627
|
+
)
|
|
628
|
+
);
|
|
629
|
+
const renderedWidth = Math.max(
|
|
630
|
+
...contexts.map((context) => context.cssWidth)
|
|
631
|
+
);
|
|
632
|
+
const renderedHeight = Math.max(
|
|
633
|
+
...contexts.map((context) => context.cssHeight)
|
|
634
|
+
);
|
|
635
|
+
const resizeNeeded = targetWidth < metadata.width || targetHeight < metadata.height;
|
|
636
|
+
const formatChanges = options.format !== "original" && options.format !== (metadata.format ?? void 0);
|
|
637
|
+
let estimatedBytes = null;
|
|
638
|
+
const willTransform = resizeNeeded || formatChanges;
|
|
639
|
+
if (willTransform && options.estimate) {
|
|
640
|
+
estimatedBytes = await estimateEncodedSize(
|
|
641
|
+
filePath,
|
|
642
|
+
targetWidth,
|
|
643
|
+
targetHeight,
|
|
644
|
+
{
|
|
645
|
+
format: options.format,
|
|
646
|
+
quality: options.quality,
|
|
647
|
+
originalFormat: metadata.format
|
|
648
|
+
}
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
const savingsBytes = estimatedBytes === null ? null : metadata.bytes - estimatedBytes;
|
|
652
|
+
const savingsPercent = savingsBytes === null || metadata.bytes === 0 ? null : savingsBytes / metadata.bytes * 100;
|
|
653
|
+
analyses.push({
|
|
654
|
+
url,
|
|
655
|
+
filePath,
|
|
656
|
+
originalWidth: metadata.width,
|
|
657
|
+
originalHeight: metadata.height,
|
|
658
|
+
originalBytes: metadata.bytes,
|
|
659
|
+
originalFormat: metadata.format,
|
|
660
|
+
renderedWidth,
|
|
661
|
+
renderedHeight,
|
|
662
|
+
contexts,
|
|
663
|
+
targetWidth,
|
|
664
|
+
targetHeight,
|
|
665
|
+
estimatedBytes,
|
|
666
|
+
savingsBytes,
|
|
667
|
+
savingsPercent,
|
|
668
|
+
transformable: willTransform,
|
|
669
|
+
recommended: willTransform && estimatedBytes !== null && estimatedBytes < metadata.bytes,
|
|
670
|
+
reason: willTransform ? void 0 : "Already at or below the rendered size."
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return analyses;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// src/core/site.ts
|
|
677
|
+
import { existsSync as existsSync2 } from "fs";
|
|
678
|
+
import {
|
|
679
|
+
copyFile,
|
|
680
|
+
mkdir as mkdir2,
|
|
681
|
+
readdir as readdir2,
|
|
682
|
+
readFile,
|
|
683
|
+
writeFile as writeFile2
|
|
684
|
+
} from "fs/promises";
|
|
685
|
+
import { dirname as dirname2, extname as extname2, join as join3, relative as relative2, resolve as resolve3, sep as sep3 } from "path";
|
|
686
|
+
var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
687
|
+
".html",
|
|
688
|
+
".htm",
|
|
689
|
+
".css",
|
|
690
|
+
".js",
|
|
691
|
+
".mjs",
|
|
692
|
+
".cjs",
|
|
693
|
+
".jsx",
|
|
694
|
+
".ts",
|
|
695
|
+
".tsx",
|
|
696
|
+
".json",
|
|
697
|
+
".svg",
|
|
698
|
+
".txt",
|
|
699
|
+
".xml",
|
|
700
|
+
".map"
|
|
701
|
+
]);
|
|
702
|
+
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
703
|
+
".jpg",
|
|
704
|
+
".jpeg",
|
|
705
|
+
".png",
|
|
706
|
+
".gif",
|
|
707
|
+
".webp",
|
|
708
|
+
".avif",
|
|
709
|
+
".tif",
|
|
710
|
+
".tiff",
|
|
711
|
+
".bmp"
|
|
712
|
+
]);
|
|
713
|
+
var IGNORED_DIRECTORIES2 = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
714
|
+
var REFERENCE_EXTENSIONS = ["webp", "avif", "png", "jpg", "jpeg"];
|
|
715
|
+
var IMAGE_REFERENCE = /([A-Za-z0-9_@%+~.,/\-]+)\.(jpe?g|png|gif|tiff?|bmp)(?=(["')\s?#>]|$))/gi;
|
|
716
|
+
function rewriteImageReferences(content, textRelativePath, outputDir) {
|
|
717
|
+
const textDirectory = dirname2(textRelativePath);
|
|
718
|
+
let replacements = 0;
|
|
719
|
+
const rewritten = content.replace(
|
|
720
|
+
IMAGE_REFERENCE,
|
|
721
|
+
(full, rawPath, extension) => {
|
|
722
|
+
if (rawPath.includes("://") || rawPath.startsWith("//") || rawPath.startsWith("data:")) {
|
|
723
|
+
return full;
|
|
724
|
+
}
|
|
725
|
+
const referenceDirectory = rawPath.startsWith("/") ? "" : textDirectory;
|
|
726
|
+
const relativeReference = rawPath.startsWith("/") ? rawPath.slice(1) : join3(referenceDirectory, rawPath);
|
|
727
|
+
const outputReference = resolve3(outputDir, relativeReference);
|
|
728
|
+
const withoutExtension = outputReference.slice(
|
|
729
|
+
0,
|
|
730
|
+
outputReference.length - extname2(outputReference).length
|
|
731
|
+
);
|
|
732
|
+
const current = extension.toLowerCase();
|
|
733
|
+
for (const candidate of REFERENCE_EXTENSIONS) {
|
|
734
|
+
const sameFormat = candidate === current || candidate === "jpg" && current === "jpeg" || candidate === "jpeg" && current === "jpg";
|
|
735
|
+
if (sameFormat) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (existsSync2(`${withoutExtension}.${candidate}`)) {
|
|
739
|
+
replacements += 1;
|
|
740
|
+
return `${rawPath}.${candidate}`;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return full;
|
|
744
|
+
}
|
|
745
|
+
);
|
|
746
|
+
return { content: rewritten, replacements };
|
|
747
|
+
}
|
|
748
|
+
async function walk(currentDirectory, options, result) {
|
|
749
|
+
const entries = await readdir2(currentDirectory, { withFileTypes: true });
|
|
750
|
+
for (const entry of entries) {
|
|
751
|
+
const sourcePath = join3(currentDirectory, entry.name);
|
|
752
|
+
if (entry.isSymbolicLink()) {
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (entry.isDirectory()) {
|
|
756
|
+
if (IGNORED_DIRECTORIES2.has(entry.name)) {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
const resolved = resolve3(sourcePath);
|
|
760
|
+
if (resolved === options.outputDir || resolved.startsWith(options.outputDir + sep3)) {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
await walk(sourcePath, options, result);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (!entry.isFile()) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const absoluteSource = resolve3(sourcePath);
|
|
770
|
+
if (options.optimized.has(absoluteSource)) {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
const relativePath = relative2(options.rootDir, sourcePath);
|
|
774
|
+
const destinationPath = join3(options.outputDir, relativePath);
|
|
775
|
+
const extension = extname2(sourcePath).toLowerCase();
|
|
776
|
+
if (TEXT_EXTENSIONS.has(extension)) {
|
|
777
|
+
const original = await readFile(sourcePath, "utf8");
|
|
778
|
+
const { content, replacements } = rewriteImageReferences(
|
|
779
|
+
original,
|
|
780
|
+
relativePath,
|
|
781
|
+
options.outputDir
|
|
782
|
+
);
|
|
783
|
+
await mkdir2(dirname2(destinationPath), { recursive: true });
|
|
784
|
+
await writeFile2(destinationPath, content);
|
|
785
|
+
result.copiedFiles += 1;
|
|
786
|
+
if (replacements > 0) {
|
|
787
|
+
result.rewrittenFiles += 1;
|
|
788
|
+
result.rewrittenReferences += replacements;
|
|
789
|
+
}
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (IMAGE_EXTENSIONS.has(extension)) {
|
|
793
|
+
if (!options.analyzed.has(absoluteSource)) {
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (existsSync2(destinationPath)) {
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
await mkdir2(dirname2(destinationPath), { recursive: true });
|
|
800
|
+
await copyFile(sourcePath, destinationPath);
|
|
801
|
+
result.copiedFiles += 1;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (existsSync2(destinationPath)) {
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
await mkdir2(dirname2(destinationPath), { recursive: true });
|
|
808
|
+
await copyFile(sourcePath, destinationPath);
|
|
809
|
+
result.copiedFiles += 1;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async function copyStaticSite(options) {
|
|
813
|
+
const result = {
|
|
814
|
+
copiedFiles: 0,
|
|
815
|
+
rewrittenFiles: 0,
|
|
816
|
+
rewrittenReferences: 0
|
|
817
|
+
};
|
|
818
|
+
await walk(resolve3(options.rootDir), {
|
|
819
|
+
...options,
|
|
820
|
+
rootDir: resolve3(options.rootDir),
|
|
821
|
+
outputDir: resolve3(options.outputDir)
|
|
822
|
+
}, result);
|
|
823
|
+
return result;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// src/report/prompts.ts
|
|
827
|
+
import prompts from "prompts";
|
|
828
|
+
|
|
829
|
+
// src/utils/bytes.ts
|
|
830
|
+
var UNITS = ["B", "KB", "MB", "GB"];
|
|
831
|
+
function formatBytes(bytes) {
|
|
832
|
+
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
833
|
+
return "-";
|
|
834
|
+
}
|
|
835
|
+
let value = bytes;
|
|
836
|
+
let unit = 0;
|
|
837
|
+
while (value >= 1024 && unit < UNITS.length - 1) {
|
|
838
|
+
value /= 1024;
|
|
839
|
+
unit += 1;
|
|
840
|
+
}
|
|
841
|
+
const digits = unit === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
|
842
|
+
return `${value.toFixed(digits)} ${UNITS[unit]}`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/report/prompts.ts
|
|
846
|
+
async function chooseInteractiveOptions(defaults, imageCount) {
|
|
847
|
+
const tierChoices = Object.entries(TIER_DESCRIPTIONS).map(([value, title]) => ({ title, value }));
|
|
848
|
+
tierChoices.push({ title: "Custom percentage", value: "custom" });
|
|
849
|
+
const tierResponse = await prompts({
|
|
850
|
+
type: "select",
|
|
851
|
+
name: "tier",
|
|
852
|
+
message: `Compression tier for ${imageCount} images`,
|
|
853
|
+
choices: tierChoices,
|
|
854
|
+
initial: Math.max(
|
|
855
|
+
0,
|
|
856
|
+
tierChoices.findIndex((choice) => choice.value === defaults.tier)
|
|
857
|
+
)
|
|
858
|
+
});
|
|
859
|
+
let tier = defaults.tier;
|
|
860
|
+
if (tierResponse.tier === "custom") {
|
|
861
|
+
const customResponse = await prompts({
|
|
862
|
+
type: "number",
|
|
863
|
+
name: "percentage",
|
|
864
|
+
message: "Target scale as a percentage of the exact rendered size",
|
|
865
|
+
initial: 80,
|
|
866
|
+
min: 1,
|
|
867
|
+
max: 100
|
|
868
|
+
});
|
|
869
|
+
tier = Number(customResponse.percentage ?? 80);
|
|
870
|
+
} else if (tierResponse.tier) {
|
|
871
|
+
tier = tierResponse.tier;
|
|
872
|
+
}
|
|
873
|
+
const formatResponse = await prompts({
|
|
874
|
+
type: "select",
|
|
875
|
+
name: "format",
|
|
876
|
+
message: "Output format",
|
|
877
|
+
choices: [
|
|
878
|
+
{ title: "Keep original format", value: "original" },
|
|
879
|
+
{ title: "WebP", value: "webp" },
|
|
880
|
+
{ title: "AVIF", value: "avif" }
|
|
881
|
+
],
|
|
882
|
+
initial: ["original", "webp", "avif"].indexOf(defaults.format)
|
|
883
|
+
});
|
|
884
|
+
const qualityResponse = await prompts({
|
|
885
|
+
type: "number",
|
|
886
|
+
name: "quality",
|
|
887
|
+
message: "Encoder quality (1-100)",
|
|
888
|
+
initial: defaults.quality,
|
|
889
|
+
min: 1,
|
|
890
|
+
max: 100
|
|
891
|
+
});
|
|
892
|
+
return {
|
|
893
|
+
tier,
|
|
894
|
+
format: formatResponse.format ?? defaults.format,
|
|
895
|
+
quality: Number(qualityResponse.quality ?? defaults.quality)
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
async function confirmProceed(analyses, options) {
|
|
899
|
+
const selected = analyses.filter((analysis) => analysis.recommended);
|
|
900
|
+
if (selected.length === 0) {
|
|
901
|
+
return false;
|
|
902
|
+
}
|
|
903
|
+
if (options.yes) {
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
const totalOriginal = selected.reduce(
|
|
907
|
+
(total, analysis) => total + analysis.originalBytes,
|
|
908
|
+
0
|
|
909
|
+
);
|
|
910
|
+
const totalEstimated = selected.reduce(
|
|
911
|
+
(total, analysis) => total + (analysis.estimatedBytes ?? analysis.originalBytes),
|
|
912
|
+
0
|
|
913
|
+
);
|
|
914
|
+
const destination = options.write ? "overwrite the original files" : `write to ${options.output} (original files are kept)`;
|
|
915
|
+
const response = await prompts({
|
|
916
|
+
type: "confirm",
|
|
917
|
+
name: "proceed",
|
|
918
|
+
message: `Compress ${selected.length} images (${formatBytes(
|
|
919
|
+
totalOriginal
|
|
920
|
+
)} \u2192 ${formatBytes(totalEstimated)}) and ${destination}?`,
|
|
921
|
+
initial: true
|
|
922
|
+
});
|
|
923
|
+
return Boolean(response.proceed);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// src/report/table.ts
|
|
927
|
+
import chalk from "chalk";
|
|
928
|
+
var MAX_FILE_WIDTH = 44;
|
|
929
|
+
function displayPath(analysis, rootDir) {
|
|
930
|
+
if (analysis.filePath && rootDir) {
|
|
931
|
+
const normalizedRoot = rootDir.endsWith("/") ? rootDir : `${rootDir}/`;
|
|
932
|
+
if (analysis.filePath.startsWith(normalizedRoot)) {
|
|
933
|
+
return analysis.filePath.slice(normalizedRoot.length);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (analysis.filePath) {
|
|
937
|
+
return analysis.filePath;
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
return new URL(analysis.url).pathname;
|
|
941
|
+
} catch {
|
|
942
|
+
return analysis.url;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
function truncate(value, maxWidth) {
|
|
946
|
+
if (value.length <= maxWidth) {
|
|
947
|
+
return value;
|
|
948
|
+
}
|
|
949
|
+
return `${value.slice(0, maxWidth - 1)}\u2026`;
|
|
950
|
+
}
|
|
951
|
+
function buildRows(analyses, options) {
|
|
952
|
+
return analyses.map((analysis) => {
|
|
953
|
+
const kinds = new Set(analysis.contexts.map((context) => context.kind));
|
|
954
|
+
const magnitude = analysis.savingsPercent === null ? null : Math.abs(analysis.savingsPercent);
|
|
955
|
+
const savings = magnitude === null ? "-" : `${analysis.savingsPercent >= 0 ? "-" : "+"}${magnitude >= 99.5 ? magnitude.toFixed(1) : magnitude.toFixed(0)}%`;
|
|
956
|
+
return {
|
|
957
|
+
file: truncate(displayPath(analysis, options.rootDir), MAX_FILE_WIDTH),
|
|
958
|
+
kind: kinds.size === 2 ? "mixed" : analysis.contexts[0]?.kind ?? "img",
|
|
959
|
+
rendered: `${Math.round(analysis.renderedWidth)}\xD7${Math.round(
|
|
960
|
+
analysis.renderedHeight
|
|
961
|
+
)}`,
|
|
962
|
+
target: `${analysis.targetWidth}\xD7${analysis.targetHeight}`,
|
|
963
|
+
original: analysis.originalBytes > 0 ? formatBytes(analysis.originalBytes) : "-",
|
|
964
|
+
estimated: analysis.estimatedBytes !== null ? `~${formatBytes(analysis.estimatedBytes)}` : "-",
|
|
965
|
+
savings,
|
|
966
|
+
recommended: analysis.recommended,
|
|
967
|
+
reason: analysis.reason ?? ""
|
|
968
|
+
};
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
function pad(value, width) {
|
|
972
|
+
return value.padEnd(width);
|
|
973
|
+
}
|
|
974
|
+
function renderReport(analyses, options) {
|
|
975
|
+
const rows = buildRows(analyses, options);
|
|
976
|
+
const headers = [
|
|
977
|
+
"FILE",
|
|
978
|
+
"KIND",
|
|
979
|
+
"RENDERED",
|
|
980
|
+
"TARGET",
|
|
981
|
+
"ORIGINAL",
|
|
982
|
+
"EST. SIZE",
|
|
983
|
+
"SAVE"
|
|
984
|
+
];
|
|
985
|
+
const widths = headers.map((header, index) => {
|
|
986
|
+
const key = [
|
|
987
|
+
"file",
|
|
988
|
+
"kind",
|
|
989
|
+
"rendered",
|
|
990
|
+
"target",
|
|
991
|
+
"original",
|
|
992
|
+
"estimated",
|
|
993
|
+
"savings"
|
|
994
|
+
][index];
|
|
995
|
+
const values = rows.map((row) => String(row[key]).length);
|
|
996
|
+
return Math.max(header.length, ...values);
|
|
997
|
+
});
|
|
998
|
+
const lines = [];
|
|
999
|
+
const headerLine = headers.map((header, index) => pad(header, widths[index] ?? header.length)).join(" ");
|
|
1000
|
+
const pages = options.pageCount && options.pageCount > 1 ? ` \xB7 ${options.pageCount} pages` : "";
|
|
1001
|
+
lines.push(
|
|
1002
|
+
chalk.bold(
|
|
1003
|
+
`${options.deviceName} ${options.viewport.width}\xD7${options.viewport.height} @${options.dpr}x \xB7 tier ${Math.round(
|
|
1004
|
+
options.tierScale * 100
|
|
1005
|
+
)}% \xB7 ${options.format} q${options.quality}${pages}`
|
|
1006
|
+
)
|
|
1007
|
+
);
|
|
1008
|
+
lines.push(chalk.dim(headerLine));
|
|
1009
|
+
lines.push(chalk.dim(widths.map((width) => "\u2500".repeat(width)).join(" ")));
|
|
1010
|
+
let totalOriginal = 0;
|
|
1011
|
+
let totalEstimated = 0;
|
|
1012
|
+
let recommendedCount = 0;
|
|
1013
|
+
for (const row of rows) {
|
|
1014
|
+
const cells = [
|
|
1015
|
+
row.file,
|
|
1016
|
+
row.kind,
|
|
1017
|
+
row.rendered,
|
|
1018
|
+
row.target,
|
|
1019
|
+
row.original,
|
|
1020
|
+
row.estimated,
|
|
1021
|
+
row.savings
|
|
1022
|
+
].map((value, index) => pad(value, widths[index] ?? value.length));
|
|
1023
|
+
const line = cells.join(" ");
|
|
1024
|
+
if (row.recommended) {
|
|
1025
|
+
recommendedCount += 1;
|
|
1026
|
+
lines.push(chalk.green(line));
|
|
1027
|
+
} else {
|
|
1028
|
+
lines.push(chalk.dim(line));
|
|
1029
|
+
}
|
|
1030
|
+
if (row.reason) {
|
|
1031
|
+
lines.push(chalk.dim(` ${row.reason}`));
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
for (const analysis of analyses) {
|
|
1035
|
+
if (!analysis.recommended) {
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
totalOriginal += analysis.originalBytes;
|
|
1039
|
+
totalEstimated += analysis.estimatedBytes ?? analysis.originalBytes;
|
|
1040
|
+
}
|
|
1041
|
+
const saved = totalOriginal - totalEstimated;
|
|
1042
|
+
const percent = totalOriginal > 0 ? saved / totalOriginal * 100 : 0;
|
|
1043
|
+
lines.push("");
|
|
1044
|
+
lines.push(
|
|
1045
|
+
`${recommendedCount} of ${analyses.length} images optimizable \xB7 ${chalk.bold(
|
|
1046
|
+
formatBytes(totalOriginal)
|
|
1047
|
+
)} \u2192 ${chalk.bold(formatBytes(totalEstimated))} \xB7 save ${chalk.green(
|
|
1048
|
+
`${formatBytes(saved)} (${percent.toFixed(0)}%)`
|
|
1049
|
+
)}`
|
|
1050
|
+
);
|
|
1051
|
+
return lines.join("\n");
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/server/static.ts
|
|
1055
|
+
import http from "http";
|
|
1056
|
+
import sirv from "sirv";
|
|
1057
|
+
async function startStaticServer(root) {
|
|
1058
|
+
const serve = sirv(root, {
|
|
1059
|
+
dev: true,
|
|
1060
|
+
etag: true,
|
|
1061
|
+
single: false,
|
|
1062
|
+
ignores: false
|
|
1063
|
+
});
|
|
1064
|
+
const server = http.createServer((request, response) => {
|
|
1065
|
+
serve(request, response, () => {
|
|
1066
|
+
response.statusCode = 404;
|
|
1067
|
+
response.end("Not found");
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
1071
|
+
server.once("error", rejectPromise);
|
|
1072
|
+
server.listen(0, "127.0.0.1", () => resolvePromise());
|
|
1073
|
+
});
|
|
1074
|
+
const address = server.address();
|
|
1075
|
+
return {
|
|
1076
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
1077
|
+
close: () => new Promise((resolvePromise, rejectPromise) => {
|
|
1078
|
+
server.close(
|
|
1079
|
+
(error) => error ? rejectPromise(error) : resolvePromise()
|
|
1080
|
+
);
|
|
1081
|
+
})
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// src/index.ts
|
|
1086
|
+
async function run(options) {
|
|
1087
|
+
const isUrl = /^https?:\/\//i.test(options.target);
|
|
1088
|
+
let rootDir = options.root ? resolve4(options.root) : null;
|
|
1089
|
+
let server = null;
|
|
1090
|
+
let pageUrl = options.target;
|
|
1091
|
+
let pageUrls = [options.target];
|
|
1092
|
+
const outputDir = resolve4(options.output);
|
|
1093
|
+
try {
|
|
1094
|
+
if (isUrl) {
|
|
1095
|
+
if (rootDir && !isDirectory(rootDir)) {
|
|
1096
|
+
throw new Error(`Root directory not found: ${rootDir}`);
|
|
1097
|
+
}
|
|
1098
|
+
} else {
|
|
1099
|
+
const targetPath = resolve4(options.target);
|
|
1100
|
+
if (!existsSync3(targetPath)) {
|
|
1101
|
+
throw new Error(`Target not found: ${targetPath}`);
|
|
1102
|
+
}
|
|
1103
|
+
const stats = statSync(targetPath);
|
|
1104
|
+
if (stats.isDirectory()) {
|
|
1105
|
+
rootDir = targetPath;
|
|
1106
|
+
server = await startStaticServer(targetPath);
|
|
1107
|
+
const exclude = outputDir !== rootDir && outputDir.startsWith(rootDir + sep4) ? [outputDir] : [];
|
|
1108
|
+
const htmlFiles = await findHtmlFiles(targetPath, { exclude });
|
|
1109
|
+
if (htmlFiles.length === 0) {
|
|
1110
|
+
throw new Error(`No HTML files found under ${targetPath}.`);
|
|
1111
|
+
}
|
|
1112
|
+
pageUrls = htmlFiles.map(
|
|
1113
|
+
(filePath) => toPageUrl(server.url, rootDir, filePath)
|
|
1114
|
+
);
|
|
1115
|
+
pageUrl = pageUrls[0];
|
|
1116
|
+
} else if (stats.isFile()) {
|
|
1117
|
+
let serveDir = resolve4(options.root ?? dirname3(targetPath));
|
|
1118
|
+
let relativeFile = relative3(serveDir, targetPath);
|
|
1119
|
+
if (relativeFile.startsWith("..") || relativeFile === "") {
|
|
1120
|
+
serveDir = dirname3(targetPath);
|
|
1121
|
+
relativeFile = relative3(serveDir, targetPath);
|
|
1122
|
+
}
|
|
1123
|
+
rootDir = serveDir;
|
|
1124
|
+
server = await startStaticServer(serveDir);
|
|
1125
|
+
pageUrl = new URL(
|
|
1126
|
+
relativeFile.split(sep4).join("/"),
|
|
1127
|
+
`${server.url}/`
|
|
1128
|
+
).href;
|
|
1129
|
+
pageUrls = [pageUrl];
|
|
1130
|
+
} else {
|
|
1131
|
+
throw new Error(`Target is not a file or directory: ${targetPath}`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
const preset = getDevicePreset(
|
|
1135
|
+
options.device,
|
|
1136
|
+
options.dpr,
|
|
1137
|
+
options.viewport
|
|
1138
|
+
);
|
|
1139
|
+
const pageCount = pageUrls.length;
|
|
1140
|
+
const inspectLabel = pageCount === 1 ? pageUrls[0] : `${pageCount} pages`;
|
|
1141
|
+
const inspectSpinner = ora(
|
|
1142
|
+
`Inspecting ${inspectLabel} as ${preset.name} ${preset.viewport.width}\xD7${preset.viewport.height} @${preset.deviceScaleFactor}x`
|
|
1143
|
+
).start();
|
|
1144
|
+
let inspection;
|
|
1145
|
+
try {
|
|
1146
|
+
inspection = await inspectPages(pageUrls, {
|
|
1147
|
+
device: options.device,
|
|
1148
|
+
dpr: preset.deviceScaleFactor,
|
|
1149
|
+
viewport: preset.viewport,
|
|
1150
|
+
timeout: options.timeout,
|
|
1151
|
+
browser: options.browser
|
|
1152
|
+
});
|
|
1153
|
+
inspectSpinner.succeed(
|
|
1154
|
+
pageCount === 1 ? `Found ${inspection.images.length} rendered image references` : `Found ${inspection.images.length} rendered image references across ${pageCount} pages`
|
|
1155
|
+
);
|
|
1156
|
+
if (inspection.failures.length > 0) {
|
|
1157
|
+
console.log(
|
|
1158
|
+
chalk2.yellow(
|
|
1159
|
+
`${inspection.failures.length} page(s) could not be inspected:`
|
|
1160
|
+
)
|
|
1161
|
+
);
|
|
1162
|
+
for (const failure of inspection.failures) {
|
|
1163
|
+
console.log(
|
|
1164
|
+
chalk2.yellow(` - ${failure.pageUrl}: ${failure.message}`)
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
inspectSpinner.fail("Inspection failed");
|
|
1170
|
+
throw error;
|
|
1171
|
+
}
|
|
1172
|
+
if (inspection.images.length === 0) {
|
|
1173
|
+
console.log(chalk2.yellow("No rendered images found on the page(s)."));
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
let tier = options.tier;
|
|
1177
|
+
let format = options.format;
|
|
1178
|
+
let quality = options.quality;
|
|
1179
|
+
if (options.interactive) {
|
|
1180
|
+
const chosen = await chooseInteractiveOptions(
|
|
1181
|
+
{ tier, format, quality },
|
|
1182
|
+
inspection.images.length
|
|
1183
|
+
);
|
|
1184
|
+
tier = chosen.tier;
|
|
1185
|
+
format = chosen.format;
|
|
1186
|
+
quality = chosen.quality;
|
|
1187
|
+
}
|
|
1188
|
+
if (options.write && format !== "original") {
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
"--in-place keeps the original file names, so it requires --format original. Use --out to convert formats."
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
const tierScale = resolveTierScale(tier);
|
|
1194
|
+
const analyzeSpinner = ora("Analyzing image dimensions and sizes...").start();
|
|
1195
|
+
let analyses;
|
|
1196
|
+
try {
|
|
1197
|
+
analyses = await analyzeImages(inspection.images, {
|
|
1198
|
+
dpr: inspection.dpr,
|
|
1199
|
+
tierScale,
|
|
1200
|
+
quality,
|
|
1201
|
+
format,
|
|
1202
|
+
pageUrl,
|
|
1203
|
+
rootDir,
|
|
1204
|
+
estimate: true
|
|
1205
|
+
});
|
|
1206
|
+
analyzeSpinner.succeed(`Analyzed ${analyses.length} unique images`);
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
analyzeSpinner.fail("Analysis failed");
|
|
1209
|
+
throw error;
|
|
1210
|
+
}
|
|
1211
|
+
if (options.json) {
|
|
1212
|
+
console.log(JSON.stringify(analyses, null, 2));
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
console.log("");
|
|
1216
|
+
console.log(
|
|
1217
|
+
renderReport(analyses, {
|
|
1218
|
+
rootDir,
|
|
1219
|
+
deviceName: inspection.deviceName,
|
|
1220
|
+
viewport: inspection.viewport,
|
|
1221
|
+
dpr: inspection.dpr,
|
|
1222
|
+
tierScale,
|
|
1223
|
+
format,
|
|
1224
|
+
quality,
|
|
1225
|
+
pageCount: inspection.pages.length
|
|
1226
|
+
})
|
|
1227
|
+
);
|
|
1228
|
+
console.log("");
|
|
1229
|
+
if (options.dryRun) {
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const proceed = await confirmProceed(analyses, {
|
|
1233
|
+
write: options.write,
|
|
1234
|
+
output: options.output,
|
|
1235
|
+
yes: options.yes
|
|
1236
|
+
});
|
|
1237
|
+
if (!proceed) {
|
|
1238
|
+
console.log(chalk2.yellow("Nothing to do."));
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
const selected = analyses.filter((analysis) => analysis.recommended);
|
|
1242
|
+
if (!options.write && rootDir && outputDir === rootDir) {
|
|
1243
|
+
throw new Error(
|
|
1244
|
+
"--out cannot be the project root. Choose a subfolder or use --in-place."
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
const compressSpinner = ora(`Compressing ${selected.length} images...`).start();
|
|
1248
|
+
let originalBytes = 0;
|
|
1249
|
+
let outputBytes = 0;
|
|
1250
|
+
const failures = [];
|
|
1251
|
+
for (const analysis of selected) {
|
|
1252
|
+
try {
|
|
1253
|
+
const result = await compressImage(analysis, {
|
|
1254
|
+
format,
|
|
1255
|
+
quality,
|
|
1256
|
+
outputDir,
|
|
1257
|
+
write: options.write,
|
|
1258
|
+
rootDir
|
|
1259
|
+
});
|
|
1260
|
+
originalBytes += analysis.originalBytes;
|
|
1261
|
+
outputBytes += result.bytes;
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1264
|
+
failures.push(`${analysis.filePath ?? analysis.url}: ${message}`);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
const saved = originalBytes - outputBytes;
|
|
1268
|
+
compressSpinner.succeed(
|
|
1269
|
+
`Compressed ${selected.length - failures.length} images \xB7 ${formatBytes(
|
|
1270
|
+
originalBytes
|
|
1271
|
+
)} \u2192 ${formatBytes(outputBytes)} \xB7 saved ${formatBytes(Math.max(saved, 0))}`
|
|
1272
|
+
);
|
|
1273
|
+
if (failures.length > 0) {
|
|
1274
|
+
console.log(chalk2.red(`
|
|
1275
|
+
${failures.length} image(s) failed:`));
|
|
1276
|
+
for (const failure of failures) {
|
|
1277
|
+
console.log(chalk2.red(` - ${failure}`));
|
|
1278
|
+
}
|
|
1279
|
+
process.exitCode = 1;
|
|
1280
|
+
}
|
|
1281
|
+
if (options.site) {
|
|
1282
|
+
if (!rootDir) {
|
|
1283
|
+
console.log(
|
|
1284
|
+
chalk2.yellow("--site requires a local root; site copy was skipped.")
|
|
1285
|
+
);
|
|
1286
|
+
} else {
|
|
1287
|
+
const siteSpinner = ora("Copying site files...").start();
|
|
1288
|
+
try {
|
|
1289
|
+
const analyzed = new Set(
|
|
1290
|
+
analyses.map((analysis) => analysis.filePath).filter((filePath) => Boolean(filePath))
|
|
1291
|
+
);
|
|
1292
|
+
const optimized = new Set(
|
|
1293
|
+
selected.map((analysis) => analysis.filePath).filter((filePath) => Boolean(filePath))
|
|
1294
|
+
);
|
|
1295
|
+
const siteResult = await copyStaticSite({
|
|
1296
|
+
rootDir,
|
|
1297
|
+
outputDir,
|
|
1298
|
+
analyzed,
|
|
1299
|
+
optimized
|
|
1300
|
+
});
|
|
1301
|
+
siteSpinner.succeed(
|
|
1302
|
+
`Copied ${siteResult.copiedFiles} files \xB7 rewrote ${siteResult.rewrittenReferences} image references`
|
|
1303
|
+
);
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
siteSpinner.fail("Site copy failed");
|
|
1306
|
+
throw error;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (options.write) {
|
|
1311
|
+
console.log(chalk2.dim("Original files were overwritten."));
|
|
1312
|
+
} else {
|
|
1313
|
+
console.log(chalk2.dim(`Output written to ${outputDir}`));
|
|
1314
|
+
}
|
|
1315
|
+
} finally {
|
|
1316
|
+
if (server) {
|
|
1317
|
+
await server.close();
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
function isDirectory(path) {
|
|
1322
|
+
return existsSync3(path) && statSync(path).isDirectory();
|
|
1323
|
+
}
|
|
1324
|
+
function toPageUrl(serverUrl, rootDir, filePath) {
|
|
1325
|
+
const relativePath = relative3(rootDir, filePath).split(sep4).join("/");
|
|
1326
|
+
return new URL(relativePath, `${serverUrl}/`).href;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// src/cli.ts
|
|
1330
|
+
var packageJson = JSON.parse(
|
|
1331
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
1332
|
+
);
|
|
1333
|
+
var program = new Command();
|
|
1334
|
+
program.name("viewfit").description(
|
|
1335
|
+
"Fit and compress images to their actual rendered dimensions using a headless browser."
|
|
1336
|
+
).version(packageJson.version).addHelpText(
|
|
1337
|
+
"after",
|
|
1338
|
+
"\nRun `viewfit inspect --help` or `viewfit optimize --help` for the full option list."
|
|
1339
|
+
);
|
|
1340
|
+
addCommonOptions(
|
|
1341
|
+
program.command("inspect").description("Analyze images and print a report. Never writes files.").argument(
|
|
1342
|
+
"[target]",
|
|
1343
|
+
"local directory, HTML file or URL to inspect",
|
|
1344
|
+
"."
|
|
1345
|
+
)
|
|
1346
|
+
).action(async (target, raw) => {
|
|
1347
|
+
await run(normalizeOptions(target, raw, false));
|
|
1348
|
+
});
|
|
1349
|
+
addCommonOptions(
|
|
1350
|
+
program.command("optimize").description("Analyze images and write the optimized files.").argument(
|
|
1351
|
+
"[target]",
|
|
1352
|
+
"local directory, HTML file or URL to optimize",
|
|
1353
|
+
"."
|
|
1354
|
+
)
|
|
1355
|
+
).option("--out <dir>", "output directory (default: viewfit-output)").option("-w, --in-place", "overwrite the original files", false).option(
|
|
1356
|
+
"--site",
|
|
1357
|
+
"copy HTML/CSS/JS into the output folder and rewrite image references",
|
|
1358
|
+
false
|
|
1359
|
+
).option("-y, --yes", "skip confirmation prompts", false).action(async (target, raw) => {
|
|
1360
|
+
await run(normalizeOptions(target, raw, true, raw));
|
|
1361
|
+
});
|
|
1362
|
+
function addCommonOptions(command) {
|
|
1363
|
+
return command.option(
|
|
1364
|
+
"-p, --preset <name>",
|
|
1365
|
+
`preset: ${Object.keys(PRESETS).join(", ")}`
|
|
1366
|
+
).option("-d, --device <mode>", "device mode: desktop or mobile").option("--viewport <width>x<height>", "viewport override, e.g. 1440x900").option(
|
|
1367
|
+
"-t, --tier <tier>",
|
|
1368
|
+
"exact, safe, aggressive, extreme or a number (1-100)"
|
|
1369
|
+
).option("-q, --quality <number>", "encoder quality (1-100)").option("-f, --format <format>", "original, webp or avif").option("--root <dir>", "project root used to map URLs to local files").option(
|
|
1370
|
+
"--browser <channel>",
|
|
1371
|
+
"Playwright browser channel (chromium or chrome)",
|
|
1372
|
+
"chromium"
|
|
1373
|
+
).option("--timeout <ms>", "navigation timeout in milliseconds", "30000").option(
|
|
1374
|
+
"-i, --interactive",
|
|
1375
|
+
"choose tier, format and quality interactively",
|
|
1376
|
+
false
|
|
1377
|
+
).option("--json", "print the analysis as JSON", false);
|
|
1378
|
+
}
|
|
1379
|
+
function normalizeOptions(target, raw, apply, optimize) {
|
|
1380
|
+
const preset = raw.preset ? getPreset(raw.preset) : void 0;
|
|
1381
|
+
const deviceValue = raw.device ?? preset?.device ?? "desktop";
|
|
1382
|
+
if (!isDeviceMode(deviceValue)) {
|
|
1383
|
+
throw new Error(`Invalid device "${deviceValue}". Use desktop or mobile.`);
|
|
1384
|
+
}
|
|
1385
|
+
const device = deviceValue;
|
|
1386
|
+
const viewport = raw.viewport ? parseViewport(raw.viewport) : void 0;
|
|
1387
|
+
const tier = raw.tier !== void 0 ? parseTier(raw.tier) : preset?.tier ?? "exact";
|
|
1388
|
+
const quality = Number(raw.quality ?? preset?.quality ?? 80);
|
|
1389
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
|
|
1390
|
+
throw new Error("--quality must be an integer between 1 and 100.");
|
|
1391
|
+
}
|
|
1392
|
+
const formatValue = raw.format ?? preset?.format ?? "original";
|
|
1393
|
+
if (!isOutputFormat(formatValue)) {
|
|
1394
|
+
throw new Error(
|
|
1395
|
+
`Invalid format "${formatValue}". Use original, webp or avif.`
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
const format = formatValue;
|
|
1399
|
+
const timeout = Number(raw.timeout);
|
|
1400
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
1401
|
+
throw new Error("--timeout must be a positive number.");
|
|
1402
|
+
}
|
|
1403
|
+
const write = optimize?.inPlace ?? false;
|
|
1404
|
+
const site = optimize?.site ?? false;
|
|
1405
|
+
if (write && format !== "original") {
|
|
1406
|
+
throw new Error(
|
|
1407
|
+
"--in-place requires --format original. Use --out to convert formats."
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
if (write && site) {
|
|
1411
|
+
throw new Error(
|
|
1412
|
+
"--site cannot be combined with --in-place. Use --out to keep a copy of the site."
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
if (write && optimize?.out) {
|
|
1416
|
+
throw new Error("--in-place cannot be combined with --out.");
|
|
1417
|
+
}
|
|
1418
|
+
return {
|
|
1419
|
+
target,
|
|
1420
|
+
root: raw.root,
|
|
1421
|
+
device,
|
|
1422
|
+
viewport,
|
|
1423
|
+
tier,
|
|
1424
|
+
quality,
|
|
1425
|
+
format,
|
|
1426
|
+
output: optimize?.out ?? "viewfit-output",
|
|
1427
|
+
write,
|
|
1428
|
+
site,
|
|
1429
|
+
browser: raw.browser,
|
|
1430
|
+
timeout,
|
|
1431
|
+
dryRun: !apply,
|
|
1432
|
+
interactive: raw.interactive,
|
|
1433
|
+
yes: optimize?.yes ?? false,
|
|
1434
|
+
json: raw.json
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
function isOutputFormat(value) {
|
|
1438
|
+
return value === "original" || value === "webp" || value === "avif";
|
|
1439
|
+
}
|
|
1440
|
+
var argv = process.argv.slice();
|
|
1441
|
+
var first = argv[2];
|
|
1442
|
+
var isHelpOrVersion = first === "--help" || first === "-h" || first === "--version" || first === "-V";
|
|
1443
|
+
if (!isHelpOrVersion && first !== "inspect" && first !== "optimize") {
|
|
1444
|
+
argv.splice(2, 0, "inspect");
|
|
1445
|
+
}
|
|
1446
|
+
program.parseAsync(argv).catch((error) => {
|
|
1447
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1448
|
+
console.error(chalk3.red(message));
|
|
1449
|
+
process.exitCode = 1;
|
|
1450
|
+
});
|
|
1451
|
+
//# sourceMappingURL=cli.js.map
|