bytecut 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,1432 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/core/errors.ts
4
+ var BytecutError = class extends Error {
5
+ code;
6
+ constructor(code, message, options) {
7
+ super(message, options);
8
+ this.name = "BytecutError";
9
+ this.code = code;
10
+ }
11
+ };
12
+
13
+ // src/core/units.ts
14
+ function formatBytes(bytes) {
15
+ if (bytes < 1024) return `${bytes} B`;
16
+ const units = ["KB", "MB", "GB"];
17
+ let value = bytes / 1024;
18
+ let unit = 0;
19
+ while (value >= 1024 && unit < units.length - 1) {
20
+ value /= 1024;
21
+ unit++;
22
+ }
23
+ return `${value.toFixed(value < 10 ? 2 : 1)} ${units[unit]}`;
24
+ }
25
+ function percentChange(before, after) {
26
+ if (before === 0) return "n/a";
27
+ const change = Math.round((after - before) / before * 100);
28
+ return `${change > 0 ? "+" : ""}${change}%`;
29
+ }
30
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
31
+ var SIZE_UNITS = {
32
+ b: 1,
33
+ kb: 1024,
34
+ kib: 1024,
35
+ mb: 1024 ** 2,
36
+ mib: 1024 ** 2,
37
+ gb: 1024 ** 3,
38
+ gib: 1024 ** 3
39
+ };
40
+ function parseSize(text) {
41
+ const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(text);
42
+ if (!match) return null;
43
+ const factor = SIZE_UNITS[(match[2] ?? "").toLowerCase() || "b"];
44
+ if (factor === void 0) return null;
45
+ return Math.round(Number(match[1]) * factor);
46
+ }
47
+
48
+ // src/core/config/schema.ts
49
+ import { z } from "zod";
50
+
51
+ // src/core/audit/rules/helpers.ts
52
+ function ruleOptions(ctx, id, defaults) {
53
+ return { ...defaults, ...ctx.options(id) };
54
+ }
55
+ function isCovered(image) {
56
+ return image.cache.kind === "hit";
57
+ }
58
+ function savingOf(image) {
59
+ if (!image.estimate) return void 0;
60
+ return Math.max(0, image.bytes - image.estimate.best.bytes);
61
+ }
62
+
63
+ // src/core/audit/rules/cmyk-or-16bit.ts
64
+ var ID = "image/cmyk-or-16bit";
65
+ var cmykOr16bit = {
66
+ id: ID,
67
+ category: "image",
68
+ defaultSeverity: "warn",
69
+ fix: "optimize",
70
+ async check(ctx) {
71
+ const findings = [];
72
+ for (const image of ctx.images) {
73
+ const { space, depth } = image.analysis;
74
+ const cmyk = space === "cmyk";
75
+ const deep = depth === "ushort" || depth === "uint" || depth === "float" || depth === "double";
76
+ if (!cmyk && !deep || isCovered(image)) continue;
77
+ findings.push({
78
+ ruleId: ID,
79
+ severity: "warn",
80
+ source: image.source,
81
+ message: cmyk ? "CMYK colour space renders wrongly in browsers; outputs are converted to sRGB" : "16-bit depth is wasted on the web; outputs are 8-bit",
82
+ current: image.bytes,
83
+ ...image.estimate ? { estimated: image.estimate.best.bytes } : {}
84
+ });
85
+ }
86
+ return findings;
87
+ }
88
+ };
89
+
90
+ // src/core/audit/rules/duplicate.ts
91
+ var ID2 = "project/duplicate";
92
+ var duplicate = {
93
+ id: ID2,
94
+ category: "project",
95
+ defaultSeverity: "warn",
96
+ fix: null,
97
+ async check(ctx) {
98
+ const groups = /* @__PURE__ */ new Map();
99
+ for (const file of [...ctx.images, ...ctx.svgs]) {
100
+ const list = groups.get(file.sha256) ?? [];
101
+ list.push({ source: file.source, bytes: file.bytes });
102
+ groups.set(file.sha256, list);
103
+ }
104
+ const findings = [];
105
+ for (const list of groups.values()) {
106
+ if (list.length < 2) continue;
107
+ list.sort((a, b) => a.source < b.source ? -1 : 1);
108
+ const [first, ...copies] = list;
109
+ findings.push({
110
+ ruleId: ID2,
111
+ severity: "warn",
112
+ source: first.source,
113
+ message: `identical content in ${copies.length} other file${copies.length === 1 ? "" : "s"}: ${copies.map((c) => c.source).join(", ")}`,
114
+ current: first.bytes * list.length,
115
+ savingsBytes: first.bytes * copies.length
116
+ });
117
+ }
118
+ return findings;
119
+ }
120
+ };
121
+
122
+ // src/core/audit/rules/embedded-raster.ts
123
+ var ID3 = "svg/embedded-raster";
124
+ var svgEmbeddedRaster = {
125
+ id: ID3,
126
+ category: "svg",
127
+ defaultSeverity: "warn",
128
+ fix: null,
129
+ async check(ctx) {
130
+ const { minBytes } = ruleOptions(ctx, ID3, { minBytes: 1024 });
131
+ const findings = [];
132
+ for (const svg of ctx.svgs) {
133
+ if (svg.embeddedRasterCount === 0 || svg.embeddedRasterBytes < minBytes) continue;
134
+ findings.push({
135
+ ruleId: ID3,
136
+ severity: "warn",
137
+ source: svg.source,
138
+ message: `embeds ${svg.embeddedRasterCount} raster image${svg.embeddedRasterCount === 1 ? "" : "s"} as base64 (${svg.embeddedRasterBytes} bytes); extract and optimize ${svg.embeddedRasterCount === 1 ? "it" : "them"} separately`,
139
+ current: svg.bytes
140
+ });
141
+ }
142
+ return findings;
143
+ }
144
+ };
145
+
146
+ // src/core/audit/rules/gif-animated.ts
147
+ import { extname } from "path";
148
+
149
+ // src/core/audit/rules/video-helpers.ts
150
+ function isVideoCovered(video) {
151
+ return video.cache.kind === "hit";
152
+ }
153
+ function videoSaving(video) {
154
+ if (!video.estimate) return void 0;
155
+ return Math.max(0, video.bytes - video.estimate.best.bytes);
156
+ }
157
+ var UNIVERSAL_CODECS = /* @__PURE__ */ new Set(["h264", "avc1"]);
158
+ var MODERN_CODECS = /* @__PURE__ */ new Set(["av1", "vp9"]);
159
+ function codecLabel(codec) {
160
+ switch (codec) {
161
+ case "h264":
162
+ return "H.264";
163
+ case "hevc":
164
+ return "HEVC";
165
+ case "vp9":
166
+ return "VP9";
167
+ case "av1":
168
+ return "AV1";
169
+ default:
170
+ return codec.toUpperCase();
171
+ }
172
+ }
173
+
174
+ // src/core/audit/rules/gif-animated.ts
175
+ var ID4 = "gif/animated";
176
+ var gifAnimated = {
177
+ id: ID4,
178
+ category: "gif",
179
+ defaultSeverity: "warn",
180
+ fix: "optimize",
181
+ async check(ctx) {
182
+ const findings = [];
183
+ for (const video of ctx.videos) {
184
+ if (!video.isGif || isVideoCovered(video)) continue;
185
+ const saving = videoSaving(video);
186
+ findings.push({
187
+ ruleId: ID4,
188
+ severity: "warn",
189
+ source: video.source,
190
+ message: "animated GIF; a looping muted video is typically far smaller",
191
+ current: video.bytes,
192
+ ...video.estimate ? { estimated: video.estimate.best.bytes } : {},
193
+ ...saving !== void 0 ? { savingsBytes: saving } : {}
194
+ });
195
+ }
196
+ for (const gif of ctx.unprobed) {
197
+ if (extname(gif.source).toLowerCase() !== ".gif") continue;
198
+ findings.push({
199
+ ruleId: ID4,
200
+ severity: "warn",
201
+ source: gif.source,
202
+ message: `animated GIF; a looping muted video is typically far smaller. Needs ffmpeg: ${ctx.ffmpeg.installHint}`,
203
+ current: gif.bytes
204
+ });
205
+ }
206
+ return findings;
207
+ }
208
+ };
209
+
210
+ // src/core/audit/rules/gif-static.ts
211
+ var ID5 = "gif/static";
212
+ var gifStatic = {
213
+ id: ID5,
214
+ category: "gif",
215
+ defaultSeverity: "warn",
216
+ fix: "optimize",
217
+ async check(ctx) {
218
+ const findings = [];
219
+ for (const image of ctx.images) {
220
+ if (image.analysis.format !== "gif" || isCovered(image)) continue;
221
+ const saving = savingOf(image);
222
+ findings.push({
223
+ ruleId: ID5,
224
+ severity: "warn",
225
+ source: image.source,
226
+ message: "GIF with a single frame; use a modern still image format",
227
+ current: image.bytes,
228
+ ...image.estimate ? { estimated: image.estimate.best.bytes } : {},
229
+ ...saving !== void 0 ? { savingsBytes: saving } : {}
230
+ });
231
+ }
232
+ return findings;
233
+ }
234
+ };
235
+
236
+ // src/core/audit/rules/heavy.ts
237
+ var ID6 = "image/heavy";
238
+ var heavy = {
239
+ id: ID6,
240
+ category: "image",
241
+ defaultSeverity: "warn",
242
+ fix: "optimize",
243
+ async check(ctx) {
244
+ const { maxBytes } = ruleOptions(ctx, ID6, { maxBytes: 500 * 1e3 });
245
+ const findings = [];
246
+ for (const image of ctx.images) {
247
+ if (image.bytes <= maxBytes || isCovered(image)) continue;
248
+ const saving = savingOf(image);
249
+ findings.push({
250
+ ruleId: ID6,
251
+ severity: "warn",
252
+ source: image.source,
253
+ message: `source exceeds the ${maxBytes}-byte limit`,
254
+ current: image.bytes,
255
+ ...image.estimate ? { estimated: image.estimate.best.bytes } : {},
256
+ ...saving !== void 0 ? { savingsBytes: saving } : {}
257
+ });
258
+ }
259
+ return findings;
260
+ }
261
+ };
262
+
263
+ // src/core/audit/rules/metadata.ts
264
+ var ID7 = "image/metadata";
265
+ var metadata = {
266
+ id: ID7,
267
+ category: "image",
268
+ defaultSeverity: "info",
269
+ fix: "optimize",
270
+ async check(ctx) {
271
+ const { minBytes } = ruleOptions(ctx, ID7, { minBytes: 1024 });
272
+ const findings = [];
273
+ for (const image of ctx.images) {
274
+ const m = image.analysis.metadata;
275
+ const bytes = m.exif + m.xmp + m.iptc;
276
+ if (bytes < minBytes && !m.gps || isCovered(image)) continue;
277
+ findings.push({
278
+ ruleId: ID7,
279
+ severity: "info",
280
+ source: image.source,
281
+ message: m.gps ? "carries embedded metadata including GPS location; outputs strip it" : "carries embedded metadata (EXIF/XMP/IPTC); outputs strip it",
282
+ current: image.bytes,
283
+ savingsBytes: bytes
284
+ });
285
+ }
286
+ return findings;
287
+ }
288
+ };
289
+
290
+ // src/core/audit/rules/no-modern-format.ts
291
+ var ID8 = "image/no-modern-format";
292
+ var noModernFormat = {
293
+ id: ID8,
294
+ category: "image",
295
+ defaultSeverity: "warn",
296
+ fix: "optimize",
297
+ async check(ctx) {
298
+ const { minSavingsBytes } = ruleOptions(ctx, ID8, { minSavingsBytes: 1024 });
299
+ const findings = [];
300
+ for (const image of ctx.images) {
301
+ const { format } = image.analysis;
302
+ if (format !== "jpeg" && format !== "png" && format !== "gif") continue;
303
+ if (isCovered(image)) continue;
304
+ const saving = savingOf(image);
305
+ if (saving === void 0 || saving < minSavingsBytes) continue;
306
+ findings.push({
307
+ ruleId: ID8,
308
+ severity: "warn",
309
+ source: image.source,
310
+ message: `${format.toUpperCase()} source has no up-to-date AVIF/WebP outputs`,
311
+ current: image.bytes,
312
+ estimated: image.estimate.best.bytes,
313
+ savingsBytes: saving
314
+ });
315
+ }
316
+ return findings;
317
+ }
318
+ };
319
+
320
+ // src/core/audit/rules/no-responsive-set.ts
321
+ var ID9 = "image/no-responsive-set";
322
+ var noResponsiveSet = {
323
+ id: ID9,
324
+ category: "image",
325
+ defaultSeverity: "info",
326
+ fix: "optimize",
327
+ async check(ctx) {
328
+ const { minWidth } = ruleOptions(ctx, ID9, { minWidth: 1e3 });
329
+ const findings = [];
330
+ for (const image of ctx.images) {
331
+ if (image.analysis.width < minWidth || isCovered(image)) continue;
332
+ const widths = new Set(image.item.outputs.map((o) => o.width));
333
+ if (widths.size < 2) continue;
334
+ findings.push({
335
+ ruleId: ID9,
336
+ severity: "info",
337
+ source: image.source,
338
+ message: `${image.analysis.width}px wide with no responsive set (${widths.size} widths would be generated)`,
339
+ current: image.bytes
340
+ });
341
+ }
342
+ return findings;
343
+ }
344
+ };
345
+
346
+ // src/core/audit/rules/orphaned-output.ts
347
+ var ID10 = "project/orphaned-output";
348
+ var orphanedOutput = {
349
+ id: ID10,
350
+ category: "project",
351
+ defaultSeverity: "info",
352
+ fix: null,
353
+ async check(ctx) {
354
+ const findings = [];
355
+ if (!ctx.manifest) return findings;
356
+ const planned = new Map(ctx.plan.items.map((i) => [i.source, i]));
357
+ for (const [source, entry] of Object.entries(ctx.manifest.sources)) {
358
+ if (!await ctx.sourceExists(source)) {
359
+ for (const out of entry.outputs) {
360
+ findings.push({
361
+ ruleId: ID10,
362
+ severity: "info",
363
+ source,
364
+ path: out.path,
365
+ message: "source no longer exists"
366
+ });
367
+ }
368
+ continue;
369
+ }
370
+ const item = planned.get(source);
371
+ if (!item || item.skip) continue;
372
+ const wanted = new Set(item.outputs.map((o) => o.path));
373
+ for (const out of entry.outputs) {
374
+ if (!wanted.has(out.path) && !wanted.has(out.search?.plannedPath ?? "")) {
375
+ findings.push({
376
+ ruleId: ID10,
377
+ severity: "info",
378
+ source,
379
+ path: out.path,
380
+ message: "no longer produced by the current preset"
381
+ });
382
+ }
383
+ }
384
+ }
385
+ for (const [source, entry] of Object.entries(ctx.manifest.videos)) {
386
+ if (!await ctx.sourceExists(source)) {
387
+ for (const out of entry.outputs) {
388
+ findings.push({
389
+ ruleId: ID10,
390
+ severity: "info",
391
+ source,
392
+ path: out.path,
393
+ message: "source no longer exists"
394
+ });
395
+ }
396
+ continue;
397
+ }
398
+ const item = (ctx.plan.videos ?? []).find((v) => v.source === source);
399
+ if (!item || item.skip) continue;
400
+ const wanted = new Set(item.outputs.map((o) => o.path));
401
+ for (const out of entry.outputs) {
402
+ if (!wanted.has(out.path)) {
403
+ findings.push({
404
+ ruleId: ID10,
405
+ severity: "info",
406
+ source,
407
+ path: out.path,
408
+ message: "no longer produced by the current preset"
409
+ });
410
+ }
411
+ }
412
+ }
413
+ return findings;
414
+ }
415
+ };
416
+
417
+ // src/core/audit/rules/over-budget.ts
418
+ var ID11 = "project/over-budget";
419
+ function servedBytes(image, ctx) {
420
+ const entry = ctx.manifest?.sources[image.source];
421
+ if (image.cache.kind === "hit" && entry) {
422
+ const written = entry.outputs.filter((o) => o.status === "written");
423
+ if (written.length > 0) {
424
+ const widest = Math.max(...written.map((o) => o.width));
425
+ return Math.min(...written.filter((o) => o.width === widest).map((o) => o.bytes));
426
+ }
427
+ }
428
+ return image.bytes;
429
+ }
430
+ var overBudget = {
431
+ id: ID11,
432
+ category: "project",
433
+ defaultSeverity: "error",
434
+ fix: null,
435
+ async check(ctx) {
436
+ const { maxFileBytes, maxTotalBytes } = ctx.budgets;
437
+ const findings = [];
438
+ let total = 0;
439
+ for (const image of ctx.images) {
440
+ const bytes = servedBytes(image, ctx);
441
+ total += bytes;
442
+ if (maxFileBytes !== void 0 && bytes > maxFileBytes) {
443
+ findings.push({
444
+ ruleId: ID11,
445
+ severity: "error",
446
+ source: image.source,
447
+ message: `served size is over the per-file budget of ${maxFileBytes} bytes`,
448
+ current: bytes
449
+ });
450
+ }
451
+ }
452
+ if (maxTotalBytes !== void 0 && total > maxTotalBytes) {
453
+ findings.push({
454
+ ruleId: ID11,
455
+ severity: "error",
456
+ message: `total served image size is over the budget of ${maxTotalBytes} bytes`,
457
+ current: total
458
+ });
459
+ }
460
+ return findings;
461
+ }
462
+ };
463
+
464
+ // src/core/audit/rules/oversized-dimensions.ts
465
+ var ID12 = "image/oversized-dimensions";
466
+ var oversizedDimensions = {
467
+ id: ID12,
468
+ category: "image",
469
+ defaultSeverity: "warn",
470
+ fix: "optimize",
471
+ async check(ctx) {
472
+ const { maxDimension } = ruleOptions(ctx, ID12, { maxDimension: 2560 });
473
+ const findings = [];
474
+ for (const image of ctx.images) {
475
+ const { width, height } = image.analysis;
476
+ if (Math.max(width, height) <= maxDimension || isCovered(image)) continue;
477
+ const cap = image.estimate?.best.width;
478
+ const saving = savingOf(image);
479
+ findings.push({
480
+ ruleId: ID12,
481
+ severity: "warn",
482
+ source: image.source,
483
+ message: `${width}x${height} exceeds ${maxDimension}px${cap ? `; outputs are capped at ${cap}w` : ""}`,
484
+ current: image.bytes,
485
+ ...image.estimate ? { estimated: image.estimate.best.bytes } : {},
486
+ ...saving !== void 0 ? { savingsBytes: saving } : {}
487
+ });
488
+ }
489
+ return findings;
490
+ }
491
+ };
492
+
493
+ // src/core/audit/rules/photo-as-png.ts
494
+ var ID13 = "image/photo-as-png";
495
+ var photoAsPng = {
496
+ id: ID13,
497
+ category: "image",
498
+ defaultSeverity: "warn",
499
+ fix: "optimize",
500
+ async check(ctx) {
501
+ const { minColors } = ruleOptions(ctx, ID13, { minColors: 128 });
502
+ const findings = [];
503
+ for (const image of ctx.images) {
504
+ const a = image.analysis;
505
+ if (a.format !== "png" || a.alphaUsed || isCovered(image)) continue;
506
+ if (image.classification.preset === "icon" || a.colorCount < minColors) continue;
507
+ const saving = savingOf(image);
508
+ findings.push({
509
+ ruleId: ID13,
510
+ severity: "warn",
511
+ source: image.source,
512
+ message: `photographic content stored as PNG (${a.colorCount} colours in sample, no transparency)`,
513
+ current: image.bytes,
514
+ ...image.estimate ? { estimated: image.estimate.best.bytes } : {},
515
+ ...saving !== void 0 ? { savingsBytes: saving } : {}
516
+ });
517
+ }
518
+ return findings;
519
+ }
520
+ };
521
+
522
+ // src/core/audit/rules/reencode-savings.ts
523
+ var ID14 = "image/reencode-savings";
524
+ var reencodeSavings = {
525
+ id: ID14,
526
+ category: "image",
527
+ defaultSeverity: "info",
528
+ fix: "optimize",
529
+ async check(ctx) {
530
+ const { minPercent, minBytes } = ruleOptions(ctx, ID14, { minPercent: 25, minBytes: 10 * 1024 });
531
+ const findings = [];
532
+ for (const image of ctx.images) {
533
+ const { format } = image.analysis;
534
+ if (format !== "webp" && format !== "avif" || isCovered(image)) continue;
535
+ const saving = savingOf(image);
536
+ if (saving === void 0 || saving < minBytes) continue;
537
+ if (saving / image.bytes * 100 < minPercent) continue;
538
+ findings.push({
539
+ ruleId: ID14,
540
+ severity: "info",
541
+ source: image.source,
542
+ message: `already ${format.toUpperCase()}, but re-encoding at the ${image.item.preset} preset saves a lot`,
543
+ current: image.bytes,
544
+ estimated: image.estimate.best.bytes,
545
+ savingsBytes: saving
546
+ });
547
+ }
548
+ return findings;
549
+ }
550
+ };
551
+
552
+ // src/core/manifest/cache.ts
553
+ function recordedByPlannedPath(entry) {
554
+ return new Map(entry.outputs.map((o) => [o.search?.plannedPath ?? o.path, o]));
555
+ }
556
+ function sameSearch(planned, r) {
557
+ const p = planned.search;
558
+ const q = r.search;
559
+ if (!p || !q) return !p && !q;
560
+ return p.metric === q.metric && p.target === q.target && p.floor === q.floor && p.maxBytes === q.maxBytes;
561
+ }
562
+ async function checkCache(item, entry, hashOutput) {
563
+ if (!entry) return { kind: "miss", reason: "new" };
564
+ if (entry.sha256 !== item.sourceSha256) return { kind: "miss", reason: "source-changed" };
565
+ if (entry.preset !== item.preset) return { kind: "miss", reason: "outputs-differ" };
566
+ const recorded = recordedByPlannedPath(entry);
567
+ for (const planned of item.outputs) {
568
+ const r = recorded.get(planned.path);
569
+ const searched = planned.search !== void 0;
570
+ if (!r || r.format !== planned.format || r.lossless !== planned.lossless || r.effort !== planned.effort || !sameSearch(planned, r) || (searched ? r.width > planned.width : r.width !== planned.width || r.quality !== planned.quality)) {
571
+ return { kind: "miss", reason: "outputs-differ" };
572
+ }
573
+ }
574
+ for (const planned of item.outputs) {
575
+ const r = recorded.get(planned.path);
576
+ if (r.status === "stale") return { kind: "miss", reason: "output-stale" };
577
+ if (r.status !== "written") continue;
578
+ const actual = await hashOutput(r.path);
579
+ if (actual === null) return { kind: "miss", reason: "output-missing" };
580
+ if (actual !== r.sha256) return { kind: "miss", reason: "output-modified" };
581
+ }
582
+ return { kind: "hit" };
583
+ }
584
+ function sameVideoParams(planned, r) {
585
+ if (planned.role !== r.role || planned.path !== r.path || planned.width !== r.width) return false;
586
+ if (planned.role === "video") {
587
+ const search = planned.search;
588
+ const sameSearch2 = search === void 0 ? r.search === void 0 && r.crf === planned.crf : r.search?.metric === search.metric && r.search.target === search.target;
589
+ return sameSearch2 && r.codec === planned.codec && r.speed === planned.speed && r.maxWidth === planned.maxWidth && r.maxFps === planned.maxFps && r.audio === planned.audio;
590
+ }
591
+ return r.quality === planned.quality && r.effort === planned.effort && r.at === planned.at;
592
+ }
593
+ async function checkVideoCache(item, entry, hashOutput, unavailable = /* @__PURE__ */ new Set()) {
594
+ if (!entry) return { kind: "miss", reason: "new" };
595
+ if (entry.sha256 !== item.sourceSha256) return { kind: "miss", reason: "source-changed" };
596
+ if (entry.preset !== item.preset) return { kind: "miss", reason: "outputs-differ" };
597
+ const recorded = new Map(entry.outputs.map((o) => [o.path, o]));
598
+ const wanted = item.outputs.filter((o) => !(o.role === "video" && unavailable.has(o.codec)));
599
+ for (const planned of wanted) {
600
+ const r = recorded.get(planned.path);
601
+ if (!r || !sameVideoParams(planned, r)) return { kind: "miss", reason: "outputs-differ" };
602
+ }
603
+ for (const planned of wanted) {
604
+ const r = recorded.get(planned.path);
605
+ if (r.status === "stale") return { kind: "miss", reason: "output-stale" };
606
+ if (r.status !== "written") continue;
607
+ const actual = await hashOutput(planned.path);
608
+ if (actual === null) return { kind: "miss", reason: "output-missing" };
609
+ if (actual !== r.sha256) return { kind: "miss", reason: "output-modified" };
610
+ }
611
+ return { kind: "hit" };
612
+ }
613
+
614
+ // src/core/audit/rules/stale-output.ts
615
+ var ID15 = "project/stale-output";
616
+ var REASONS = {
617
+ "source-changed": "source changed since the last run; will be regenerated",
618
+ "outputs-differ": "preset or widths differ from what was last generated; will be regenerated",
619
+ "output-missing": "an output file is missing; will be regenerated",
620
+ "output-modified": "an output file was modified after it was written; it is left alone unless you use --force",
621
+ "output-stale": "an output was left over from an older source (a run did not complete); will be regenerated"
622
+ };
623
+ var staleOutput = {
624
+ id: ID15,
625
+ category: "project",
626
+ defaultSeverity: "warn",
627
+ fix: "optimize",
628
+ async check(ctx) {
629
+ const findings = [];
630
+ if (!ctx.manifest) return findings;
631
+ for (const item of ctx.plan.items) {
632
+ if (item.skip) continue;
633
+ const entry = ctx.manifest.sources[item.source];
634
+ const state = await checkCache(item, entry, ctx.outputHash);
635
+ if (state.kind === "miss" && state.reason !== "new") {
636
+ findings.push({
637
+ ruleId: ID15,
638
+ severity: "warn",
639
+ source: item.source,
640
+ message: REASONS[state.reason]
641
+ });
642
+ continue;
643
+ }
644
+ for (const out of entry?.outputs ?? []) {
645
+ const current = ctx.encoders[out.format];
646
+ if (out.status === "written" && out.encoder !== current) {
647
+ findings.push({
648
+ ruleId: ID15,
649
+ severity: "warn",
650
+ source: item.source,
651
+ path: out.path,
652
+ message: `encoded with ${out.encoder}, installed is ${current}; run with --force to regenerate`
653
+ });
654
+ }
655
+ }
656
+ }
657
+ for (const item of ctx.plan.videos ?? []) {
658
+ if (item.skip) continue;
659
+ const entry = ctx.manifest.videos[item.source];
660
+ const state = await checkVideoCache(item, entry, ctx.outputHash, ctx.unavailableCodecs);
661
+ if (state.kind === "miss" && state.reason !== "new") {
662
+ findings.push({
663
+ ruleId: ID15,
664
+ severity: "warn",
665
+ source: item.source,
666
+ message: REASONS[state.reason]
667
+ });
668
+ continue;
669
+ }
670
+ for (const out of entry?.outputs ?? []) {
671
+ if (out.status !== "written" || out.role !== "video" || !out.codec) continue;
672
+ const current = ctx.videoEncoders?.[out.codec];
673
+ if (current && out.encoder !== current) {
674
+ findings.push({
675
+ ruleId: ID15,
676
+ severity: "warn",
677
+ source: item.source,
678
+ path: out.path,
679
+ message: `encoded with ${out.encoder}, installed is ${current}; run with --force to regenerate`
680
+ });
681
+ }
682
+ }
683
+ }
684
+ return findings;
685
+ }
686
+ };
687
+
688
+ // src/core/audit/rules/video-audio-in-background.ts
689
+ var ID16 = "video/audio-in-background";
690
+ var videoAudioInBackground = {
691
+ id: ID16,
692
+ category: "video",
693
+ defaultSeverity: "warn",
694
+ fix: "optimize",
695
+ async check(ctx) {
696
+ const findings = [];
697
+ for (const video of ctx.videos) {
698
+ if (!video.probe.hasAudio || video.classification.preset !== "background-video") continue;
699
+ if (isVideoCovered(video)) continue;
700
+ findings.push({
701
+ ruleId: ID16,
702
+ severity: "warn",
703
+ source: video.source,
704
+ message: "looks like a background video but carries an audio track; outputs drop it",
705
+ current: video.bytes
706
+ });
707
+ }
708
+ return findings;
709
+ }
710
+ };
711
+
712
+ // src/core/audit/rules/video-high-bitrate.ts
713
+ var ID17 = "video/high-bitrate";
714
+ var videoHighBitrate = {
715
+ id: ID17,
716
+ category: "video",
717
+ defaultSeverity: "warn",
718
+ fix: "optimize",
719
+ async check(ctx) {
720
+ const { maxBitsPerPixel, minBytes } = ruleOptions(ctx, ID17, {
721
+ maxBitsPerPixel: 0.2,
722
+ minBytes: 200 * 1024
723
+ });
724
+ const findings = [];
725
+ for (const video of ctx.videos) {
726
+ const { bitrate, width, height, fps } = video.probe;
727
+ if (bitrate === null || width * height * fps === 0) continue;
728
+ if (video.bytes < minBytes || isVideoCovered(video)) continue;
729
+ const bpp = bitrate / (width * height * fps);
730
+ if (bpp <= maxBitsPerPixel) continue;
731
+ const saving = videoSaving(video);
732
+ findings.push({
733
+ ruleId: ID17,
734
+ severity: "warn",
735
+ source: video.source,
736
+ message: `${bpp.toFixed(2)} bits per pixel per frame (over ${maxBitsPerPixel})`,
737
+ current: video.bytes,
738
+ ...video.estimate ? { estimated: video.estimate.best.bytes } : {},
739
+ ...saving !== void 0 ? { savingsBytes: saving } : {}
740
+ });
741
+ }
742
+ return findings;
743
+ }
744
+ };
745
+
746
+ // src/core/audit/rules/video-high-fps.ts
747
+ var ID18 = "video/high-fps";
748
+ var videoHighFps = {
749
+ id: ID18,
750
+ category: "video",
751
+ defaultSeverity: "warn",
752
+ fix: "optimize",
753
+ async check(ctx) {
754
+ const { maxFps } = ruleOptions(ctx, ID18, { maxFps: 30 });
755
+ const findings = [];
756
+ for (const video of ctx.videos) {
757
+ if (video.probe.fps <= maxFps + 0.01 || isVideoCovered(video)) continue;
758
+ const capped = video.classification.preset === "background-video";
759
+ findings.push({
760
+ ruleId: ID18,
761
+ severity: capped ? "warn" : "info",
762
+ source: video.source,
763
+ message: capped ? `${Math.round(video.probe.fps)} fps is more than a backdrop needs; outputs are capped at ${maxFps}` : `${Math.round(video.probe.fps)} fps; kept as-is because it looks like content`,
764
+ current: video.bytes
765
+ });
766
+ }
767
+ return findings;
768
+ }
769
+ };
770
+
771
+ // src/core/audit/rules/video-no-fallback.ts
772
+ var ID19 = "video/no-fallback";
773
+ var videoNoFallback = {
774
+ id: ID19,
775
+ category: "video",
776
+ defaultSeverity: "warn",
777
+ fix: "optimize",
778
+ async check(ctx) {
779
+ const findings = [];
780
+ for (const video of ctx.videos) {
781
+ if (video.isGif || UNIVERSAL_CODECS.has(video.probe.videoCodec)) continue;
782
+ if (isVideoCovered(video)) continue;
783
+ findings.push({
784
+ ruleId: ID19,
785
+ severity: "warn",
786
+ source: video.source,
787
+ message: `${codecLabel(video.probe.videoCodec)} is not playable everywhere and there is no H.264 MP4 fallback`,
788
+ current: video.bytes
789
+ });
790
+ }
791
+ return findings;
792
+ }
793
+ };
794
+
795
+ // src/core/audit/rules/video-no-faststart.ts
796
+ var ID20 = "video/no-faststart";
797
+ var videoNoFaststart = {
798
+ id: ID20,
799
+ category: "video",
800
+ defaultSeverity: "warn",
801
+ fix: "optimize",
802
+ async check(ctx) {
803
+ const findings = [];
804
+ for (const video of ctx.videos) {
805
+ if (video.faststart !== false || isVideoCovered(video)) continue;
806
+ findings.push({
807
+ ruleId: ID20,
808
+ severity: "warn",
809
+ source: video.source,
810
+ message: "moov atom is after the media data, so playback waits for the whole file; outputs are faststart",
811
+ current: video.bytes
812
+ });
813
+ }
814
+ return findings;
815
+ }
816
+ };
817
+
818
+ // src/core/audit/rules/video-no-modern-codec.ts
819
+ var ID21 = "video/no-modern-codec";
820
+ var videoNoModernCodec = {
821
+ id: ID21,
822
+ category: "video",
823
+ defaultSeverity: "warn",
824
+ fix: "optimize",
825
+ async check(ctx) {
826
+ const { minSavingsBytes } = ruleOptions(ctx, ID21, { minSavingsBytes: 10 * 1024 });
827
+ const findings = [];
828
+ for (const video of ctx.videos) {
829
+ if (video.isGif) continue;
830
+ if (MODERN_CODECS.has(video.probe.videoCodec) || isVideoCovered(video)) continue;
831
+ const saving = videoSaving(video);
832
+ if (saving !== void 0 && saving < minSavingsBytes) continue;
833
+ findings.push({
834
+ ruleId: ID21,
835
+ severity: "warn",
836
+ source: video.source,
837
+ message: `${codecLabel(video.probe.videoCodec)} source has no AV1 output`,
838
+ current: video.bytes,
839
+ ...video.estimate ? { estimated: video.estimate.best.bytes } : {},
840
+ ...saving !== void 0 ? { savingsBytes: saving } : {}
841
+ });
842
+ }
843
+ return findings;
844
+ }
845
+ };
846
+
847
+ // src/core/audit/rules/video-no-poster.ts
848
+ var ID22 = "video/no-poster";
849
+ var videoNoPoster = {
850
+ id: ID22,
851
+ category: "video",
852
+ defaultSeverity: "info",
853
+ fix: "optimize",
854
+ async check(ctx) {
855
+ const findings = [];
856
+ for (const video of ctx.videos) {
857
+ if (video.hasPoster || isVideoCovered(video)) continue;
858
+ findings.push({
859
+ ruleId: ID22,
860
+ severity: "info",
861
+ source: video.source,
862
+ message: "no poster image, so nothing shows before the first frame loads; one is generated",
863
+ current: video.bytes
864
+ });
865
+ }
866
+ return findings;
867
+ }
868
+ };
869
+
870
+ // src/core/audit/rules/video-oversized-resolution.ts
871
+ var ID23 = "video/oversized-resolution";
872
+ var videoOversizedResolution = {
873
+ id: ID23,
874
+ category: "video",
875
+ defaultSeverity: "warn",
876
+ fix: "optimize",
877
+ async check(ctx) {
878
+ const { maxWidth } = ruleOptions(ctx, ID23, { maxWidth: 1920 });
879
+ const findings = [];
880
+ for (const video of ctx.videos) {
881
+ const { width, height } = video.probe;
882
+ if (Math.max(width, height) <= maxWidth || isVideoCovered(video)) continue;
883
+ const capped = video.classification.preset !== "content-video";
884
+ const saving = videoSaving(video);
885
+ findings.push({
886
+ ruleId: ID23,
887
+ severity: capped ? "warn" : "info",
888
+ source: video.source,
889
+ message: capped ? `${width}x${height} exceeds ${maxWidth}px; outputs are capped` : `${width}x${height} exceeds ${maxWidth}px; kept as-is because it looks like content (use the background-video preset to cap it)`,
890
+ current: video.bytes,
891
+ ...video.estimate ? { estimated: video.estimate.best.bytes } : {},
892
+ ...saving !== void 0 ? { savingsBytes: saving } : {}
893
+ });
894
+ }
895
+ return findings;
896
+ }
897
+ };
898
+
899
+ // src/core/audit/rules/unminified.ts
900
+ var ID24 = "svg/unminified";
901
+ var svgUnminified = {
902
+ id: ID24,
903
+ category: "svg",
904
+ defaultSeverity: "warn",
905
+ fix: null,
906
+ async check(ctx) {
907
+ const { minPercent, minBytes } = ruleOptions(ctx, ID24, { minPercent: 10, minBytes: 256 });
908
+ const findings = [];
909
+ for (const svg of ctx.svgs) {
910
+ if (svg.minifiedBytes === null) continue;
911
+ const saving = svg.bytes - svg.minifiedBytes;
912
+ if (saving < minBytes || saving / svg.bytes * 100 < minPercent) continue;
913
+ findings.push({
914
+ ruleId: ID24,
915
+ severity: "warn",
916
+ source: svg.source,
917
+ message: "contains comments, metadata or whitespace that svgo removes",
918
+ current: svg.bytes,
919
+ estimated: svg.minifiedBytes,
920
+ savingsBytes: saving
921
+ });
922
+ }
923
+ return findings;
924
+ }
925
+ };
926
+
927
+ // src/core/audit/rules/unreferenced.ts
928
+ var ID25 = "project/unreferenced";
929
+ var unreferenced = {
930
+ id: ID25,
931
+ category: "project",
932
+ defaultSeverity: "info",
933
+ fix: null,
934
+ async check(ctx) {
935
+ if (!ctx.references) return [];
936
+ const refs = ctx.references;
937
+ const findings = [];
938
+ const files = [
939
+ ...ctx.images.map((i) => ({ source: i.source, bytes: i.bytes })),
940
+ ...ctx.skipped.map((s) => ({ source: s.source, bytes: s.bytes })),
941
+ ...ctx.svgs.map((s) => ({ source: s.source, bytes: s.bytes }))
942
+ ];
943
+ for (const file of files) {
944
+ const name = file.source.split("/").pop().toLowerCase();
945
+ if (refs.has(name)) continue;
946
+ findings.push({
947
+ ruleId: ID25,
948
+ severity: "info",
949
+ source: file.source,
950
+ message: "no reference to this file name found in project source files",
951
+ current: file.bytes,
952
+ savingsBytes: file.bytes
953
+ });
954
+ }
955
+ return findings;
956
+ }
957
+ };
958
+
959
+ // src/core/audit/rules/index.ts
960
+ var PROJECT_RULES = [staleOutput, orphanedOutput];
961
+ var ALL_RULES = [
962
+ noModernFormat,
963
+ oversizedDimensions,
964
+ heavy,
965
+ photoAsPng,
966
+ reencodeSavings,
967
+ metadata,
968
+ noResponsiveSet,
969
+ cmykOr16bit,
970
+ svgUnminified,
971
+ svgEmbeddedRaster,
972
+ gifAnimated,
973
+ gifStatic,
974
+ videoNoModernCodec,
975
+ videoNoFallback,
976
+ videoOversizedResolution,
977
+ videoHighFps,
978
+ videoHighBitrate,
979
+ videoAudioInBackground,
980
+ videoNoFaststart,
981
+ videoNoPoster,
982
+ duplicate,
983
+ unreferenced,
984
+ overBudget,
985
+ staleOutput,
986
+ orphanedOutput
987
+ ];
988
+
989
+ // src/core/config/schema.ts
990
+ var M9 = "arrives with perceptual quality search in M9; use `target` for now";
991
+ var size = z.union([z.number().int().nonnegative(), z.string()]).transform((value, ctx) => {
992
+ if (typeof value === "number") return value;
993
+ const bytes = parseSize(value);
994
+ if (bytes === null) {
995
+ ctx.addIssue({ code: "custom", message: `"${value}" is not a size (try "250 KB" or 262144)` });
996
+ return z.NEVER;
997
+ }
998
+ return bytes;
999
+ });
1000
+ var severity = z.enum(["error", "warn", "info", "off"]);
1001
+ var ruleSetting = z.union([severity, z.tuple([severity, z.record(z.string(), z.unknown())])]);
1002
+ var quality = z.number().int().min(1).max(100);
1003
+ var TARGETS = ["low", "balanced", "high", "lossless"];
1004
+ var PLACEHOLDERS = ["dominant-color", "lqip", "blurhash", "none"];
1005
+ var FRAMEWORKS = ["next", "astro", "vite", "static"];
1006
+ var rejected = (what, why = M9) => z.unknown().optional().superRefine((value, ctx) => {
1007
+ if (value !== void 0) ctx.addIssue({ code: "custom", message: `${what} ${why}` });
1008
+ });
1009
+ var qualityByFormat = z.object({ avif: quality.optional(), webp: quality.optional() }).strict();
1010
+ var effortByFormat = z.object({
1011
+ avif: z.number().int().min(0).max(9).optional(),
1012
+ webp: z.number().int().min(0).max(6).optional()
1013
+ }).strict();
1014
+ var crfByCodec = z.object({
1015
+ av1: z.number().int().min(0).max(63).optional(),
1016
+ h264: z.number().int().min(0).max(51).optional()
1017
+ }).strict();
1018
+ var presetSchema = z.object({
1019
+ extends: z.string().min(1).optional(),
1020
+ target: z.enum(TARGETS).optional(),
1021
+ // images
1022
+ widths: z.array(z.number().int().min(16).max(16384)).min(1).optional(),
1023
+ formats: z.array(z.enum(["avif", "webp"])).min(1).optional(),
1024
+ quality: qualityByFormat.optional(),
1025
+ effort: effortByFormat.optional(),
1026
+ lossless: z.boolean().optional(),
1027
+ priority: z.boolean().optional(),
1028
+ // video
1029
+ audio: z.enum(["keep", "strip"]).optional(),
1030
+ maxWidth: z.number().int().min(16).nullable().optional(),
1031
+ maxFps: z.number().int().min(1).nullable().optional(),
1032
+ codecs: z.array(z.enum(["av1", "h264"])).min(1).optional(),
1033
+ crf: crfByCodec.optional(),
1034
+ // perceptual search: images and video (`floor` and `maxSize` are image-only)
1035
+ ssimulacra2: z.number().min(1).max(100).optional(),
1036
+ vmaf: z.number().min(1).max(100).optional(),
1037
+ floor: z.number().min(1).max(100).optional(),
1038
+ maxSize: size.optional(),
1039
+ // reserved
1040
+ raw: rejected("`raw`", "is not supported; use `target`, `quality` or `crf`")
1041
+ }).strict().superRefine((preset, ctx) => {
1042
+ const say = (path, message) => ctx.addIssue({ code: "custom", path: [path], message });
1043
+ if (preset.vmaf !== void 0 && preset.ssimulacra2 !== void 0) {
1044
+ say("vmaf", "cannot be combined with `ssimulacra2`: a preset searches for one metric");
1045
+ }
1046
+ if (preset.floor !== void 0 && preset.ssimulacra2 === void 0) {
1047
+ say(
1048
+ "floor",
1049
+ "needs `ssimulacra2`: it is the lowest score allowed when `maxSize` forces quality down"
1050
+ );
1051
+ }
1052
+ if (preset.maxSize !== void 0 && preset.ssimulacra2 === void 0) {
1053
+ say(
1054
+ "maxSize",
1055
+ "needs `ssimulacra2`, so there is a quality target to hold while fitting the size"
1056
+ );
1057
+ }
1058
+ if (preset.maxSize !== void 0 && preset.floor === void 0) {
1059
+ say(
1060
+ "floor",
1061
+ "is required with `maxSize`: how far quality may drop to fit is a tradeoff to state, not a default"
1062
+ );
1063
+ }
1064
+ if (preset.floor !== void 0 && preset.ssimulacra2 !== void 0 && preset.floor > preset.ssimulacra2) {
1065
+ say("floor", `must not exceed \`ssimulacra2\` (${preset.ssimulacra2})`);
1066
+ }
1067
+ if (preset.floor !== void 0 && preset.maxSize === void 0 && preset.ssimulacra2 !== void 0) {
1068
+ say("floor", "only applies with `maxSize`");
1069
+ }
1070
+ });
1071
+ var knownRuleIds = new Set(ALL_RULES.map((rule) => rule.id));
1072
+ var auditSchema = z.object({
1073
+ rules: z.record(z.string(), ruleSetting).superRefine((rules, ctx) => {
1074
+ for (const id of Object.keys(rules)) {
1075
+ if (!knownRuleIds.has(id)) {
1076
+ ctx.addIssue({
1077
+ code: "custom",
1078
+ path: [id],
1079
+ message: `unknown rule${hint(id, [...knownRuleIds])}`
1080
+ });
1081
+ }
1082
+ }
1083
+ }).optional(),
1084
+ ignore: z.array(z.string().min(1)).optional()
1085
+ }).strict();
1086
+ var budgetsSchema = z.object({ maxFileSize: size.optional(), maxTotalSize: size.optional() }).strict();
1087
+ var manifestSchema = z.object({ placeholder: z.enum(PLACEHOLDERS).optional() }).strict();
1088
+ var configSchema = z.object({
1089
+ $schema: z.string().optional(),
1090
+ include: z.array(z.string().min(1)).optional(),
1091
+ exclude: z.array(z.string().min(1)).optional(),
1092
+ outDir: z.string().min(1).optional(),
1093
+ publicPath: z.string().regex(/^(\/|https?:\/\/)/, 'must start with "/" or be an http(s) URL').optional(),
1094
+ framework: z.enum(FRAMEWORKS).optional(),
1095
+ assets: z.record(z.string().min(1), z.string().min(1)).optional(),
1096
+ presets: z.record(z.string().min(1), presetSchema).optional(),
1097
+ audit: auditSchema.optional(),
1098
+ budgets: budgetsSchema.optional(),
1099
+ manifest: manifestSchema.optional(),
1100
+ concurrency: z.number().int().min(1).max(16).optional(),
1101
+ ffmpegPath: z.string().min(1).optional(),
1102
+ ffprobePath: z.string().min(1).optional(),
1103
+ ssimulacra2Path: z.string().min(1).optional()
1104
+ }).strict();
1105
+ function distance(a, b) {
1106
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
1107
+ for (let i = 1; i <= a.length; i++) {
1108
+ let previous = row[0];
1109
+ row[0] = i;
1110
+ for (let j = 1; j <= b.length; j++) {
1111
+ const current = row[j];
1112
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
1113
+ previous = current;
1114
+ }
1115
+ }
1116
+ return row[b.length];
1117
+ }
1118
+ function hint(wrong, candidates) {
1119
+ let best;
1120
+ let bestDistance = Infinity;
1121
+ for (const candidate of candidates) {
1122
+ const d = distance(wrong.toLowerCase(), candidate.toLowerCase());
1123
+ if (d < bestDistance) [best, bestDistance] = [candidate, d];
1124
+ }
1125
+ return best !== void 0 && bestDistance <= Math.max(2, Math.floor(wrong.length / 3)) ? ` (did you mean "${best}"?)` : "";
1126
+ }
1127
+ function keysAt(path) {
1128
+ const p = path.map(String);
1129
+ if (p.length === 0) return Object.keys(configSchema.shape);
1130
+ if (p[0] === "presets") {
1131
+ if (p.length === 2) return Object.keys(presetSchema.shape);
1132
+ if (p[2] === "quality") return Object.keys(qualityByFormat.shape);
1133
+ if (p[2] === "effort") return Object.keys(effortByFormat.shape);
1134
+ if (p[2] === "crf") return Object.keys(crfByCodec.shape);
1135
+ }
1136
+ if (p[0] === "audit" && p.length === 1) return Object.keys(auditSchema.shape);
1137
+ if (p[0] === "budgets") return Object.keys(budgetsSchema.shape);
1138
+ if (p[0] === "manifest") return Object.keys(manifestSchema.shape);
1139
+ return [];
1140
+ }
1141
+ function describePath(path) {
1142
+ return path.length === 0 ? "(config)" : path.map(String).join(".");
1143
+ }
1144
+ function formatConfigIssues(issues) {
1145
+ const lines = [];
1146
+ for (const issue of issues) {
1147
+ if (issue.code === "unrecognized_keys") {
1148
+ for (const key of issue.keys) {
1149
+ const known = keysAt(issue.path);
1150
+ lines.push(
1151
+ `${describePath(issue.path)}: unknown key "${key}"${hint(key, known)}${known.length ? ` \u2014 valid keys: ${known.join(", ")}` : ""}`
1152
+ );
1153
+ }
1154
+ } else if (issue.code === "invalid_value" && issue.values.length > 0) {
1155
+ lines.push(
1156
+ `${describePath(issue.path)}: must be one of ${issue.values.map((v) => JSON.stringify(v)).join(", ")}`
1157
+ );
1158
+ } else {
1159
+ lines.push(`${describePath(issue.path)}: ${issue.message}`);
1160
+ }
1161
+ }
1162
+ return lines;
1163
+ }
1164
+ function parseConfig(raw, fileName) {
1165
+ const result = configSchema.safeParse(raw);
1166
+ if (result.success) return result.data;
1167
+ const lines = formatConfigIssues(result.error.issues);
1168
+ throw new BytecutError(
1169
+ "CONFIG_INVALID",
1170
+ `${fileName} is not a valid bytecut config:
1171
+ ${lines.map((l) => ` - ${l}`).join("\n")}`
1172
+ );
1173
+ }
1174
+
1175
+ // src/core/config/load.ts
1176
+ import { readFile, stat } from "fs/promises";
1177
+ import { basename, extname as extname2, relative, resolve } from "path";
1178
+ import { pathToFileURL } from "url";
1179
+ import { parse as parseToml } from "smol-toml";
1180
+
1181
+ // src/core/config/files.ts
1182
+ var CONFIG_FILES = [
1183
+ "bytecut.config.ts",
1184
+ "bytecut.config.mts",
1185
+ "bytecut.config.js",
1186
+ "bytecut.config.mjs",
1187
+ "bytecut.config.json",
1188
+ "bytecut.config.toml"
1189
+ ];
1190
+
1191
+ // src/core/config/load.ts
1192
+ var formatOf = (file) => {
1193
+ const ext = extname2(file).toLowerCase();
1194
+ if (ext === ".json") return "json";
1195
+ if (ext === ".toml") return "toml";
1196
+ return ext === ".ts" || ext === ".mts" ? "ts" : "js";
1197
+ };
1198
+ var invalid = (name, message, cause) => new BytecutError("CONFIG_INVALID", `${name}: ${message}`, cause ? { cause } : void 0);
1199
+ async function importConfig(file, name) {
1200
+ const original = process.emitWarning;
1201
+ process.emitWarning = ((warning, ...rest) => {
1202
+ const text = typeof warning === "string" ? warning : warning.message;
1203
+ if (/type stripping/i.test(text)) return;
1204
+ return original.call(process, warning, ...rest);
1205
+ });
1206
+ try {
1207
+ const mod = await import(pathToFileURL(file).href);
1208
+ let value = mod.default;
1209
+ if (typeof value === "function") value = await value();
1210
+ if (value === void 0 || value === null || typeof value !== "object") {
1211
+ throw invalid(name, "must `export default` a config object");
1212
+ }
1213
+ return value;
1214
+ } catch (error) {
1215
+ if (error instanceof BytecutError) throw error;
1216
+ const code = error.code;
1217
+ const message = error instanceof Error ? error.message : String(error);
1218
+ if (code === "ERR_UNKNOWN_FILE_EXTENSION") {
1219
+ throw invalid(
1220
+ name,
1221
+ `this Node (${process.version}) cannot load TypeScript files. Use Node 22.18 or newer, or write the config as bytecut.config.json.`,
1222
+ error
1223
+ );
1224
+ }
1225
+ if (code === "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX") {
1226
+ throw invalid(
1227
+ name,
1228
+ `uses TypeScript syntax Node cannot strip (${message}). Keep the config a plain object.`,
1229
+ error
1230
+ );
1231
+ }
1232
+ throw invalid(name, `could not be loaded: ${message}`, error);
1233
+ } finally {
1234
+ process.emitWarning = original;
1235
+ }
1236
+ }
1237
+ async function readRaw(file, name, format) {
1238
+ if (format === "ts" || format === "js") return importConfig(file, name);
1239
+ const text = await readFile(file, "utf8");
1240
+ try {
1241
+ return format === "json" ? JSON.parse(text) : parseToml(text);
1242
+ } catch (error) {
1243
+ const message = error instanceof Error ? error.message : String(error);
1244
+ throw invalid(name, `is not valid ${format === "json" ? "JSON" : "TOML"}: ${message}`, error);
1245
+ }
1246
+ }
1247
+ async function findConfigFiles(projectRoot) {
1248
+ const found = [];
1249
+ for (const name of CONFIG_FILES) {
1250
+ const info = await stat(resolve(projectRoot, name)).catch(() => null);
1251
+ if (info?.isFile()) found.push(name);
1252
+ }
1253
+ return found;
1254
+ }
1255
+ async function loadConfig(projectRoot, explicit) {
1256
+ let file;
1257
+ if (explicit) {
1258
+ file = resolve(projectRoot, explicit);
1259
+ const info = await stat(file).catch(() => null);
1260
+ if (!info?.isFile()) {
1261
+ throw new BytecutError("INPUT_NOT_FOUND", `No such config file: ${explicit}`);
1262
+ }
1263
+ } else {
1264
+ const found = await findConfigFiles(projectRoot);
1265
+ if (found.length === 0) return null;
1266
+ if (found.length > 1) {
1267
+ throw new BytecutError(
1268
+ "CONFIG_INVALID",
1269
+ `More than one config file found (${found.join(", ")}). Keep one, or pick with --config.`
1270
+ );
1271
+ }
1272
+ file = resolve(projectRoot, found[0]);
1273
+ }
1274
+ const name = relative(projectRoot, file) || basename(file);
1275
+ const format = formatOf(file);
1276
+ const raw = await readRaw(file, name, format);
1277
+ return { file, name, format, config: parseConfig(raw, name), raw };
1278
+ }
1279
+
1280
+ // src/core/scan/detect.ts
1281
+ import { readFile as readFile2, readdir, stat as stat2 } from "fs/promises";
1282
+ import { join } from "path";
1283
+ var FRAMEWORK_CONFIG = [
1284
+ ["next", /^next\.config\.(js|mjs|cjs|ts)$/],
1285
+ ["astro", /^astro\.config\.(js|mjs|cjs|ts)$/],
1286
+ ["vite", /^vite\.config\.(js|mjs|cjs|ts|mts|cts)$/]
1287
+ ];
1288
+ var FRAMEWORK_DEP = [
1289
+ ["next", "next"],
1290
+ ["astro", "astro"],
1291
+ ["vite", "vite"]
1292
+ ];
1293
+ async function isDirectory(path) {
1294
+ return (await stat2(path).catch(() => null))?.isDirectory() ?? false;
1295
+ }
1296
+ async function readDependencies(root) {
1297
+ try {
1298
+ const pkg = JSON.parse(await readFile2(join(root, "package.json"), "utf8"));
1299
+ const names = /* @__PURE__ */ new Set();
1300
+ for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
1301
+ const section = pkg[key];
1302
+ if (section && typeof section === "object") {
1303
+ for (const name of Object.keys(section)) names.add(name);
1304
+ }
1305
+ }
1306
+ return names;
1307
+ } catch {
1308
+ return /* @__PURE__ */ new Set();
1309
+ }
1310
+ }
1311
+ async function detectProject(root, options = {}) {
1312
+ const deps = await readDependencies(root);
1313
+ const files = await readdir(root).catch(() => []);
1314
+ let framework = "static";
1315
+ for (const [candidate, dep] of FRAMEWORK_DEP) {
1316
+ const byConfig = FRAMEWORK_CONFIG.some(
1317
+ ([name, re]) => name === candidate && files.some((f) => re.test(f))
1318
+ );
1319
+ if (deps.has(dep) || byConfig) {
1320
+ framework = candidate;
1321
+ break;
1322
+ }
1323
+ }
1324
+ if (options.framework) framework = options.framework;
1325
+ const react = deps.has("react");
1326
+ const hasPublic = await isDirectory(join(root, "public"));
1327
+ if (framework !== "static" || hasPublic) {
1328
+ return {
1329
+ framework,
1330
+ react,
1331
+ include: ["public"],
1332
+ outDir: "public/_bytecut",
1333
+ publicPath: "/_bytecut",
1334
+ staticRoot: "public"
1335
+ };
1336
+ }
1337
+ return {
1338
+ framework,
1339
+ react,
1340
+ include: ["."],
1341
+ outDir: "_bytecut",
1342
+ publicPath: "/_bytecut",
1343
+ staticRoot: "."
1344
+ };
1345
+ }
1346
+
1347
+ // src/core/config/glob.ts
1348
+ var SPECIAL = /[\\^$.|+()]/;
1349
+ function bracesPair(pattern) {
1350
+ let depth = 0;
1351
+ for (let i = 0; i < pattern.length; i++) {
1352
+ if (pattern[i] === "\\") i++;
1353
+ else if (pattern[i] === "{") depth++;
1354
+ else if (pattern[i] === "}" && --depth < 0) return false;
1355
+ }
1356
+ return depth === 0;
1357
+ }
1358
+ function translate(pattern) {
1359
+ let out = "";
1360
+ let braceDepth = 0;
1361
+ const alternation = bracesPair(pattern);
1362
+ for (let i = 0; i < pattern.length; i++) {
1363
+ const ch = pattern[i];
1364
+ if (ch === "\\") {
1365
+ const next = pattern[++i];
1366
+ if (next !== void 0) out += next.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
1367
+ } else if (ch === "*") {
1368
+ if (pattern[i + 1] === "*") {
1369
+ i++;
1370
+ if (pattern[i + 1] === "/") {
1371
+ i++;
1372
+ out += "(?:.*/)?";
1373
+ } else out += ".*";
1374
+ } else out += "[^/]*";
1375
+ } else if (ch === "?") out += "[^/]";
1376
+ else if (ch === "{" && alternation) {
1377
+ braceDepth++;
1378
+ out += "(?:";
1379
+ } else if (ch === "}" && alternation) {
1380
+ braceDepth--;
1381
+ out += ")";
1382
+ } else if (ch === "," && braceDepth > 0) out += "|";
1383
+ else if (ch === "[") {
1384
+ const end = pattern.indexOf("]", i + 2);
1385
+ if (end === -1) out += "\\[";
1386
+ else {
1387
+ let body = pattern.slice(i + 1, end);
1388
+ if (body.startsWith("!")) body = `^${body.slice(1)}`;
1389
+ out += `[${body.replace(/\\/g, "\\\\")}]`;
1390
+ i = end;
1391
+ }
1392
+ } else if (SPECIAL.test(ch) || ch === "{" || ch === "}") out += `\\${ch}`;
1393
+ else out += ch;
1394
+ }
1395
+ return out;
1396
+ }
1397
+ function hasSlash(pattern) {
1398
+ return pattern.replace(/\\./g, "").includes("/");
1399
+ }
1400
+ function globToRegExp(pattern) {
1401
+ const body = translate(pattern.replace(/^\.\//, ""));
1402
+ return new RegExp(hasSlash(pattern) ? `^${body}$` : `^(?:.*/)?${body}$`);
1403
+ }
1404
+ function matchesGlob(pattern, path) {
1405
+ return globToRegExp(pattern).test(path);
1406
+ }
1407
+ function escapeGlob(path) {
1408
+ return path.replace(/[\\*?[\]{}]/g, "\\$&");
1409
+ }
1410
+
1411
+ export {
1412
+ BytecutError,
1413
+ savingOf,
1414
+ recordedByPlannedPath,
1415
+ checkCache,
1416
+ checkVideoCache,
1417
+ PROJECT_RULES,
1418
+ ALL_RULES,
1419
+ CONFIG_FILES,
1420
+ matchesGlob,
1421
+ escapeGlob,
1422
+ formatBytes,
1423
+ percentChange,
1424
+ plural,
1425
+ PLACEHOLDERS,
1426
+ FRAMEWORKS,
1427
+ hint,
1428
+ parseConfig,
1429
+ findConfigFiles,
1430
+ loadConfig,
1431
+ detectProject
1432
+ };