@t09tanaka/stoneage 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +768 -0
  4. package/dist/agent-skill.d.ts +1 -0
  5. package/dist/agent-skill.js +140 -0
  6. package/dist/assets.d.ts +125 -0
  7. package/dist/assets.js +341 -0
  8. package/dist/cli.d.ts +236 -0
  9. package/dist/cli.js +3077 -0
  10. package/dist/core.d.ts +473 -0
  11. package/dist/core.js +2897 -0
  12. package/dist/data.d.ts +121 -0
  13. package/dist/data.js +358 -0
  14. package/dist/deploy.d.ts +34 -0
  15. package/dist/deploy.js +203 -0
  16. package/dist/dev.d.ts +36 -0
  17. package/dist/dev.js +313 -0
  18. package/dist/example.d.ts +134 -0
  19. package/dist/example.js +1272 -0
  20. package/dist/fragment/client.d.ts +13 -0
  21. package/dist/fragment/client.js +150 -0
  22. package/dist/fragment.d.ts +15 -0
  23. package/dist/fragment.js +27 -0
  24. package/dist/html.d.ts +57 -0
  25. package/dist/html.js +208 -0
  26. package/dist/images.d.ts +95 -0
  27. package/dist/images.js +292 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.js +1 -0
  30. package/dist/migration.d.ts +157 -0
  31. package/dist/migration.js +983 -0
  32. package/dist/optimize.d.ts +15 -0
  33. package/dist/optimize.js +215 -0
  34. package/dist/pagination.d.ts +26 -0
  35. package/dist/pagination.js +62 -0
  36. package/dist/paths.d.ts +1 -0
  37. package/dist/paths.js +10 -0
  38. package/dist/search.d.ts +32 -0
  39. package/dist/search.js +55 -0
  40. package/dist/testing.d.ts +66 -0
  41. package/dist/testing.js +97 -0
  42. package/dist/validate.d.ts +2 -0
  43. package/dist/validate.js +1 -0
  44. package/jsx-loader.mjs +52 -0
  45. package/jsx-register.mjs +7 -0
  46. package/package.json +135 -0
  47. package/tsconfig.base.json +16 -0
package/dist/cli.js ADDED
@@ -0,0 +1,3077 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { realpathSync } from "node:fs";
5
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
6
+ import { createRequire } from "node:module";
7
+ import { dirname, join, posix, relative, resolve } from "node:path";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import { loadValidationConfig, notFoundOutputPath, notFoundRouteName, } from "./core.js";
10
+ import { benchmarkExampleSite } from "./example.js";
11
+ import { renderNetlifyHeaders, renderNetlifyRedirects, writeDeployArtifacts, } from "./deploy.js";
12
+ import { auditSvelteKitPublicSurface, emitSvelteKitMigrationStubs } from "./migration.js";
13
+ import { compressPublicOutput } from "./optimize.js";
14
+ import { countConverterUnavailable, optimizeImages, } from "./images.js";
15
+ import { startDevServer } from "./dev.js";
16
+ import { stoneAgeAgentSkill } from "./agent-skill.js";
17
+ const countOptions = new Map([
18
+ ["--members", "members"],
19
+ ["--sessions", "sessions"],
20
+ ["--conversations", "conversations"],
21
+ ["--bills", "bills"],
22
+ ]);
23
+ const benchmarkSizeMetrics = [
24
+ { name: "totalOutputBytes", read: (report) => readBenchmarkNumber(report, ["totalOutputBytes"]) },
25
+ { name: "publicOutputBytes", read: (report) => readBenchmarkNumber(report, ["publicOutputBytes"]) },
26
+ { name: "htmlOutputBytes", read: (report) => readBenchmarkNumber(report, ["htmlOutputBytes"]) },
27
+ { name: "cssOutputBytes", read: (report) => readBenchmarkNumber(report, ["cssOutputBytes"]) },
28
+ { name: "jsOutputBytes", read: (report) => readBenchmarkNumber(report, ["jsOutputBytes"]) },
29
+ { name: "imageOutputBytes", read: (report) => readBenchmarkNumber(report, ["imageOutputBytes"]) },
30
+ { name: "dataOutputBytes", read: (report) => readBenchmarkNumber(report, ["dataOutputBytes"]) },
31
+ { name: "averageHtmlBytes", read: (report) => readBenchmarkNumber(report, ["averageHtmlBytes"]) },
32
+ { name: "p95HtmlBytes", read: (report) => readBenchmarkNumber(report, ["p95HtmlBytes"]) },
33
+ ];
34
+ export function parseArgs(args) {
35
+ const remaining = [...args];
36
+ let command = "benchmark";
37
+ if (remaining[0] === "help" ||
38
+ remaining[0] === "--help" ||
39
+ remaining[0] === "-h" ||
40
+ remaining[0] === "benchmark" ||
41
+ remaining[0] === "validate" ||
42
+ remaining[0] === "inspect" ||
43
+ remaining[0] === "plan" ||
44
+ remaining[0] === "optimize" ||
45
+ remaining[0] === "optimize-images" ||
46
+ remaining[0] === "deploy" ||
47
+ remaining[0] === "dev" ||
48
+ remaining[0] === "agent-skill" ||
49
+ remaining[0] === "audit-sveltekit" ||
50
+ remaining[0] === "build" ||
51
+ remaining[0] === "test") {
52
+ const rawCommand = remaining.shift();
53
+ command = rawCommand === "--help" || rawCommand === "-h"
54
+ ? "help"
55
+ : rawCommand;
56
+ }
57
+ if (command === "build" || command === "test") {
58
+ return parseTsxCommand(command, remaining);
59
+ }
60
+ const counts = {};
61
+ let outDir = "example/dist";
62
+ let trailingSlash;
63
+ let includePublicDataExports = false;
64
+ let compareTo;
65
+ let maxSizeRegressionPercent;
66
+ let comparisonReportPath;
67
+ let json = false;
68
+ let reportPath;
69
+ let requirePrecompressed = false;
70
+ let dependency;
71
+ let routesDir;
72
+ let paramsDir;
73
+ let migrationPlan = false;
74
+ let emitStubsDir;
75
+ let provider = "netlify";
76
+ let providerExplicit = false;
77
+ let cname;
78
+ let host;
79
+ let port;
80
+ let buildCommand;
81
+ let debounceMs;
82
+ let printAgentSkill = false;
83
+ let validationConfigPath;
84
+ let writeValidationConfigPath;
85
+ const dependencies = [];
86
+ const watchPaths = [];
87
+ const budgets = {};
88
+ let imageSourceDir;
89
+ const imageWidths = [];
90
+ const imageFormats = [];
91
+ while (remaining.length > 0) {
92
+ const option = remaining.shift();
93
+ if (!option) {
94
+ break;
95
+ }
96
+ if (option === "--out-dir") {
97
+ outDir = readValue(option, remaining);
98
+ continue;
99
+ }
100
+ if (command === "benchmark" && option === "--trailing-slash") {
101
+ trailingSlash = readTrailingSlash(option, remaining);
102
+ continue;
103
+ }
104
+ if (command === "benchmark" && option === "--public-data-exports") {
105
+ includePublicDataExports = true;
106
+ continue;
107
+ }
108
+ if (command === "benchmark" && option === "--compare-to") {
109
+ compareTo = readValue(option, remaining);
110
+ continue;
111
+ }
112
+ if (command === "benchmark" && option === "--max-size-regression-percent") {
113
+ maxSizeRegressionPercent = readNonNegativeNumber(option, remaining);
114
+ continue;
115
+ }
116
+ if (command === "benchmark" && option === "--comparison-report") {
117
+ comparisonReportPath = readValue(option, remaining);
118
+ continue;
119
+ }
120
+ if (command === "validate" && option === "--max-html-bytes") {
121
+ budgets.maxHtmlBytes = readPositiveInteger(option, remaining);
122
+ continue;
123
+ }
124
+ if (command === "validate" && option === "--max-pages") {
125
+ budgets.maxPages = readPositiveInteger(option, remaining);
126
+ continue;
127
+ }
128
+ if (command === "validate" && option === "--max-artifacts") {
129
+ budgets.maxArtifacts = readPositiveInteger(option, remaining);
130
+ continue;
131
+ }
132
+ if (command === "validate" && option === "--max-css-bytes") {
133
+ budgets.maxCssBytes = readPositiveInteger(option, remaining);
134
+ continue;
135
+ }
136
+ if (command === "validate" && option === "--max-js-bytes") {
137
+ budgets.maxJsBytes = readPositiveInteger(option, remaining);
138
+ continue;
139
+ }
140
+ if (command === "validate" && option === "--max-public-file-bytes") {
141
+ budgets.maxPublicFileBytes = readPositiveInteger(option, remaining);
142
+ continue;
143
+ }
144
+ if (command === "validate" && option === "--max-total-public-bytes") {
145
+ budgets.maxTotalPublicBytes = readPositiveInteger(option, remaining);
146
+ continue;
147
+ }
148
+ if (command === "validate" && option === "--max-image-bytes") {
149
+ budgets.maxImageBytes = readPositiveInteger(option, remaining);
150
+ continue;
151
+ }
152
+ if (command === "validate" && option === "--max-brotli-bytes") {
153
+ budgets.maxBrotliBytes = readPositiveInteger(option, remaining);
154
+ continue;
155
+ }
156
+ if (command === "validate" && option === "--max-gzip-bytes") {
157
+ budgets.maxGzipBytes = readPositiveInteger(option, remaining);
158
+ continue;
159
+ }
160
+ if (command === "validate" && option === "--max-dependency-fanout") {
161
+ budgets.maxDependencyFanout = readPositiveInteger(option, remaining);
162
+ continue;
163
+ }
164
+ if (command === "validate" && option === "--require-precompressed") {
165
+ requirePrecompressed = true;
166
+ continue;
167
+ }
168
+ if ((command === "deploy" || command === "validate") && option === "--provider") {
169
+ provider = readDeployProvider(option, remaining);
170
+ providerExplicit = true;
171
+ continue;
172
+ }
173
+ if (command === "deploy" && option === "--cname") {
174
+ cname = readValue(option, remaining);
175
+ continue;
176
+ }
177
+ if (command === "dev" && option === "--host") {
178
+ host = readValue(option, remaining);
179
+ continue;
180
+ }
181
+ if (command === "dev" && option === "--port") {
182
+ port = readNonNegativeInteger(option, remaining);
183
+ continue;
184
+ }
185
+ if (command === "dev" && option === "--watch") {
186
+ watchPaths.push(readValue(option, remaining));
187
+ continue;
188
+ }
189
+ if (command === "dev" && option === "--build-command") {
190
+ buildCommand = readValue(option, remaining);
191
+ continue;
192
+ }
193
+ if (command === "dev" && option === "--debounce-ms") {
194
+ debounceMs = readNonNegativeInteger(option, remaining);
195
+ continue;
196
+ }
197
+ if (command === "agent-skill" && option === "--print") {
198
+ printAgentSkill = true;
199
+ continue;
200
+ }
201
+ if ((command === "validate" ||
202
+ command === "inspect" ||
203
+ command === "plan" ||
204
+ command === "audit-sveltekit") &&
205
+ option === "--json") {
206
+ json = true;
207
+ continue;
208
+ }
209
+ if ((command === "validate" || command === "audit-sveltekit") && option === "--report") {
210
+ reportPath = readValue(option, remaining);
211
+ continue;
212
+ }
213
+ if (command === "validate" && option === "--validation-config") {
214
+ validationConfigPath = readValue(option, remaining);
215
+ continue;
216
+ }
217
+ if (command === "validate" && option === "--write-validation-config") {
218
+ writeValidationConfigPath = readValue(option, remaining);
219
+ continue;
220
+ }
221
+ if (command === "audit-sveltekit" && option === "--routes-dir") {
222
+ routesDir = readValue(option, remaining);
223
+ continue;
224
+ }
225
+ if (command === "audit-sveltekit" && option === "--params-dir") {
226
+ paramsDir = readValue(option, remaining);
227
+ continue;
228
+ }
229
+ if (command === "audit-sveltekit" && option === "--migration-plan") {
230
+ migrationPlan = true;
231
+ continue;
232
+ }
233
+ if (command === "audit-sveltekit" && option === "--emit-stubs") {
234
+ emitStubsDir = readValue(option, remaining);
235
+ migrationPlan = true;
236
+ continue;
237
+ }
238
+ if (command === "inspect" && option === "--dependency") {
239
+ dependency = readValue(option, remaining);
240
+ continue;
241
+ }
242
+ if (command === "plan" && option === "--dependency") {
243
+ dependencies.push(readValue(option, remaining));
244
+ continue;
245
+ }
246
+ if (command === "optimize-images" && option === "--source-dir") {
247
+ imageSourceDir = readValue(option, remaining);
248
+ continue;
249
+ }
250
+ if (command === "optimize-images" && option === "--width") {
251
+ imageWidths.push(readPositiveInteger(option, remaining));
252
+ continue;
253
+ }
254
+ if (command === "optimize-images" && option === "--format") {
255
+ imageFormats.push(readImageFormat(option, remaining));
256
+ continue;
257
+ }
258
+ const countKey = countOptions.get(option);
259
+ if (command === "benchmark" && countKey) {
260
+ counts[countKey] = readPositiveInteger(option, remaining);
261
+ continue;
262
+ }
263
+ throw new Error(`Unknown option: ${option}`);
264
+ }
265
+ if (command === "help") {
266
+ return { command };
267
+ }
268
+ if (command === "benchmark") {
269
+ if (comparisonReportPath && !compareTo) {
270
+ throw new Error("--comparison-report requires --compare-to");
271
+ }
272
+ return {
273
+ command,
274
+ outDir,
275
+ counts,
276
+ ...(trailingSlash === undefined ? {} : { trailingSlash }),
277
+ ...(includePublicDataExports ? { includePublicDataExports } : {}),
278
+ ...(compareTo ? { compareTo } : {}),
279
+ ...(maxSizeRegressionPercent === undefined ? {} : { maxSizeRegressionPercent }),
280
+ ...(comparisonReportPath ? { comparisonReportPath } : {}),
281
+ };
282
+ }
283
+ if (command === "validate") {
284
+ return {
285
+ command,
286
+ outDir,
287
+ ...(Object.keys(budgets).length > 0 ? { budgets } : {}),
288
+ ...(requirePrecompressed ? { requirePrecompressed } : {}),
289
+ ...(validationConfigPath ? { validationConfigPath } : {}),
290
+ ...(writeValidationConfigPath ? { writeValidationConfigPath } : {}),
291
+ ...(json ? { json } : {}),
292
+ ...(reportPath ? { reportPath } : {}),
293
+ ...(providerExplicit ? { provider } : {}),
294
+ };
295
+ }
296
+ if (command === "deploy") {
297
+ if (cname !== undefined && provider !== "github-pages") {
298
+ throw new Error("--cname is only supported with --provider github-pages");
299
+ }
300
+ return {
301
+ command,
302
+ outDir,
303
+ provider,
304
+ ...(cname !== undefined ? { cname } : {}),
305
+ };
306
+ }
307
+ if (command === "dev") {
308
+ return {
309
+ command,
310
+ outDir,
311
+ ...(host ? { host } : {}),
312
+ ...(port === undefined ? {} : { port }),
313
+ ...(watchPaths.length > 0 ? { watchPaths } : {}),
314
+ ...(buildCommand ? { buildCommand } : {}),
315
+ ...(debounceMs === undefined ? {} : { debounceMs }),
316
+ };
317
+ }
318
+ if (command === "agent-skill") {
319
+ if (!printAgentSkill) {
320
+ throw new Error("Missing required --print for agent-skill");
321
+ }
322
+ return {
323
+ command,
324
+ print: true,
325
+ };
326
+ }
327
+ if (command === "inspect") {
328
+ if (!dependency) {
329
+ throw new Error("Missing required --dependency for inspect");
330
+ }
331
+ return {
332
+ command,
333
+ outDir,
334
+ dependency,
335
+ ...(json ? { json } : {}),
336
+ };
337
+ }
338
+ if (command === "plan") {
339
+ if (dependencies.length === 0) {
340
+ throw new Error("Missing required --dependency for plan");
341
+ }
342
+ return {
343
+ command,
344
+ outDir,
345
+ dependencies,
346
+ ...(json ? { json } : {}),
347
+ };
348
+ }
349
+ if (command === "audit-sveltekit") {
350
+ if (!routesDir) {
351
+ throw new Error("Missing required --routes-dir for audit-sveltekit");
352
+ }
353
+ return {
354
+ command,
355
+ routesDir,
356
+ ...(paramsDir ? { paramsDir } : {}),
357
+ ...(json ? { json } : {}),
358
+ ...(migrationPlan ? { migrationPlan } : {}),
359
+ ...(emitStubsDir ? { emitStubsDir } : {}),
360
+ ...(reportPath ? { reportPath } : {}),
361
+ };
362
+ }
363
+ if (command === "optimize-images") {
364
+ if (!imageSourceDir) {
365
+ throw new Error("Missing required --source-dir for optimize-images");
366
+ }
367
+ return {
368
+ command,
369
+ outDir,
370
+ sourceDir: imageSourceDir,
371
+ widths: imageWidths.length > 0 ? imageWidths : [640, 1280],
372
+ formats: imageFormats.length > 0 ? imageFormats : ["webp"],
373
+ };
374
+ }
375
+ return { command, outDir };
376
+ }
377
+ function parseTsxCommand(command, remaining) {
378
+ const positionals = [];
379
+ let forwardedArgs = [];
380
+ while (remaining.length > 0) {
381
+ const arg = remaining.shift();
382
+ if (arg === undefined) {
383
+ break;
384
+ }
385
+ if (arg === "--") {
386
+ forwardedArgs = remaining.splice(0);
387
+ break;
388
+ }
389
+ positionals.push(arg);
390
+ }
391
+ if (command === "build") {
392
+ const [entry, ...extraPositionals] = positionals;
393
+ if (!entry) {
394
+ throw new Error("Missing required entry for build");
395
+ }
396
+ if (extraPositionals.length > 0) {
397
+ throw new Error(`Unexpected argument for build: ${extraPositionals[0]} (pass extra arguments after --)`);
398
+ }
399
+ return { command, entry, forwardedArgs };
400
+ }
401
+ return { command, forwardedArgs: [...positionals, ...forwardedArgs] };
402
+ }
403
+ export async function runCli(args = process.argv.slice(2)) {
404
+ const command = parseArgs(args);
405
+ if (command.command === "help") {
406
+ console.log(helpText());
407
+ return;
408
+ }
409
+ if (command.command === "build") {
410
+ const code = await runWithForcedJsx([command.entry, ...command.forwardedArgs]);
411
+ if (code !== 0) {
412
+ process.exitCode = code;
413
+ }
414
+ return;
415
+ }
416
+ if (command.command === "test") {
417
+ const code = await runWithForcedJsx(["--test", ...command.forwardedArgs]);
418
+ if (code !== 0) {
419
+ process.exitCode = code;
420
+ }
421
+ return;
422
+ }
423
+ if (command.command === "benchmark") {
424
+ const metrics = await benchmarkExampleSite({
425
+ outDir: command.outDir,
426
+ counts: command.counts,
427
+ trailingSlash: command.trailingSlash,
428
+ includePublicDataExports: command.includePublicDataExports,
429
+ });
430
+ console.log(`StoneAge benchmark complete`);
431
+ console.log(`Pages: ${metrics.pageCount}`);
432
+ console.log(`Full build: ${metrics.fullBuildMs.toFixed(1)}ms`);
433
+ console.log(`Incremental build: ${metrics.incrementalBuildMs.toFixed(1)}ms`);
434
+ console.log(`Normalized data snapshots: ${metrics.normalizedDataSnapshots}`);
435
+ console.log(`Normalized data rewrites: ${metrics.fullBuildNormalizedDataWrites}/${metrics.incrementalBuildNormalizedDataWrites}/${metrics.changedBuildNormalizedDataWrites}`);
436
+ console.log(`Output: ${metrics.totalOutputBytes} bytes`);
437
+ console.log(`Public data exports: ${metrics.dataOutputBytes} bytes`);
438
+ console.log(`Report: ${command.outDir}/.stoneage/benchmark.json`);
439
+ if (command.compareTo) {
440
+ const baseline = JSON.parse(await readFile(command.compareTo, "utf8"));
441
+ const comparison = {
442
+ ...compareBenchmarkMetrics(metrics, baseline, {
443
+ maxSizeRegressionPercent: command.maxSizeRegressionPercent,
444
+ }),
445
+ baselinePath: command.compareTo,
446
+ currentReportPath: join(command.outDir, ".stoneage/benchmark.json"),
447
+ };
448
+ if (command.comparisonReportPath) {
449
+ await writeJson(command.comparisonReportPath, comparison);
450
+ }
451
+ console.log(`Benchmark comparison: ${comparison.ok ? "passed" : "failed"}`);
452
+ for (const regression of comparison.regressions) {
453
+ console.log(`- ${regression.metric}: ${regression.current} bytes, baseline ${regression.baseline} bytes, +${regression.delta} bytes (${formatRegressionPercent(regression.regressionPercent)}, limit ${regression.limitPercent}%)`);
454
+ }
455
+ if (!comparison.ok) {
456
+ process.exitCode = 1;
457
+ }
458
+ }
459
+ return;
460
+ }
461
+ if (command.command === "optimize") {
462
+ const metrics = await compressPublicOutput(command.outDir);
463
+ console.log("StoneAge optimization complete");
464
+ console.log(`Compressed files: ${metrics.filesCompressed}`);
465
+ console.log(`Original transfer: ${metrics.originalBytes} bytes`);
466
+ console.log(`Brotli transfer: ${metrics.brotliBytes} bytes`);
467
+ console.log(`Gzip transfer: ${metrics.gzipBytes} bytes`);
468
+ return;
469
+ }
470
+ if (command.command === "optimize-images") {
471
+ const sourceDir = resolve(command.sourceDir);
472
+ let sourceStats;
473
+ try {
474
+ sourceStats = await stat(sourceDir);
475
+ }
476
+ catch (error) {
477
+ if (isMissingPath(error)) {
478
+ throw new Error(`Source directory not found: ${command.sourceDir}`);
479
+ }
480
+ throw error;
481
+ }
482
+ if (!sourceStats.isDirectory()) {
483
+ throw new Error(`--source-dir is not a directory: ${command.sourceDir}`);
484
+ }
485
+ // Exclude the output directory from the scan so previously generated
486
+ // variants are not re-ingested as inputs when outDir lives under sourceDir.
487
+ const images = await collectImageInputs(sourceDir, sourceDir, resolve(command.outDir));
488
+ const result = await optimizeImages({
489
+ outDir: command.outDir,
490
+ images,
491
+ widths: command.widths,
492
+ formats: command.formats,
493
+ skipExisting: true,
494
+ });
495
+ const generatedBytes = result.generated.reduce((total, image) => total + image.bytes, 0);
496
+ const reused = result.skipped.filter((image) => image.reason === "existing-current");
497
+ console.log("StoneAge image optimization complete");
498
+ console.log(`Source images: ${images.length}`);
499
+ console.log(`Generated variants: ${result.generated.length}`);
500
+ console.log(`Reused variants: ${reused.length}`);
501
+ console.log(`Generated output: ${generatedBytes} bytes`);
502
+ const unoptimized = countConverterUnavailable(result);
503
+ if (unoptimized > 0) {
504
+ console.warn(`Skipped optimization for ${unoptimized} image${unoptimized === 1 ? "" : "s"} because sharp is unavailable.`);
505
+ console.warn("Image optimization requires the optional 'sharp' dependency. Install it with: npm install sharp");
506
+ console.warn("If sharp is already installed, its native binary may not support this platform; reinstall sharp for your OS/CPU.");
507
+ }
508
+ return;
509
+ }
510
+ if (command.command === "deploy") {
511
+ const result = await writeDeployArtifacts({
512
+ outDir: command.outDir,
513
+ provider: command.provider,
514
+ ...(command.cname !== undefined ? { cname: command.cname } : {}),
515
+ });
516
+ console.log("StoneAge deploy artifacts complete");
517
+ console.log(`Provider: ${result.provider}`);
518
+ console.log(`Files: ${result.files.length}`);
519
+ if (result.provider === "github-pages") {
520
+ console.log(`Artifacts: ${result.files.length > 0 ? result.files.join(", ") : "none"}`);
521
+ }
522
+ else {
523
+ console.log(`Redirects: ${result.redirects}`);
524
+ console.log(`Headers: ${result.headers}`);
525
+ }
526
+ return;
527
+ }
528
+ if (command.command === "dev") {
529
+ const server = await startDevServer({
530
+ outDir: command.outDir,
531
+ host: command.host,
532
+ port: command.port,
533
+ watchPaths: command.watchPaths ?? ["."],
534
+ buildCommand: command.buildCommand,
535
+ debounceMs: command.debounceMs,
536
+ });
537
+ console.log(`StoneAge dev server: ${server.url}`);
538
+ console.log(`Output: ${command.outDir}`);
539
+ if (command.buildCommand) {
540
+ console.log(`Build command: ${command.buildCommand}`);
541
+ }
542
+ return new Promise((resolve) => {
543
+ const close = async () => {
544
+ await server.close();
545
+ resolve();
546
+ };
547
+ process.once("SIGINT", close);
548
+ process.once("SIGTERM", close);
549
+ });
550
+ }
551
+ if (command.command === "agent-skill") {
552
+ console.log(stoneAgeAgentSkill);
553
+ return;
554
+ }
555
+ if (command.command === "audit-sveltekit") {
556
+ const report = await auditSvelteKitPublicSurface({
557
+ routesDir: command.routesDir,
558
+ paramsDir: command.paramsDir,
559
+ migrationPlan: command.migrationPlan || Boolean(command.emitStubsDir),
560
+ });
561
+ if (command.emitStubsDir) {
562
+ if (!report.migrationPlan) {
563
+ throw new Error("Missing migration plan for --emit-stubs");
564
+ }
565
+ await emitSvelteKitMigrationStubs(report.migrationPlan, command.emitStubsDir);
566
+ }
567
+ if (command.reportPath) {
568
+ await writeJson(command.reportPath, report);
569
+ }
570
+ if (command.json) {
571
+ console.log(JSON.stringify(report, null, 2));
572
+ return;
573
+ }
574
+ console.log("StoneAge SvelteKit audit complete");
575
+ console.log(`Pages: ${report.summary.pages}`);
576
+ console.log(`Endpoints: ${report.summary.endpoints}`);
577
+ console.log(`Dynamic routes: ${report.summary.dynamicRoutes}`);
578
+ console.log(`Warnings: ${report.summary.warnings}`);
579
+ if (command.emitStubsDir) {
580
+ console.log(`Migration stubs: ${command.emitStubsDir}`);
581
+ }
582
+ console.log("Endpoint kinds:");
583
+ for (const [kind, count] of Object.entries(report.summary.endpointKinds)) {
584
+ console.log(`- ${kind}: ${count}`);
585
+ }
586
+ for (const warning of report.warnings.slice(0, 10)) {
587
+ console.log(`- ${warning.code}: ${warning.message}`);
588
+ }
589
+ return;
590
+ }
591
+ if (command.command === "inspect") {
592
+ const summary = await inspectDependency(command.outDir, command.dependency);
593
+ if (command.json) {
594
+ console.log(JSON.stringify(summary, null, 2));
595
+ return;
596
+ }
597
+ console.log(`Dependency: ${summary.dependency}`);
598
+ console.log(`Pages: ${summary.pageCount}`);
599
+ for (const page of summary.pages) {
600
+ console.log(`- page ${page.route} ${page.path} (${page.outputPath})`);
601
+ }
602
+ console.log(`Artifacts: ${summary.artifactCount}`);
603
+ for (const artifact of summary.artifacts) {
604
+ console.log(`- artifact ${artifact.route} ${artifact.path} (${artifact.outputPath})`);
605
+ }
606
+ return;
607
+ }
608
+ if (command.command === "plan") {
609
+ const summary = await planDependencyRebuilds(command.outDir, command.dependencies);
610
+ if (command.json) {
611
+ console.log(JSON.stringify(summary, null, 2));
612
+ return;
613
+ }
614
+ console.log(`Dependencies: ${summary.dependencies.length}`);
615
+ for (const dependency of summary.dependencies) {
616
+ console.log(`- ${dependency}`);
617
+ }
618
+ console.log(`Outputs: ${summary.outputCount}`);
619
+ console.log(`Pages: ${summary.pageCount}`);
620
+ console.log(`Artifacts: ${summary.artifactCount}`);
621
+ for (const output of summary.outputs) {
622
+ const dependencies = Object.keys(output.dependencies).join(", ");
623
+ console.log(`- ${output.kind} ${output.path} (${output.outputPath}) [${dependencies}]`);
624
+ }
625
+ return;
626
+ }
627
+ const configOptions = command.validationConfigPath
628
+ ? await readValidationOptionsConfig(command.validationConfigPath)
629
+ : {};
630
+ const budgets = {
631
+ ...(configOptions.budgets ?? {}),
632
+ ...(command.budgets ?? {}),
633
+ };
634
+ const provider = command.provider ?? configOptions.provider;
635
+ const summary = await validateOutput(command.outDir, {
636
+ ...(Object.keys(budgets).length > 0 ? { budgets } : {}),
637
+ requirePrecompressed: command.requirePrecompressed ?? configOptions.requirePrecompressed,
638
+ ...(command.validationConfigPath ? { configPath: command.validationConfigPath } : {}),
639
+ ...(configOptions.configHash ? { configHash: configOptions.configHash } : {}),
640
+ ...(provider !== undefined ? { provider } : {}),
641
+ });
642
+ if (command.reportPath) {
643
+ await writeJson(command.reportPath, summary);
644
+ }
645
+ if (command.writeValidationConfigPath) {
646
+ await writeJson(command.writeValidationConfigPath, validationConfigFromSummary(summary));
647
+ }
648
+ if (command.json) {
649
+ console.log(JSON.stringify(summary, null, 2));
650
+ if (!summary.ok) {
651
+ process.exitCode = 1;
652
+ }
653
+ return;
654
+ }
655
+ console.log("StoneAge validation complete");
656
+ console.log(`Pages: ${summary.pageCount}`);
657
+ console.log(`Artifacts: ${summary.artifactCount}`);
658
+ console.log(`Public assets: ${summary.publicAssetCount}`);
659
+ console.log(`Public assets copied: ${summary.publicAssetsCopied}`);
660
+ console.log(`Public assets skipped: ${summary.publicAssetsSkipped}`);
661
+ console.log(`Stale outputs removed: ${summary.staleOutputsRemoved}`);
662
+ console.log(`Public files: ${summary.publicFileCount}`);
663
+ console.log(`Public file bytes: ${summary.publicFileBytes}`);
664
+ if (summary.transferSidecars.compressibleFiles > 0) {
665
+ console.log(`Compressible files: ${summary.transferSidecars.compressibleFiles}`);
666
+ console.log(`Brotli sidecars: ${summary.transferSidecars.brotliFiles}`);
667
+ console.log(`Gzip sidecars: ${summary.transferSidecars.gzipFiles}`);
668
+ console.log(`Missing Brotli sidecars: ${summary.transferSidecars.missingBrotliFiles.length}`);
669
+ console.log(`Missing Gzip sidecars: ${summary.transferSidecars.missingGzipFiles.length}`);
670
+ console.log(`Brotli sidecar bytes: ${summary.transferSidecars.brotliBytes}`);
671
+ console.log(`Gzip sidecar bytes: ${summary.transferSidecars.gzipBytes}`);
672
+ }
673
+ console.log(`Stylesheet assets: ${summary.stylesheetAssetCount}`);
674
+ console.log(`Stylesheet bytes: ${summary.stylesheetAssetBytes}`);
675
+ console.log(`Public CSS files: ${summary.publicCssFileCount}`);
676
+ console.log(`Public CSS bytes: ${summary.publicCssFileBytes}`);
677
+ console.log(`Public JavaScript files: ${summary.publicJavaScriptFileCount}`);
678
+ console.log(`Public JavaScript bytes: ${summary.publicJavaScriptFileBytes}`);
679
+ console.log(`Image assets: ${summary.imageAssetCount}`);
680
+ console.log(`Image bytes: ${summary.imageAssetBytes}`);
681
+ console.log(`Route families written: ${Object.keys(summary.routeWrites).length}`);
682
+ console.log(`Artifact families written: ${Object.keys(summary.artifactWrites).length}`);
683
+ console.log(`Missing links: ${summary.missingLinks.length}`);
684
+ console.log(`Sitemap URLs: ${summary.sitemapUrls}`);
685
+ console.log(`Sitemap files: ${summary.sitemapFiles.length}`);
686
+ console.log(`Sitemap file issues: ${summary.sitemapFileIssues.length}`);
687
+ console.log(`Sitemap drift: ${summary.sitemapDrift.missing.length} missing, ${summary.sitemapDrift.extra.length} extra`);
688
+ console.log(`Validation issues: ${summary.validationIssues.length}`);
689
+ console.log(`Budget failures: ${summary.budgetFailures.length}`);
690
+ console.log(`Public data exports: ${summary.publicDataExports}`);
691
+ if (summary.dataFlow) {
692
+ console.log(`Data-flow dependencies: ${summary.dataFlow.dependencyCount}`);
693
+ console.log(`Data-flow outputs: ${summary.dataFlow.outputs.total}`);
694
+ }
695
+ console.log(`Public data export issues: ${summary.publicDataExportIssues.length}`);
696
+ console.log(`Headers manifest issues: ${summary.headersManifestIssues.length}`);
697
+ console.log(`Redirect manifest issues: ${summary.redirectManifestIssues.length}`);
698
+ console.log(`Robots.txt issues: ${summary.robotsTxtIssues.length}`);
699
+ console.log(`Deploy artifact issues: ${summary.deployArtifactIssues.length}`);
700
+ console.log(`HTML metadata issues: ${summary.htmlMetadataIssues.length}`);
701
+ console.log(`Hydration payload issues: ${summary.hydrationPayloadIssues.length}`);
702
+ console.log(`Missing asset references: ${summary.missingAssetReferences.length}`);
703
+ console.log(`Prefetch reference issues: ${summary.prefetchReferenceIssues.length}`);
704
+ console.log(`Manifest output issues: ${summary.manifestOutputIssues.length}`);
705
+ console.log(`Orphan HTML issues: ${summary.orphanHtmlIssues.length}`);
706
+ console.log("Largest HTML:");
707
+ for (const item of summary.largestHtml.slice(0, 10)) {
708
+ console.log(`- ${item.path} (${item.bytes} bytes)`);
709
+ }
710
+ console.log("Largest artifacts:");
711
+ for (const item of summary.largestArtifacts.slice(0, 10)) {
712
+ console.log(`- ${item.path} (${item.bytes} bytes)`);
713
+ }
714
+ console.log("Largest public files:");
715
+ for (const item of summary.largestPublicFiles.slice(0, 10)) {
716
+ console.log(`- ${item.path} (${item.bytes} bytes, ${item.type})`);
717
+ }
718
+ console.log("Public file types:");
719
+ for (const item of summary.publicFileTypes.slice(0, 10)) {
720
+ console.log(`- ${item.type}: ${item.files} files, ${item.bytes} bytes`);
721
+ }
722
+ console.log("Largest stylesheets:");
723
+ for (const item of summary.largestStylesheets.slice(0, 10)) {
724
+ console.log(`- ${item.path} (${item.bytes} bytes, ${item.references} references)`);
725
+ }
726
+ console.log("Largest public CSS files:");
727
+ for (const item of summary.largestPublicCssFiles.slice(0, 10)) {
728
+ console.log(`- ${item.path} (${item.bytes} bytes, ${item.type})`);
729
+ }
730
+ console.log("Largest public JavaScript files:");
731
+ for (const item of summary.largestPublicJavaScriptFiles.slice(0, 10)) {
732
+ console.log(`- ${item.path} (${item.bytes} bytes, ${item.type})`);
733
+ }
734
+ console.log("Largest images:");
735
+ for (const item of summary.largestImages.slice(0, 10)) {
736
+ console.log(`- ${item.path} (${item.bytes} bytes, ${item.type})`);
737
+ }
738
+ for (const issue of summary.validationIssues.slice(0, 10)) {
739
+ console.log(`- ${issue.code}: ${issue.message}`);
740
+ }
741
+ if (!summary.ok) {
742
+ process.exitCode = 1;
743
+ }
744
+ }
745
+ async function writeJson(path, value) {
746
+ await mkdir(dirname(path), { recursive: true });
747
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
748
+ }
749
+ const validationConfigKeys = ["budgets", "requiredMetadata", "requirePrecompressed", "provider"];
750
+ async function readValidationOptionsConfig(path) {
751
+ const raw = await readFile(path, "utf8");
752
+ const value = JSON.parse(raw);
753
+ if (!isRecord(value)) {
754
+ throw new Error(`Validation config must be a JSON object: ${path}`);
755
+ }
756
+ rejectUnknownKeys(value, validationConfigKeys, "Validation config", path);
757
+ const validation = await loadValidationConfig(path);
758
+ const requirePrecompressed = value.requirePrecompressed;
759
+ if (requirePrecompressed !== undefined && typeof requirePrecompressed !== "boolean") {
760
+ throw new Error(`Validation config requirePrecompressed must be a boolean: ${path}`);
761
+ }
762
+ const provider = value.provider;
763
+ if (provider !== undefined && provider !== "netlify" && provider !== "github-pages") {
764
+ throw new Error(`Validation config provider must be netlify or github-pages: ${path}`);
765
+ }
766
+ return {
767
+ configHash: createHash("sha256").update(raw).digest("hex"),
768
+ ...(validation.budgets ? { budgets: validation.budgets } : {}),
769
+ ...(requirePrecompressed === undefined ? {} : { requirePrecompressed }),
770
+ ...(provider === undefined ? {} : { provider }),
771
+ };
772
+ }
773
+ function isRecord(value) {
774
+ return typeof value === "object" && value !== null && !Array.isArray(value);
775
+ }
776
+ function rejectUnknownKeys(value, allowedKeys, label, path) {
777
+ const allowed = new Set(allowedKeys);
778
+ for (const key of Object.keys(value)) {
779
+ if (!allowed.has(key)) {
780
+ throw new Error(`Unknown ${label.toLowerCase()} key ${key}: ${path}`);
781
+ }
782
+ }
783
+ }
784
+ function validationConfigFromSummary(summary) {
785
+ const budgets = {};
786
+ const largestHtml = summary.largestHtml[0];
787
+ const largestPublicFile = summary.largestPublicFiles[0];
788
+ const largestCssFile = summary.largestPublicCssFiles[0];
789
+ const largestJavaScriptFile = summary.largestPublicJavaScriptFiles[0];
790
+ const largestDependencyFanout = summary.dataFlow?.largestDependencyFanout[0];
791
+ if (summary.pageCount > 0) {
792
+ budgets.maxPages = summary.pageCount;
793
+ }
794
+ if (summary.artifactCount > 0) {
795
+ budgets.maxArtifacts = summary.artifactCount;
796
+ }
797
+ const routeFamilyBudgets = positiveIntegerEntries(summary.routeFamilies);
798
+ if (Object.keys(routeFamilyBudgets).length > 0) {
799
+ budgets.maxRouteFamilyPages = routeFamilyBudgets;
800
+ }
801
+ const artifactFamilyBudgets = positiveIntegerEntries(summary.artifactFamilies);
802
+ if (Object.keys(artifactFamilyBudgets).length > 0) {
803
+ budgets.maxArtifactFamilyOutputs = artifactFamilyBudgets;
804
+ }
805
+ if (largestHtml && largestHtml.bytes > 0) {
806
+ budgets.maxHtmlBytes = largestHtml.bytes;
807
+ }
808
+ if (largestPublicFile && largestPublicFile.bytes > 0) {
809
+ budgets.maxPublicFileBytes = largestPublicFile.bytes;
810
+ }
811
+ if (largestCssFile && largestCssFile.bytes > 0) {
812
+ budgets.maxCssBytes = largestCssFile.bytes;
813
+ }
814
+ if (largestJavaScriptFile && largestJavaScriptFile.bytes > 0) {
815
+ budgets.maxJsBytes = largestJavaScriptFile.bytes;
816
+ }
817
+ if (summary.publicFileBytes > 0) {
818
+ budgets.maxTotalPublicBytes = summary.publicFileBytes;
819
+ }
820
+ if (summary.imageAssetBytes > 0) {
821
+ budgets.maxImageBytes = summary.imageAssetBytes;
822
+ }
823
+ if (largestDependencyFanout && largestDependencyFanout.outputs > 0) {
824
+ budgets.maxDependencyFanout = largestDependencyFanout.outputs;
825
+ }
826
+ return {
827
+ ...(summary.validationOptions?.provider !== undefined
828
+ ? { provider: summary.validationOptions.provider }
829
+ : {}),
830
+ ...(Object.keys(budgets).length > 0 ? { budgets } : {}),
831
+ };
832
+ }
833
+ function validationOptionsSummary(options) {
834
+ const summary = {};
835
+ if (options.configPath) {
836
+ summary.configPath = options.configPath;
837
+ }
838
+ if (options.configHash) {
839
+ summary.configHash = options.configHash;
840
+ }
841
+ if (options.budgets && Object.keys(options.budgets).length > 0) {
842
+ summary.budgets = options.budgets;
843
+ }
844
+ if (options.requirePrecompressed !== undefined) {
845
+ summary.requirePrecompressed = options.requirePrecompressed;
846
+ }
847
+ if (options.provider !== undefined) {
848
+ summary.provider = options.provider;
849
+ }
850
+ return Object.keys(summary).length > 0 ? { validationOptions: summary } : {};
851
+ }
852
+ function positiveIntegerEntries(value) {
853
+ return Object.fromEntries(Object.entries(value).filter(([, count]) => Number.isInteger(count) && count > 0));
854
+ }
855
+ export function compareBenchmarkMetrics(current, baseline, options = {}) {
856
+ const maxSizeRegressionPercent = options.maxSizeRegressionPercent ?? 0;
857
+ const regressions = benchmarkSizeMetrics.flatMap((metric) => {
858
+ const baselineValue = metric.read(baseline);
859
+ const currentValue = metric.read(current);
860
+ if (currentValue <= baselineValue) {
861
+ return [];
862
+ }
863
+ const delta = currentValue - baselineValue;
864
+ const regressionPercent = baselineValue === 0 ? null : (delta / baselineValue) * 100;
865
+ const failed = regressionPercent === null || regressionPercent > maxSizeRegressionPercent;
866
+ return failed
867
+ ? [
868
+ {
869
+ metric: metric.name,
870
+ baseline: baselineValue,
871
+ current: currentValue,
872
+ delta,
873
+ regressionPercent,
874
+ limitPercent: maxSizeRegressionPercent,
875
+ },
876
+ ]
877
+ : [];
878
+ });
879
+ return {
880
+ ok: regressions.length === 0,
881
+ maxSizeRegressionPercent,
882
+ comparedMetrics: benchmarkSizeMetrics.length,
883
+ regressions,
884
+ };
885
+ }
886
+ function readBenchmarkNumber(report, path) {
887
+ let value = report;
888
+ for (const key of path) {
889
+ if (!isRecord(value)) {
890
+ throw new Error(`Benchmark report missing numeric metric: ${path.join(".")}`);
891
+ }
892
+ value = value[key];
893
+ }
894
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
895
+ throw new Error(`Benchmark report missing numeric metric: ${path.join(".")}`);
896
+ }
897
+ return value;
898
+ }
899
+ function formatRegressionPercent(value) {
900
+ return value === null ? "new bytes" : `+${value.toFixed(1)}%`;
901
+ }
902
+ export async function inspectDependency(outDir, dependency) {
903
+ const manifest = await readRequiredGeneratedManifest(outDir);
904
+ const pages = manifest.pages
905
+ .flatMap((page) => {
906
+ const hash = page.dependencies[dependency];
907
+ return hash === undefined
908
+ ? []
909
+ : [
910
+ {
911
+ route: page.route,
912
+ path: page.path,
913
+ outputPath: page.outputPath,
914
+ hash,
915
+ },
916
+ ];
917
+ })
918
+ .sort(compareInspectEntries);
919
+ const artifacts = manifest.artifacts
920
+ .flatMap((artifact) => {
921
+ const hash = artifact.dependencies[dependency];
922
+ return hash === undefined
923
+ ? []
924
+ : [
925
+ {
926
+ route: artifact.route,
927
+ path: artifact.path,
928
+ outputPath: artifact.outputPath,
929
+ hash,
930
+ },
931
+ ];
932
+ })
933
+ .sort(compareInspectEntries);
934
+ return {
935
+ dependency,
936
+ pageCount: pages.length,
937
+ artifactCount: artifacts.length,
938
+ pages,
939
+ artifacts,
940
+ };
941
+ }
942
+ export async function planDependencyRebuilds(outDir, dependencies) {
943
+ const requestedDependencies = uniqueStrings(dependencies);
944
+ const manifest = await readRequiredGeneratedManifest(outDir);
945
+ const outputs = [
946
+ ...manifest.artifacts.flatMap((artifact) => planOutputFor("artifact", artifact, requestedDependencies)),
947
+ ...manifest.pages.flatMap((page) => planOutputFor("page", page, requestedDependencies)),
948
+ ].sort(comparePlanOutputs);
949
+ const uniqueOutputs = Array.from(new Map(outputs.map((output) => [output.outputPath, output])).values());
950
+ return {
951
+ dependencies: requestedDependencies,
952
+ outputCount: uniqueOutputs.length,
953
+ pageCount: uniqueOutputs.filter((output) => output.kind === "page").length,
954
+ artifactCount: uniqueOutputs.filter((output) => output.kind === "artifact").length,
955
+ outputs: uniqueOutputs,
956
+ };
957
+ }
958
+ function compareInspectEntries(left, right) {
959
+ return left.path.localeCompare(right.path) || left.route.localeCompare(right.route);
960
+ }
961
+ function comparePlanOutputs(left, right) {
962
+ return (left.kind.localeCompare(right.kind) ||
963
+ left.path.localeCompare(right.path) ||
964
+ left.route.localeCompare(right.route) ||
965
+ left.outputPath.localeCompare(right.outputPath));
966
+ }
967
+ export async function validateOutput(outDir, options = {}) {
968
+ const report = JSON.parse(await readFile(join(outDir, ".stoneage/report.json"), "utf8"));
969
+ const sitemap = await readOptionalText(join(outDir, "sitemap.xml"));
970
+ const manifest = await readGeneratedManifest(join(outDir, ".stoneage/manifest.json"));
971
+ const dataFlow = (await readSplitDataFlowReport(outDir)) ??
972
+ (await readDataFlowReport(join(outDir, ".stoneage/data-flow.json"))) ??
973
+ (manifest ? dataFlowReportFromManifest(manifest) : undefined);
974
+ const sitemapResult = sitemap
975
+ ? await readSitemapPaths(outDir, sitemap)
976
+ : { paths: [], files: [], issues: [] };
977
+ const sitemapDrift = manifest ? compareSitemapPaths(manifest.pages, sitemapResult.paths) : { missing: [], extra: [] };
978
+ const budgetFailures = [
979
+ ...outputCountBudgetFailures("pages", ".", report.pageCount, options.budgets?.maxPages),
980
+ ...outputCountBudgetFailures("artifacts", ".", report.artifactCount ?? 0, options.budgets?.maxArtifacts),
981
+ ...routeFamilyPageBudgetFailures(report.routeFamilies ?? {}, options.budgets?.maxRouteFamilyPages),
982
+ ...artifactFamilyOutputBudgetFailures(report.artifactFamilies ?? {}, options.budgets?.maxArtifactFamilyOutputs),
983
+ ...htmlBudgetFailures(report.largestHtml, options.budgets?.maxHtmlBytes),
984
+ ...(await assetBudgetFailures(outDir, "css", options.budgets?.maxCssBytes)),
985
+ ...(await assetBudgetFailures(outDir, "js", options.budgets?.maxJsBytes)),
986
+ ];
987
+ const publicDataExportTargets = await collectPublicDataExportTargets(outDir, manifest);
988
+ const publicDataExportIssues = await validatePublicDataExports(outDir, publicDataExportTargets);
989
+ const headersManifestIssues = manifest
990
+ ? await validateHeadersManifest(outDir, manifest, report.publicAssets ?? [])
991
+ : [];
992
+ const redirectManifestIssues = manifest ? await validateRedirectManifest(outDir, manifest) : [];
993
+ const robotsTxtIssues = await validateRobotsTxt(outDir);
994
+ const deployArtifactIssues = await validateDeployArtifacts(outDir, options.provider);
995
+ const htmlMetadataIssues = manifest ? await validateHtmlMetadata(outDir, manifest) : [];
996
+ const hydrationPayloadIssues = await validateHydrationPayloads(outDir);
997
+ const missingAssetReferences = await validateHtmlAssetReferences(outDir);
998
+ const publicFileInventory = await collectPublicFileInventory(outDir);
999
+ budgetFailures.push(...publicFileBudgetFailures(publicFileInventory, options.budgets?.maxPublicFileBytes), ...totalPublicBudgetFailures(publicFileInventory, options.budgets?.maxTotalPublicBytes), ...dependencyFanoutBudgetFailures(dataFlow, options.budgets?.maxDependencyFanout));
1000
+ const shouldCheckTransferSidecars = shouldCheckPrecompressedSidecars(options);
1001
+ const transferSidecars = shouldCheckTransferSidecars
1002
+ ? await collectTransferSidecarMetrics(outDir)
1003
+ : emptyTransferSidecarSummary();
1004
+ budgetFailures.push(...compressedTransferBudgetFailures(transferSidecars, "brotli", options.budgets?.maxBrotliBytes), ...compressedTransferBudgetFailures(transferSidecars, "gzip", options.budgets?.maxGzipBytes));
1005
+ const stylesheetAssets = await collectStylesheetAssetMetrics(outDir);
1006
+ const publicCssFiles = collectPublicFileTypeMetrics(publicFileInventory.files, "css");
1007
+ const publicJavaScriptFiles = collectPublicFileTypeMetrics(publicFileInventory.files, "js");
1008
+ const imageAssets = collectImageAssetMetrics(publicFileInventory.files);
1009
+ budgetFailures.push(...imageBudgetFailures(imageAssets, options.budgets?.maxImageBytes));
1010
+ const prefetchReferenceIssues = manifest
1011
+ ? await validateHtmlPrefetchReferences(outDir, manifest)
1012
+ : [];
1013
+ const manifestOutputIssues = manifest ? await validateManifestOutputs(outDir, manifest) : [];
1014
+ const orphanHtmlIssues = manifest ? await validateOrphanHtmlOutputs(outDir, manifest) : [];
1015
+ const dataFlowIssues = dataFlowReportIssues(dataFlow, options.budgets);
1016
+ const validationIssues = dedupeIssues([
1017
+ ...(report.validation?.issues ?? []),
1018
+ ...missingManifestIssues(manifest),
1019
+ ...sitemapDrift.missing.map((path) => ({
1020
+ code: "sitemap.missing_url",
1021
+ path,
1022
+ message: `${path} is indexable but missing from sitemap.xml.`,
1023
+ })),
1024
+ ...sitemapDrift.extra.map((path) => ({
1025
+ code: "sitemap.extra_url",
1026
+ path,
1027
+ message: `${path} is present in sitemap.xml but not in the generated manifest.`,
1028
+ })),
1029
+ ...budgetFailures.map((failure) => ({
1030
+ code: budgetFailureCode(failure),
1031
+ path: failure.path,
1032
+ bytes: failure.bytes,
1033
+ ...(failure.dependency ? { dependency: failure.dependency } : {}),
1034
+ ...(failure.outputs !== undefined ? { outputs: failure.outputs } : {}),
1035
+ limit: failure.limit,
1036
+ message: budgetFailureMessage(failure),
1037
+ })),
1038
+ ...publicDataExportIssues,
1039
+ ...headersManifestIssues,
1040
+ ...redirectManifestIssues,
1041
+ ...robotsTxtIssues,
1042
+ ...deployArtifactIssues,
1043
+ ...htmlMetadataIssues,
1044
+ ...hydrationPayloadIssues,
1045
+ ...missingAssetReferences,
1046
+ ...prefetchReferenceIssues,
1047
+ ...manifestOutputIssues,
1048
+ ...orphanHtmlIssues,
1049
+ ...precompressionIssues(transferSidecars, options.requirePrecompressed),
1050
+ ...dataFlowIssues,
1051
+ ...sitemapResult.issues,
1052
+ ]);
1053
+ const publicDataExports = publicDataExportTargets.length;
1054
+ return {
1055
+ ok: report.missingLinks.length === 0 && validationIssues.length === 0,
1056
+ pageCount: report.pageCount,
1057
+ artifactCount: report.artifactCount ?? 0,
1058
+ publicAssetCount: report.publicAssets?.length ?? 0,
1059
+ publicAssetsCopied: report.publicAssetsCopied ?? 0,
1060
+ publicAssetsSkipped: report.publicAssetsSkipped ?? 0,
1061
+ staleOutputsRemoved: report.staleOutputsRemoved ?? 0,
1062
+ publicFileCount: publicFileInventory.files.length,
1063
+ publicFileBytes: publicFileInventory.totalBytes,
1064
+ transferSidecars,
1065
+ stylesheetAssetCount: stylesheetAssets.items.length,
1066
+ stylesheetAssetBytes: stylesheetAssets.totalBytes,
1067
+ publicCssFileCount: publicCssFiles.items.length,
1068
+ publicCssFileBytes: publicCssFiles.totalBytes,
1069
+ publicJavaScriptFileCount: publicJavaScriptFiles.items.length,
1070
+ publicJavaScriptFileBytes: publicJavaScriptFiles.totalBytes,
1071
+ imageAssetCount: imageAssets.items.length,
1072
+ imageAssetBytes: imageAssets.totalBytes,
1073
+ missingLinks: report.missingLinks,
1074
+ sitemapUrls: sitemapResult.paths.length,
1075
+ sitemapFiles: report.sitemapFiles ?? sitemapResult.files,
1076
+ sitemapFileIssues: sitemapResult.issues,
1077
+ publicDataExports,
1078
+ publicDataExportIssues,
1079
+ headersManifestIssues,
1080
+ redirectManifestIssues,
1081
+ robotsTxtIssues,
1082
+ deployArtifactIssues,
1083
+ htmlMetadataIssues,
1084
+ hydrationPayloadIssues,
1085
+ missingAssetReferences,
1086
+ prefetchReferenceIssues,
1087
+ manifestOutputIssues,
1088
+ orphanHtmlIssues,
1089
+ largestHtml: report.largestHtml,
1090
+ largestArtifacts: report.largestArtifacts ?? [],
1091
+ largestPublicFiles: publicFileInventory.files.slice(0, 20),
1092
+ publicFileTypes: publicFileInventory.types,
1093
+ largestStylesheets: stylesheetAssets.items.slice(0, 20),
1094
+ largestPublicCssFiles: publicCssFiles.items.slice(0, 20),
1095
+ largestPublicJavaScriptFiles: publicJavaScriptFiles.items.slice(0, 20),
1096
+ largestImages: imageAssets.items.slice(0, 20),
1097
+ routeFamilies: report.routeFamilies,
1098
+ routeWrites: report.routeWrites ?? {},
1099
+ artifactFamilies: report.artifactFamilies ?? {},
1100
+ artifactWrites: report.artifactWrites ?? {},
1101
+ ...(dataFlow ? { dataFlow: summarizeDataFlow(dataFlow) } : {}),
1102
+ ...validationOptionsSummary(options),
1103
+ validationIssues,
1104
+ budgetFailures,
1105
+ sitemapDrift,
1106
+ };
1107
+ }
1108
+ async function readRequiredGeneratedManifest(outDir) {
1109
+ const manifest = await readGeneratedManifest(join(outDir, ".stoneage/manifest.json"));
1110
+ if (!manifest) {
1111
+ throw new Error(`Missing or unreadable generated manifest in ${outDir}.`);
1112
+ }
1113
+ return manifest;
1114
+ }
1115
+ function missingManifestIssues(manifest) {
1116
+ return manifest
1117
+ ? []
1118
+ : [
1119
+ {
1120
+ code: "manifest.missing",
1121
+ path: ".stoneage/manifest.json",
1122
+ message: ".stoneage/manifest.json is missing. Run a StoneAge build before validating publish output.",
1123
+ },
1124
+ ];
1125
+ }
1126
+ async function readGeneratedManifest(path) {
1127
+ const raw = await readOptionalText(path);
1128
+ if (!raw) {
1129
+ return undefined;
1130
+ }
1131
+ const parsed = JSON.parse(raw);
1132
+ if (parsed.version === 1 && Array.isArray(parsed.pages)) {
1133
+ return {
1134
+ pages: parsed.pages.map((page) => ({
1135
+ route: page.route ?? "",
1136
+ path: page.path,
1137
+ outputPath: page.outputPath ?? outputPathForManifestPage(page.route ?? "", page.path),
1138
+ dependencies: page.dependencies ?? {},
1139
+ bytes: page.htmlBytes ?? 0,
1140
+ sitemap: page.sitemap !== false,
1141
+ })),
1142
+ artifacts: (parsed.artifacts ?? []).map((artifact) => ({
1143
+ route: artifact.route ?? "",
1144
+ path: artifact.path,
1145
+ outputPath: artifact.outputPath ?? outputPathForArtifactPath(artifact.path),
1146
+ dependencies: artifact.dependencies ?? {},
1147
+ bytes: artifact.bytes ?? 0,
1148
+ })),
1149
+ };
1150
+ }
1151
+ if ((parsed.version === 6 || parsed.version === 7 || parsed.version === 8) &&
1152
+ Array.isArray(parsed.pages) &&
1153
+ Array.isArray(parsed.strings)) {
1154
+ return {
1155
+ pages: parsed.pages.map(([route, path, dependencies, , bytes, sitemap]) => {
1156
+ const pagePath = parsed.strings[path] ?? "";
1157
+ const pageRoute = parsed.strings[route] ?? "";
1158
+ return {
1159
+ route: pageRoute,
1160
+ path: pagePath,
1161
+ outputPath: outputPathForManifestPage(pageRoute, pagePath),
1162
+ dependencies: readDependencies(parsed.strings, dependencies),
1163
+ bytes,
1164
+ sitemap: sitemap !== 0,
1165
+ };
1166
+ }),
1167
+ artifacts: (parsed.artifacts ?? []).map(([route, path, dependencies, bytes, metadata]) => {
1168
+ const artifactPath = parsed.strings[path] ?? "";
1169
+ return {
1170
+ route: parsed.strings[route] ?? "",
1171
+ path: artifactPath,
1172
+ outputPath: outputPathForArtifactPath(artifactPath),
1173
+ dependencies: readDependencies(parsed.strings, dependencies),
1174
+ bytes,
1175
+ ...(metadata?.contentType ? { contentType: metadata.contentType } : {}),
1176
+ ...(metadata?.status !== undefined ? { status: metadata.status } : {}),
1177
+ ...(metadata?.headers ? { headers: metadata.headers } : {}),
1178
+ };
1179
+ }),
1180
+ };
1181
+ }
1182
+ return undefined;
1183
+ }
1184
+ async function readDataFlowReport(path) {
1185
+ const raw = await readOptionalText(path);
1186
+ if (!raw) {
1187
+ return undefined;
1188
+ }
1189
+ const parsed = JSON.parse(raw);
1190
+ return parsed.version === 1 ? parsed : undefined;
1191
+ }
1192
+ async function readSplitDataFlowReport(outDir) {
1193
+ const [summary, dependencies] = await Promise.all([
1194
+ readDataFlowSummaryReport(join(outDir, ".stoneage/data-flow-summary.json")),
1195
+ readDataFlowDependenciesReport(join(outDir, ".stoneage/data-flow-dependencies.json")),
1196
+ ]);
1197
+ if (!summary || !dependencies) {
1198
+ return undefined;
1199
+ }
1200
+ return {
1201
+ version: 1,
1202
+ outputs: summary.outputs,
1203
+ routeFamilies: summary.routeFamilies,
1204
+ artifactFamilies: summary.artifactFamilies,
1205
+ dependencies: dependencies.dependencies,
1206
+ };
1207
+ }
1208
+ async function readDataFlowSummaryReport(path) {
1209
+ const raw = await readOptionalText(path);
1210
+ if (!raw) {
1211
+ return undefined;
1212
+ }
1213
+ const parsed = JSON.parse(raw);
1214
+ return parsed.version === 1 ? parsed : undefined;
1215
+ }
1216
+ async function readDataFlowDependenciesReport(path) {
1217
+ const raw = await readOptionalText(path);
1218
+ if (!raw) {
1219
+ return undefined;
1220
+ }
1221
+ const parsed = JSON.parse(raw);
1222
+ return parsed.version === 1 ? parsed : undefined;
1223
+ }
1224
+ function summarizeDataFlow(dataFlow) {
1225
+ return {
1226
+ outputs: dataFlow.outputs,
1227
+ dependencyCount: dataFlow.dependencies.length,
1228
+ largestDependencyFanout: dataFlow.dependencies.slice(0, 10),
1229
+ routeFamilies: dataFlow.routeFamilies,
1230
+ artifactFamilies: dataFlow.artifactFamilies,
1231
+ };
1232
+ }
1233
+ function dataFlowReportFromManifest(manifest) {
1234
+ return {
1235
+ version: 1,
1236
+ outputs: {
1237
+ pages: manifest.pages.length,
1238
+ artifacts: manifest.artifacts.length,
1239
+ total: manifest.pages.length + manifest.artifacts.length,
1240
+ },
1241
+ routeFamilies: dataFlowFamiliesFromOutputs(manifest.pages, (page) => page.route, (page) => page.bytes),
1242
+ artifactFamilies: dataFlowFamiliesFromOutputs(manifest.artifacts, (artifact) => artifact.route, (artifact) => artifact.bytes),
1243
+ dependencies: dataFlowDependenciesFromManifest(manifest),
1244
+ };
1245
+ }
1246
+ function dataFlowFamiliesFromOutputs(outputs, routeOf, bytesOf) {
1247
+ const summaries = new Map();
1248
+ for (const output of outputs) {
1249
+ const name = routeOf(output);
1250
+ const summary = summaries.get(name) ??
1251
+ {
1252
+ name,
1253
+ outputs: 0,
1254
+ written: 0,
1255
+ skipped: 0,
1256
+ bytes: 0,
1257
+ };
1258
+ summary.outputs += 1;
1259
+ summary.skipped += 1;
1260
+ summary.bytes += bytesOf(output);
1261
+ summaries.set(name, summary);
1262
+ }
1263
+ return [...summaries.values()].sort((left, right) => left.name.localeCompare(right.name));
1264
+ }
1265
+ function dataFlowDependenciesFromManifest(manifest) {
1266
+ const summaries = new Map();
1267
+ for (const page of manifest.pages) {
1268
+ for (const key of visibleDependencyKeys(page.dependencies)) {
1269
+ const summary = dataFlowDependencySummaryFor(summaries, key);
1270
+ summary.outputs += 1;
1271
+ summary.pages += 1;
1272
+ summary.routes[page.route] = (summary.routes[page.route] ?? 0) + 1;
1273
+ }
1274
+ }
1275
+ for (const artifact of manifest.artifacts) {
1276
+ for (const key of visibleDependencyKeys(artifact.dependencies)) {
1277
+ const summary = dataFlowDependencySummaryFor(summaries, key);
1278
+ summary.outputs += 1;
1279
+ summary.artifacts += 1;
1280
+ summary.artifactRoutes[artifact.route] =
1281
+ (summary.artifactRoutes[artifact.route] ?? 0) + 1;
1282
+ }
1283
+ }
1284
+ return [...summaries.values()].sort((left, right) => right.outputs - left.outputs || left.key.localeCompare(right.key));
1285
+ }
1286
+ function visibleDependencyKeys(dependencies) {
1287
+ return Object.keys(dependencies).filter((key) => !key.startsWith("\0stoneage:")).sort();
1288
+ }
1289
+ function dataFlowDependencySummaryFor(summaries, key) {
1290
+ const existing = summaries.get(key);
1291
+ if (existing) {
1292
+ return existing;
1293
+ }
1294
+ const summary = {
1295
+ key,
1296
+ outputs: 0,
1297
+ pages: 0,
1298
+ artifacts: 0,
1299
+ routes: {},
1300
+ artifactRoutes: {},
1301
+ };
1302
+ summaries.set(key, summary);
1303
+ return summary;
1304
+ }
1305
+ function readDependencies(strings, dependencies) {
1306
+ return Object.fromEntries(dependencies.map(([key, hash]) => [strings[key] ?? "", hash]));
1307
+ }
1308
+ function planOutputFor(kind, output, requestedDependencies) {
1309
+ const matchingDependencies = Object.fromEntries(requestedDependencies.flatMap((dependency) => {
1310
+ const hash = output.dependencies[dependency];
1311
+ return hash === undefined ? [] : [[dependency, hash]];
1312
+ }));
1313
+ return Object.keys(matchingDependencies).length === 0
1314
+ ? []
1315
+ : [
1316
+ {
1317
+ kind,
1318
+ route: output.route,
1319
+ path: output.path,
1320
+ outputPath: output.outputPath,
1321
+ dependencies: matchingDependencies,
1322
+ },
1323
+ ];
1324
+ }
1325
+ function uniqueStrings(values) {
1326
+ return Array.from(new Set(values));
1327
+ }
1328
+ async function validateManifestOutputs(outDir, manifest) {
1329
+ const outputs = [
1330
+ ...manifest.pages.map((page) => ({
1331
+ kind: "HTML",
1332
+ path: page.outputPath,
1333
+ expectedBytes: page.bytes,
1334
+ })),
1335
+ ...manifest.artifacts.map((artifact) => ({
1336
+ kind: "artifact",
1337
+ path: artifact.outputPath,
1338
+ expectedBytes: artifact.bytes,
1339
+ })),
1340
+ ];
1341
+ const issues = [];
1342
+ issues.push(...duplicateManifestOutputIssues(outputs));
1343
+ for (const output of outputs) {
1344
+ let outputStat;
1345
+ try {
1346
+ outputStat = await stat(join(outDir, output.path));
1347
+ }
1348
+ catch (error) {
1349
+ if (isMissingPath(error)) {
1350
+ issues.push({
1351
+ code: "output.missing",
1352
+ path: output.path,
1353
+ message: `${output.kind} output ${output.path} is listed in the manifest but missing from disk.`,
1354
+ });
1355
+ continue;
1356
+ }
1357
+ throw error;
1358
+ }
1359
+ if (outputStat.size !== output.expectedBytes) {
1360
+ issues.push({
1361
+ code: "output.size_mismatch",
1362
+ path: output.path,
1363
+ bytes: outputStat.size,
1364
+ limit: output.expectedBytes,
1365
+ message: `${output.kind} output ${output.path} is ${outputStat.size} bytes, expected ${output.expectedBytes} bytes from the manifest.`,
1366
+ });
1367
+ }
1368
+ }
1369
+ return issues;
1370
+ }
1371
+ function duplicateManifestOutputIssues(outputs) {
1372
+ const firstByPath = new Map();
1373
+ const duplicatePaths = new Set();
1374
+ for (const output of outputs) {
1375
+ if (firstByPath.has(output.path)) {
1376
+ duplicatePaths.add(output.path);
1377
+ continue;
1378
+ }
1379
+ firstByPath.set(output.path, output);
1380
+ }
1381
+ return [...duplicatePaths].sort().map((path) => ({
1382
+ code: "output.duplicate_path",
1383
+ path,
1384
+ message: `Manifest lists multiple generated outputs for ${path}.`,
1385
+ }));
1386
+ }
1387
+ async function validateOrphanHtmlOutputs(outDir, manifest) {
1388
+ const manifestHtml = new Set([...manifest.pages, ...manifest.artifacts]
1389
+ .map((output) => output.outputPath)
1390
+ .filter((path) => path.endsWith(".html")));
1391
+ const files = await listPublicFiles(outDir);
1392
+ return files
1393
+ .filter((file) => file.endsWith(".html") && !manifestHtml.has(file))
1394
+ .map((file) => ({
1395
+ code: "output.orphan_html",
1396
+ path: file,
1397
+ message: `${file} is an HTML file in the public output directory but is not listed in the manifest.`,
1398
+ }));
1399
+ }
1400
+ async function validateHeadersManifest(outDir, manifest, publicAssets = []) {
1401
+ const raw = await readOptionalText(join(outDir, "headers.json"));
1402
+ if (!raw) {
1403
+ return [];
1404
+ }
1405
+ let parsed;
1406
+ try {
1407
+ parsed = JSON.parse(raw);
1408
+ }
1409
+ catch (error) {
1410
+ return [
1411
+ {
1412
+ code: "headers.invalid_json",
1413
+ path: "headers.json",
1414
+ message: `headers.json is not valid JSON: ${errorMessage(error)}.`,
1415
+ },
1416
+ ];
1417
+ }
1418
+ if (!isHeadersManifest(parsed)) {
1419
+ return [
1420
+ {
1421
+ code: "headers.invalid_shape",
1422
+ path: "headers.json",
1423
+ message: "headers.json must contain a headers array with path and headers entries.",
1424
+ },
1425
+ ];
1426
+ }
1427
+ const issues = [];
1428
+ const expected = expectedHeadersManifestEntries(manifest);
1429
+ const managedPublicAssets = new Set(publicAssets);
1430
+ const actual = new Map();
1431
+ const duplicatePaths = new Set();
1432
+ for (const entry of parsed.headers) {
1433
+ if (actual.has(entry.path)) {
1434
+ duplicatePaths.add(entry.path);
1435
+ continue;
1436
+ }
1437
+ actual.set(entry.path, entry.headers);
1438
+ }
1439
+ for (const path of [...duplicatePaths].sort()) {
1440
+ issues.push({
1441
+ code: "headers.duplicate_path",
1442
+ path,
1443
+ message: `headers.json lists ${path} more than once.`,
1444
+ });
1445
+ }
1446
+ for (const path of [...actual.keys()].sort()) {
1447
+ if (!expected.has(path) && !managedPublicAssets.has(path)) {
1448
+ issues.push({
1449
+ code: "headers.unexpected_path",
1450
+ path,
1451
+ message: `headers.json includes ${path}, but no generated artifact or managed public asset declares response headers for that path.`,
1452
+ });
1453
+ }
1454
+ }
1455
+ for (const [path, expectedHeaders] of [...expected.entries()].sort(([left], [right]) => left.localeCompare(right))) {
1456
+ const actualHeaders = actual.get(path);
1457
+ if (!actualHeaders) {
1458
+ issues.push({
1459
+ code: "headers.missing_path",
1460
+ path,
1461
+ message: `headers.json is missing response headers for generated artifact ${path}.`,
1462
+ });
1463
+ continue;
1464
+ }
1465
+ if (stableHeaderJson(actualHeaders) !== stableHeaderJson(expectedHeaders)) {
1466
+ issues.push({
1467
+ code: "headers.header_mismatch",
1468
+ path,
1469
+ message: `headers.json headers for ${path} do not match generated artifact response metadata.`,
1470
+ });
1471
+ }
1472
+ }
1473
+ return issues;
1474
+ }
1475
+ function expectedHeadersManifestEntries(manifest) {
1476
+ const entries = new Map();
1477
+ for (const artifact of manifest.artifacts) {
1478
+ const headers = {
1479
+ ...(artifact.contentType ? { "content-type": artifact.contentType } : {}),
1480
+ ...(artifact.headers ?? {}),
1481
+ };
1482
+ if (Object.keys(headers).length > 0) {
1483
+ entries.set(artifact.path, headers);
1484
+ }
1485
+ }
1486
+ return entries;
1487
+ }
1488
+ function isHeadersManifest(value) {
1489
+ if (!value || typeof value !== "object" || !("headers" in value)) {
1490
+ return false;
1491
+ }
1492
+ const headers = value.headers;
1493
+ return Array.isArray(headers) && headers.every(isHeadersManifestEntry);
1494
+ }
1495
+ function isHeadersManifestEntry(value) {
1496
+ if (!value || typeof value !== "object") {
1497
+ return false;
1498
+ }
1499
+ const entry = value;
1500
+ return (typeof entry.path === "string" &&
1501
+ Boolean(entry.path) &&
1502
+ isStringRecord(entry.headers));
1503
+ }
1504
+ function isStringRecord(value) {
1505
+ return (value !== null &&
1506
+ typeof value === "object" &&
1507
+ !Array.isArray(value) &&
1508
+ Object.values(value).every((item) => typeof item === "string"));
1509
+ }
1510
+ function stableHeaderJson(headers) {
1511
+ return JSON.stringify(Object.fromEntries(Object.entries(headers).sort(([left], [right]) => left.localeCompare(right))));
1512
+ }
1513
+ async function validateRedirectManifest(outDir, manifest) {
1514
+ const raw = await readOptionalText(join(outDir, "redirects.json"));
1515
+ if (!raw) {
1516
+ return [];
1517
+ }
1518
+ let parsed;
1519
+ try {
1520
+ parsed = JSON.parse(raw);
1521
+ }
1522
+ catch (error) {
1523
+ return [
1524
+ {
1525
+ code: "redirects.invalid_json",
1526
+ path: "redirects.json",
1527
+ message: `redirects.json is not valid JSON: ${errorMessage(error)}.`,
1528
+ },
1529
+ ];
1530
+ }
1531
+ if (!isRedirectsManifest(parsed)) {
1532
+ return [
1533
+ {
1534
+ code: "redirects.invalid_shape",
1535
+ path: "redirects.json",
1536
+ message: "redirects.json must contain a redirects array with from, to, and status entries.",
1537
+ },
1538
+ ];
1539
+ }
1540
+ const issues = [];
1541
+ const generatedTargets = new Set([
1542
+ ...manifest.pages.map((page) => page.path),
1543
+ ...manifest.artifacts.map((artifact) => artifact.path),
1544
+ ]);
1545
+ const fromPaths = new Set();
1546
+ const duplicateFromPaths = new Set();
1547
+ for (const redirect of parsed.redirects) {
1548
+ if (fromPaths.has(redirect.from)) {
1549
+ duplicateFromPaths.add(redirect.from);
1550
+ }
1551
+ fromPaths.add(redirect.from);
1552
+ if (!isRedirectStatus(redirect.status)) {
1553
+ issues.push({
1554
+ code: "redirects.invalid_status",
1555
+ path: redirect.from,
1556
+ message: `${redirect.from} has unsupported redirect status ${redirect.status}.`,
1557
+ });
1558
+ }
1559
+ if (isLocalPublicPath(redirect.from) && generatedTargets.has(redirect.from)) {
1560
+ issues.push({
1561
+ code: "redirects.from_collides_output",
1562
+ path: redirect.from,
1563
+ message: `${redirect.from} is both a redirect source and a generated output path.`,
1564
+ });
1565
+ }
1566
+ const localTarget = localRedirectTarget(redirect.to);
1567
+ if (localTarget && !generatedTargets.has(localTarget)) {
1568
+ issues.push({
1569
+ code: "redirects.missing_target",
1570
+ path: redirect.from,
1571
+ message: `${redirect.from} redirects to missing local target ${localTarget}.`,
1572
+ });
1573
+ }
1574
+ }
1575
+ for (const path of [...duplicateFromPaths].sort()) {
1576
+ issues.push({
1577
+ code: "redirects.duplicate_from",
1578
+ path,
1579
+ message: `redirects.json lists redirect source ${path} more than once.`,
1580
+ });
1581
+ }
1582
+ return issues;
1583
+ }
1584
+ function isRedirectsManifest(value) {
1585
+ if (!value || typeof value !== "object" || !("redirects" in value)) {
1586
+ return false;
1587
+ }
1588
+ const redirects = value.redirects;
1589
+ return Array.isArray(redirects) && redirects.every(isRedirectManifestEntry);
1590
+ }
1591
+ function isRedirectManifestEntry(value) {
1592
+ if (!value || typeof value !== "object") {
1593
+ return false;
1594
+ }
1595
+ const entry = value;
1596
+ return (typeof entry.from === "string" &&
1597
+ Boolean(entry.from) &&
1598
+ typeof entry.to === "string" &&
1599
+ Boolean(entry.to) &&
1600
+ typeof entry.status === "number");
1601
+ }
1602
+ function isRedirectStatus(status) {
1603
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
1604
+ }
1605
+ function localRedirectTarget(value) {
1606
+ if (!isLocalPublicPath(value)) {
1607
+ return undefined;
1608
+ }
1609
+ const [path] = value.split(/[?#]/, 1);
1610
+ return path || undefined;
1611
+ }
1612
+ function isLocalPublicPath(value) {
1613
+ return value.startsWith("/") && !value.startsWith("//");
1614
+ }
1615
+ async function validateRobotsTxt(outDir) {
1616
+ const raw = await readOptionalText(join(outDir, "robots.txt"));
1617
+ if (!raw) {
1618
+ return [];
1619
+ }
1620
+ const publicFiles = new Set(await listPublicFiles(outDir));
1621
+ const issues = [];
1622
+ for (const sitemap of readRobotsSitemaps(raw)) {
1623
+ let parsed;
1624
+ try {
1625
+ parsed = new URL(sitemap);
1626
+ }
1627
+ catch {
1628
+ issues.push({
1629
+ code: "robots.invalid_sitemap_url",
1630
+ path: "robots.txt",
1631
+ message: `robots.txt contains invalid Sitemap URL ${JSON.stringify(sitemap)}.`,
1632
+ });
1633
+ continue;
1634
+ }
1635
+ const outputPath = parsed.pathname.replace(/^\/+/, "");
1636
+ if (isGeneratedSitemapFile(outputPath) && !publicFiles.has(outputPath)) {
1637
+ issues.push({
1638
+ code: "robots.missing_sitemap",
1639
+ path: outputPath,
1640
+ message: `robots.txt references missing sitemap file ${outputPath}.`,
1641
+ });
1642
+ }
1643
+ }
1644
+ return issues;
1645
+ }
1646
+ function readRobotsSitemaps(robots) {
1647
+ return robots
1648
+ .split(/\r?\n/)
1649
+ .map((line) => line.trim())
1650
+ .flatMap((line) => {
1651
+ const match = line.match(/^sitemap:\s*(\S.*)$/i);
1652
+ return match ? [match[1]] : [];
1653
+ });
1654
+ }
1655
+ function isGeneratedSitemapFile(path) {
1656
+ return path === "sitemap.xml" || /^sitemap-\d+\.xml$/.test(path);
1657
+ }
1658
+ async function validateDeployArtifacts(outDir, provider = "netlify") {
1659
+ // GitHub Pages relies on redirect HTML stubs and CNAME, not Netlify
1660
+ // _redirects/_headers, so skip the Netlify-specific artifact checks.
1661
+ if (provider === "github-pages") {
1662
+ return [];
1663
+ }
1664
+ const issues = [];
1665
+ const redirectsArtifact = await readOptionalText(join(outDir, "_redirects"));
1666
+ const headersArtifact = await readOptionalText(join(outDir, "_headers"));
1667
+ const expectedRedirects = await expectedNetlifyRedirects(outDir);
1668
+ const expectedHeaders = await expectedNetlifyHeaders(outDir);
1669
+ if (redirectsArtifact === undefined) {
1670
+ if (expectedRedirects.manifestExists && expectedRedirects.content !== undefined) {
1671
+ issues.push({
1672
+ code: "deploy.netlify_redirects_missing",
1673
+ path: "_redirects",
1674
+ message: "_redirects is missing for redirects.json. Run deploy --provider netlify.",
1675
+ });
1676
+ }
1677
+ }
1678
+ else {
1679
+ if (expectedRedirects.content !== undefined && redirectsArtifact !== expectedRedirects.content) {
1680
+ issues.push({
1681
+ code: "deploy.netlify_redirects_mismatch",
1682
+ path: "_redirects",
1683
+ message: "_redirects does not match redirects.json. Run deploy --provider netlify again.",
1684
+ });
1685
+ }
1686
+ }
1687
+ if (headersArtifact === undefined) {
1688
+ if (expectedHeaders.manifestExists && expectedHeaders.content !== undefined) {
1689
+ issues.push({
1690
+ code: "deploy.netlify_headers_missing",
1691
+ path: "_headers",
1692
+ message: "_headers is missing for headers.json. Run deploy --provider netlify.",
1693
+ });
1694
+ }
1695
+ }
1696
+ else {
1697
+ if (expectedHeaders.content !== undefined && headersArtifact !== expectedHeaders.content) {
1698
+ issues.push({
1699
+ code: "deploy.netlify_headers_mismatch",
1700
+ path: "_headers",
1701
+ message: "_headers does not match headers.json. Run deploy --provider netlify again.",
1702
+ });
1703
+ }
1704
+ }
1705
+ return issues;
1706
+ }
1707
+ async function expectedNetlifyRedirects(outDir) {
1708
+ const raw = await readOptionalText(join(outDir, "redirects.json"));
1709
+ if (raw === undefined) {
1710
+ return { manifestExists: false };
1711
+ }
1712
+ if (!raw) {
1713
+ return { manifestExists: true, content: "" };
1714
+ }
1715
+ const parsed = parseOptionalJson(raw);
1716
+ if (!isRedirectsManifest(parsed)) {
1717
+ return { manifestExists: true };
1718
+ }
1719
+ return {
1720
+ manifestExists: true,
1721
+ content: parsed.redirects.length > 0 ? renderNetlifyRedirects(parsed.redirects) : "",
1722
+ };
1723
+ }
1724
+ async function expectedNetlifyHeaders(outDir) {
1725
+ const raw = await readOptionalText(join(outDir, "headers.json"));
1726
+ if (raw === undefined) {
1727
+ return { manifestExists: false };
1728
+ }
1729
+ if (!raw) {
1730
+ return { manifestExists: true, content: "" };
1731
+ }
1732
+ const parsed = parseOptionalJson(raw);
1733
+ if (!isHeadersManifest(parsed)) {
1734
+ return { manifestExists: true };
1735
+ }
1736
+ return {
1737
+ manifestExists: true,
1738
+ content: parsed.headers.length > 0 ? renderNetlifyHeaders(parsed.headers) : "",
1739
+ };
1740
+ }
1741
+ function parseOptionalJson(raw) {
1742
+ try {
1743
+ return JSON.parse(raw);
1744
+ }
1745
+ catch {
1746
+ return undefined;
1747
+ }
1748
+ }
1749
+ function outputPathForPagePath(path) {
1750
+ if (path === "/") {
1751
+ return "index.html";
1752
+ }
1753
+ if (path.endsWith("/")) {
1754
+ return posix.join(path.slice(1), "index.html");
1755
+ }
1756
+ return `${path.replace(/^\/+/, "")}.html`;
1757
+ }
1758
+ function outputPathForManifestPage(route, path) {
1759
+ // The synthetic not-found page is written to a fixed file at the output root,
1760
+ // so its output path is not derived from the page-path output rules.
1761
+ return route === notFoundRouteName ? notFoundOutputPath : outputPathForPagePath(path);
1762
+ }
1763
+ function outputPathForArtifactPath(path) {
1764
+ return path.replace(/^\/+/, "");
1765
+ }
1766
+ async function readSitemapPaths(outDir, sitemap) {
1767
+ if (!sitemap.includes("<sitemapindex")) {
1768
+ const locs = extractSitemapLocPaths(sitemap);
1769
+ return {
1770
+ paths: locs.paths,
1771
+ files: ["sitemap.xml"],
1772
+ issues: [...locs.issues, ...duplicateSitemapUrlIssues(locs.paths)],
1773
+ };
1774
+ }
1775
+ const files = ["sitemap.xml"];
1776
+ const paths = [];
1777
+ const indexLocs = extractSitemapLocPaths(sitemap);
1778
+ const issues = [...indexLocs.issues];
1779
+ for (const sitemapPath of indexLocs.paths) {
1780
+ const file = sitemapPath.replace(/^\//, "");
1781
+ files.push(file);
1782
+ const child = await readOptionalText(join(outDir, file));
1783
+ if (child) {
1784
+ const childLocs = extractSitemapLocPaths(child);
1785
+ paths.push(...childLocs.paths);
1786
+ issues.push(...childLocs.issues);
1787
+ continue;
1788
+ }
1789
+ issues.push({
1790
+ code: "sitemap.missing_file",
1791
+ path: file,
1792
+ message: `Sitemap index references missing child sitemap ${file}.`,
1793
+ });
1794
+ }
1795
+ return {
1796
+ paths: paths.sort(),
1797
+ files,
1798
+ issues: [...issues, ...duplicateSitemapUrlIssues(paths)],
1799
+ };
1800
+ }
1801
+ function extractSitemapLocPaths(sitemap) {
1802
+ const paths = [];
1803
+ const issues = [];
1804
+ const locPattern = /<loc>([^<]+)<\/loc>/g;
1805
+ let match;
1806
+ while ((match = locPattern.exec(sitemap)) !== null) {
1807
+ const loc = match[1];
1808
+ let path = loc;
1809
+ try {
1810
+ path = new URL(loc, "https://stoneage.local").pathname;
1811
+ }
1812
+ catch {
1813
+ // Preserve the original value in the path field when even fallback URL parsing fails.
1814
+ }
1815
+ try {
1816
+ const absolute = new URL(loc);
1817
+ paths.push(absolute.pathname);
1818
+ }
1819
+ catch {
1820
+ paths.push(path);
1821
+ issues.push({
1822
+ code: "sitemap.invalid_url",
1823
+ path,
1824
+ message: `Sitemap loc ${JSON.stringify(loc)} must be an absolute URL.`,
1825
+ });
1826
+ }
1827
+ }
1828
+ return {
1829
+ paths: paths.sort(),
1830
+ issues,
1831
+ };
1832
+ }
1833
+ function duplicateSitemapUrlIssues(paths) {
1834
+ const counts = new Map();
1835
+ for (const path of paths) {
1836
+ counts.set(path, (counts.get(path) ?? 0) + 1);
1837
+ }
1838
+ return [...counts.entries()]
1839
+ .filter(([, count]) => count > 1)
1840
+ .sort(([left], [right]) => left.localeCompare(right))
1841
+ .map(([path, count]) => ({
1842
+ code: "sitemap.duplicate_url",
1843
+ path,
1844
+ message: `${path} appears ${count} times across generated sitemap files.`,
1845
+ }));
1846
+ }
1847
+ function compareSitemapPaths(manifestPages, sitemapPaths) {
1848
+ const expected = new Set(manifestPages.filter((page) => page.sitemap).map((page) => page.path));
1849
+ const actual = new Set(sitemapPaths);
1850
+ return {
1851
+ missing: [...expected].filter((path) => !actual.has(path)).sort(),
1852
+ extra: [...actual].filter((path) => !expected.has(path)).sort(),
1853
+ };
1854
+ }
1855
+ function htmlBudgetFailures(largestHtml, limit) {
1856
+ if (limit === undefined) {
1857
+ return [];
1858
+ }
1859
+ return largestHtml
1860
+ .filter((page) => page.bytes > limit)
1861
+ .map((page) => ({
1862
+ kind: "html",
1863
+ path: page.path,
1864
+ bytes: page.bytes,
1865
+ limit,
1866
+ }));
1867
+ }
1868
+ function outputCountBudgetFailures(kind, path, count, limit) {
1869
+ if (limit === undefined || count <= limit) {
1870
+ return [];
1871
+ }
1872
+ return [
1873
+ {
1874
+ kind,
1875
+ path,
1876
+ bytes: count,
1877
+ limit,
1878
+ },
1879
+ ];
1880
+ }
1881
+ function routeFamilyPageBudgetFailures(routeFamilies, limits) {
1882
+ if (!limits) {
1883
+ return [];
1884
+ }
1885
+ return Object.entries(limits)
1886
+ .flatMap(([route, limit]) => {
1887
+ const count = routeFamilies[route] ?? 0;
1888
+ return count > limit
1889
+ ? [
1890
+ {
1891
+ kind: "route-family-pages",
1892
+ path: route,
1893
+ bytes: count,
1894
+ limit,
1895
+ },
1896
+ ]
1897
+ : [];
1898
+ })
1899
+ .sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
1900
+ }
1901
+ function artifactFamilyOutputBudgetFailures(artifactFamilies, limits) {
1902
+ if (!limits) {
1903
+ return [];
1904
+ }
1905
+ return Object.entries(limits)
1906
+ .flatMap(([family, limit]) => {
1907
+ const count = artifactFamilies[family] ?? 0;
1908
+ return count > limit
1909
+ ? [
1910
+ {
1911
+ kind: "artifact-family-outputs",
1912
+ path: family,
1913
+ bytes: count,
1914
+ limit,
1915
+ },
1916
+ ]
1917
+ : [];
1918
+ })
1919
+ .sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
1920
+ }
1921
+ async function assetBudgetFailures(outDir, kind, limit) {
1922
+ if (limit === undefined) {
1923
+ return [];
1924
+ }
1925
+ const files = await listPublicFiles(outDir);
1926
+ const failures = [];
1927
+ for (const file of files) {
1928
+ if (!file.endsWith(`.${kind}`)) {
1929
+ continue;
1930
+ }
1931
+ const fileStat = await stat(join(outDir, file));
1932
+ if (fileStat.size > limit) {
1933
+ failures.push({
1934
+ kind,
1935
+ path: file,
1936
+ bytes: fileStat.size,
1937
+ limit,
1938
+ });
1939
+ }
1940
+ }
1941
+ return failures;
1942
+ }
1943
+ function publicFileBudgetFailures(inventory, limit) {
1944
+ if (limit === undefined) {
1945
+ return [];
1946
+ }
1947
+ return inventory.files
1948
+ .filter((file) => file.bytes > limit)
1949
+ .map((file) => ({
1950
+ kind: "public-file",
1951
+ path: file.path,
1952
+ bytes: file.bytes,
1953
+ limit,
1954
+ }));
1955
+ }
1956
+ function totalPublicBudgetFailures(inventory, limit) {
1957
+ if (limit === undefined || inventory.totalBytes <= limit) {
1958
+ return [];
1959
+ }
1960
+ return [
1961
+ {
1962
+ kind: "total-public",
1963
+ path: ".",
1964
+ bytes: inventory.totalBytes,
1965
+ limit,
1966
+ },
1967
+ ];
1968
+ }
1969
+ function imageBudgetFailures(imageAssets, limit) {
1970
+ if (limit === undefined || imageAssets.totalBytes <= limit) {
1971
+ return [];
1972
+ }
1973
+ return [
1974
+ {
1975
+ kind: "image-total",
1976
+ path: ".",
1977
+ bytes: imageAssets.totalBytes,
1978
+ limit,
1979
+ },
1980
+ ];
1981
+ }
1982
+ function compressedTransferBudgetFailures(transferSidecars, kind, limit) {
1983
+ const bytes = kind === "brotli" ? transferSidecars.brotliBytes : transferSidecars.gzipBytes;
1984
+ if (limit === undefined || bytes <= limit) {
1985
+ return [];
1986
+ }
1987
+ return [
1988
+ {
1989
+ kind,
1990
+ path: ".",
1991
+ bytes,
1992
+ limit,
1993
+ },
1994
+ ];
1995
+ }
1996
+ function dependencyFanoutBudgetFailures(dataFlow, limit) {
1997
+ if (!dataFlow || limit === undefined) {
1998
+ return [];
1999
+ }
2000
+ return dataFlow.dependencies
2001
+ .filter((dependency) => dependency.outputs > limit)
2002
+ .map((dependency) => ({
2003
+ kind: "dependency-fanout",
2004
+ path: dependency.key,
2005
+ bytes: dependency.outputs,
2006
+ dependency: dependency.key,
2007
+ outputs: dependency.outputs,
2008
+ limit,
2009
+ }));
2010
+ }
2011
+ function dataFlowReportIssues(dataFlow, budgets) {
2012
+ if (dataFlow || budgets?.maxDependencyFanout === undefined) {
2013
+ return [];
2014
+ }
2015
+ return [
2016
+ {
2017
+ code: "data_flow.missing",
2018
+ message: "Missing split data-flow reports (.stoneage/data-flow-summary.json and .stoneage/data-flow-dependencies.json) or legacy .stoneage/data-flow.json for dependency fanout budget validation.",
2019
+ },
2020
+ ];
2021
+ }
2022
+ function precompressionIssues(transferSidecars, required) {
2023
+ if (!required) {
2024
+ return [];
2025
+ }
2026
+ return [
2027
+ ...transferSidecars.missingBrotliFiles.map((path) => ({
2028
+ code: "precompression.brotli_missing",
2029
+ path,
2030
+ message: `${path} is missing a Brotli sidecar. Run optimize before publishing.`,
2031
+ })),
2032
+ ...transferSidecars.missingGzipFiles.map((path) => ({
2033
+ code: "precompression.gzip_missing",
2034
+ path,
2035
+ message: `${path} is missing a Gzip sidecar. Run optimize before publishing.`,
2036
+ })),
2037
+ ];
2038
+ }
2039
+ function budgetFailureCode(failure) {
2040
+ if (failure.kind === "dependency-fanout") {
2041
+ return "budget.dependency_fanout";
2042
+ }
2043
+ if (failure.kind === "route-family-pages") {
2044
+ return "budget.route_family_pages";
2045
+ }
2046
+ if (failure.kind === "artifact-family-outputs") {
2047
+ return "budget.artifact_family_outputs";
2048
+ }
2049
+ if (failure.kind === "public-file") {
2050
+ return "budget.public_file";
2051
+ }
2052
+ if (failure.kind === "total-public") {
2053
+ return "budget.total_public";
2054
+ }
2055
+ if (failure.kind === "image-total") {
2056
+ return "budget.image_total";
2057
+ }
2058
+ return `budget.${failure.kind}`;
2059
+ }
2060
+ function budgetFailureMessage(failure) {
2061
+ if (failure.kind === "dependency-fanout") {
2062
+ return `${failure.dependency} fans out to ${failure.outputs} outputs, exceeding the ${failure.limit} output dependency fanout budget.`;
2063
+ }
2064
+ if (failure.kind === "total-public") {
2065
+ return `Public output is ${failure.bytes} bytes, exceeding the ${failure.limit} byte total public output budget.`;
2066
+ }
2067
+ if (failure.kind === "public-file") {
2068
+ return `${failure.path} is ${failure.bytes} bytes, exceeding the ${failure.limit} byte public file budget.`;
2069
+ }
2070
+ if (failure.kind === "pages") {
2071
+ return `Generated page count is ${failure.bytes}, exceeding the ${failure.limit} page budget.`;
2072
+ }
2073
+ if (failure.kind === "artifacts") {
2074
+ return `Generated artifact count is ${failure.bytes}, exceeding the ${failure.limit} artifact budget.`;
2075
+ }
2076
+ if (failure.kind === "route-family-pages") {
2077
+ return `${failure.path} generated ${failure.bytes} pages, exceeding the ${failure.limit} page route family budget.`;
2078
+ }
2079
+ if (failure.kind === "artifact-family-outputs") {
2080
+ return `${failure.path} generated ${failure.bytes} artifacts, exceeding the ${failure.limit} artifact family output budget.`;
2081
+ }
2082
+ if (failure.kind === "image-total") {
2083
+ return `Public images are ${failure.bytes} bytes, exceeding the ${failure.limit} byte image asset budget.`;
2084
+ }
2085
+ if (failure.kind === "brotli") {
2086
+ return `Brotli sidecars are ${failure.bytes} bytes, exceeding the ${failure.limit} byte Brotli transfer budget.`;
2087
+ }
2088
+ if (failure.kind === "gzip") {
2089
+ return `Gzip sidecars are ${failure.bytes} bytes, exceeding the ${failure.limit} byte Gzip transfer budget.`;
2090
+ }
2091
+ return `${failure.path} is ${failure.bytes} bytes, exceeding the ${failure.limit} byte ${failure.kind.toUpperCase()} budget.`;
2092
+ }
2093
+ async function validateHtmlAssetReferences(outDir) {
2094
+ const files = await listPublicFiles(outDir);
2095
+ const publicFiles = new Set(files);
2096
+ const issues = [];
2097
+ for (const file of files.filter((item) => item.endsWith(".html"))) {
2098
+ const html = await readFile(join(outDir, file), "utf8");
2099
+ for (const reference of extractHtmlAssetReferences(html, file)) {
2100
+ if (!publicFiles.has(reference.outputPath)) {
2101
+ issues.push({
2102
+ code: "asset.missing",
2103
+ path: file,
2104
+ message: `${file} references missing asset ${reference.href}.`,
2105
+ });
2106
+ }
2107
+ }
2108
+ }
2109
+ return issues;
2110
+ }
2111
+ async function collectPublicFileInventory(outDir) {
2112
+ const publicFiles = await listPublicFiles(outDir);
2113
+ const files = [];
2114
+ const types = new Map();
2115
+ for (const path of publicFiles.filter((file) => !isPrecompressedSidecarPath(file))) {
2116
+ const fileStat = await stat(join(outDir, path));
2117
+ const type = publicFileType(path);
2118
+ const bytes = fileStat.size;
2119
+ files.push({ path, bytes, type });
2120
+ const summary = types.get(type) ?? { type, files: 0, bytes: 0 };
2121
+ summary.files += 1;
2122
+ summary.bytes += bytes;
2123
+ types.set(type, summary);
2124
+ }
2125
+ files.sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
2126
+ const typeSummaries = [...types.values()].sort((left, right) => right.bytes - left.bytes || left.type.localeCompare(right.type));
2127
+ return {
2128
+ totalBytes: files.reduce((total, file) => total + file.bytes, 0),
2129
+ files,
2130
+ types: typeSummaries,
2131
+ };
2132
+ }
2133
+ function collectImageAssetMetrics(files) {
2134
+ const items = files.filter((file) => isImagePublicFileType(file.type));
2135
+ return {
2136
+ totalBytes: items.reduce((total, file) => total + file.bytes, 0),
2137
+ items,
2138
+ };
2139
+ }
2140
+ function collectPublicFileTypeMetrics(files, type) {
2141
+ const items = files.filter((file) => file.type === type);
2142
+ return {
2143
+ totalBytes: items.reduce((total, file) => total + file.bytes, 0),
2144
+ items,
2145
+ };
2146
+ }
2147
+ async function collectTransferSidecarMetrics(outDir) {
2148
+ const publicFiles = await listPublicFiles(outDir);
2149
+ const publicFileSet = new Set(publicFiles);
2150
+ const compressibleFiles = publicFiles
2151
+ .filter((file) => !isPrecompressedSidecarPath(file) && isCompressiblePublicPath(file))
2152
+ .sort();
2153
+ const missingBrotliFiles = [];
2154
+ const missingGzipFiles = [];
2155
+ let originalBytes = 0;
2156
+ let brotliBytes = 0;
2157
+ let gzipBytes = 0;
2158
+ let brotliFiles = 0;
2159
+ let gzipFiles = 0;
2160
+ for (const file of compressibleFiles) {
2161
+ originalBytes += (await stat(join(outDir, file))).size;
2162
+ const brotliPath = `${file}.br`;
2163
+ const gzipPath = `${file}.gz`;
2164
+ if (publicFileSet.has(brotliPath)) {
2165
+ brotliFiles += 1;
2166
+ brotliBytes += (await stat(join(outDir, brotliPath))).size;
2167
+ }
2168
+ else {
2169
+ missingBrotliFiles.push(file);
2170
+ }
2171
+ if (publicFileSet.has(gzipPath)) {
2172
+ gzipFiles += 1;
2173
+ gzipBytes += (await stat(join(outDir, gzipPath))).size;
2174
+ }
2175
+ else {
2176
+ missingGzipFiles.push(file);
2177
+ }
2178
+ }
2179
+ return {
2180
+ compressibleFiles: compressibleFiles.length,
2181
+ brotliFiles,
2182
+ gzipFiles,
2183
+ originalBytes,
2184
+ brotliBytes,
2185
+ gzipBytes,
2186
+ missingBrotliFiles,
2187
+ missingGzipFiles,
2188
+ };
2189
+ }
2190
+ function shouldCheckPrecompressedSidecars(options) {
2191
+ return Boolean(options.requirePrecompressed ||
2192
+ options.budgets?.maxBrotliBytes !== undefined ||
2193
+ options.budgets?.maxGzipBytes !== undefined);
2194
+ }
2195
+ function emptyTransferSidecarSummary() {
2196
+ return {
2197
+ compressibleFiles: 0,
2198
+ brotliFiles: 0,
2199
+ gzipFiles: 0,
2200
+ originalBytes: 0,
2201
+ brotliBytes: 0,
2202
+ gzipBytes: 0,
2203
+ missingBrotliFiles: [],
2204
+ missingGzipFiles: [],
2205
+ };
2206
+ }
2207
+ async function collectStylesheetAssetMetrics(outDir) {
2208
+ const files = await listPublicFiles(outDir);
2209
+ const references = new Map();
2210
+ for (const file of files.filter((item) => item.endsWith(".html"))) {
2211
+ const html = await readFile(join(outDir, file), "utf8");
2212
+ for (const outputPath of extractHtmlStylesheetOutputPaths(html, file)) {
2213
+ references.set(outputPath, (references.get(outputPath) ?? 0) + 1);
2214
+ }
2215
+ }
2216
+ const items = [];
2217
+ for (const [path, referenceCount] of references) {
2218
+ if (!isSafePublicOutputPath(path)) {
2219
+ continue;
2220
+ }
2221
+ let fileStat;
2222
+ try {
2223
+ fileStat = await stat(join(outDir, path));
2224
+ }
2225
+ catch (error) {
2226
+ if (isMissingPath(error)) {
2227
+ continue;
2228
+ }
2229
+ throw error;
2230
+ }
2231
+ items.push({
2232
+ path,
2233
+ bytes: fileStat.size,
2234
+ references: referenceCount,
2235
+ });
2236
+ }
2237
+ items.sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
2238
+ return {
2239
+ totalBytes: items.reduce((total, item) => total + item.bytes, 0),
2240
+ items,
2241
+ };
2242
+ }
2243
+ async function validateHtmlPrefetchReferences(outDir, manifest) {
2244
+ const files = await listPublicFiles(outDir);
2245
+ const existingTargets = new Set([
2246
+ ...manifest.pages.map((page) => page.path),
2247
+ ...manifest.artifacts.map((artifact) => artifact.path),
2248
+ ]);
2249
+ const issues = [];
2250
+ for (const file of files.filter((item) => item.endsWith(".html"))) {
2251
+ const html = await readFile(join(outDir, file), "utf8");
2252
+ for (const target of extractHtmlPrefetchTargets(html)) {
2253
+ if (!existingTargets.has(target)) {
2254
+ issues.push({
2255
+ code: "prefetch.missing_target",
2256
+ path: file,
2257
+ message: `${file} prefetches missing target ${target}.`,
2258
+ });
2259
+ }
2260
+ }
2261
+ }
2262
+ return issues;
2263
+ }
2264
+ async function validateHydrationPayloads(outDir) {
2265
+ const files = await listPublicFiles(outDir);
2266
+ const issues = [];
2267
+ for (const file of files.filter((item) => item.endsWith(".html"))) {
2268
+ const html = await readFile(join(outDir, file), "utf8");
2269
+ if (referencesRuntimeDataPayload(html)) {
2270
+ issues.push({
2271
+ code: "html.runtime_data_payload",
2272
+ path: file,
2273
+ message: `${file} references a runtime data payload such as __data.json. Generated pages should be readable as plain HTML.`,
2274
+ });
2275
+ }
2276
+ if (containsHydrationPayload(html)) {
2277
+ issues.push({
2278
+ code: "html.hydration_payload",
2279
+ path: file,
2280
+ message: `${file} contains framework hydration payload markers. Move runtime page data into build-time rendering or explicit artifacts.`,
2281
+ });
2282
+ }
2283
+ }
2284
+ return issues;
2285
+ }
2286
+ async function validateHtmlMetadata(outDir, manifest) {
2287
+ const issues = [];
2288
+ const canonicalPages = new Map();
2289
+ for (const page of manifest.pages) {
2290
+ const file = page.outputPath;
2291
+ let html;
2292
+ try {
2293
+ html = await readFile(join(outDir, file), "utf8");
2294
+ }
2295
+ catch (error) {
2296
+ if (isMissingPath(error)) {
2297
+ continue;
2298
+ }
2299
+ throw error;
2300
+ }
2301
+ if (!hasNonEmptyTitle(html)) {
2302
+ issues.push({
2303
+ code: "html.missing_title",
2304
+ path: file,
2305
+ message: `${file} is missing a non-empty <title> tag.`,
2306
+ });
2307
+ }
2308
+ if (!hasMetaNameContent(html, "description")) {
2309
+ issues.push({
2310
+ code: "html.missing_description",
2311
+ path: file,
2312
+ message: `${file} is missing a non-empty meta description.`,
2313
+ });
2314
+ }
2315
+ const canonicalHref = linkRelHref(html, "canonical");
2316
+ if (!canonicalHref) {
2317
+ issues.push({
2318
+ code: "html.missing_canonical",
2319
+ path: file,
2320
+ message: `${file} is missing a canonical link.`,
2321
+ });
2322
+ continue;
2323
+ }
2324
+ let canonicalUrl;
2325
+ try {
2326
+ canonicalUrl = new URL(canonicalHref);
2327
+ }
2328
+ catch {
2329
+ issues.push({
2330
+ code: "html.canonical_not_absolute",
2331
+ path: file,
2332
+ message: `${file} canonical URL must be absolute: ${canonicalHref}.`,
2333
+ });
2334
+ continue;
2335
+ }
2336
+ if (canonicalUrl.protocol !== "https:" && canonicalUrl.protocol !== "http:") {
2337
+ issues.push({
2338
+ code: "html.canonical_not_absolute",
2339
+ path: file,
2340
+ message: `${file} canonical URL must use http or https: ${canonicalHref}.`,
2341
+ });
2342
+ continue;
2343
+ }
2344
+ const canonicalPath = canonicalUrl.pathname;
2345
+ canonicalPages.set(canonicalUrl.href, [...(canonicalPages.get(canonicalUrl.href) ?? []), file]);
2346
+ if (canonicalPath !== page.path) {
2347
+ issues.push({
2348
+ code: "html.canonical_path_mismatch",
2349
+ path: file,
2350
+ message: `${file} canonical path ${canonicalPath} does not match manifest path ${page.path}.`,
2351
+ });
2352
+ }
2353
+ }
2354
+ for (const [canonical, files] of canonicalPages) {
2355
+ if (files.length < 2) {
2356
+ continue;
2357
+ }
2358
+ for (const file of files) {
2359
+ issues.push({
2360
+ code: "html.canonical_duplicate",
2361
+ path: file,
2362
+ message: `${file} shares canonical URL ${canonical} with ${files.length - 1} other page(s).`,
2363
+ });
2364
+ }
2365
+ }
2366
+ return issues;
2367
+ }
2368
+ function referencesRuntimeDataPayload(html) {
2369
+ const tagPattern = /<(a|link|script|img|source|iframe|form)\b([^>]*)>/gi;
2370
+ let match;
2371
+ while ((match = tagPattern.exec(html)) !== null) {
2372
+ const attrs = parseTagAttributes(match[2]);
2373
+ for (const attr of ["href", "src", "action"]) {
2374
+ if (isRuntimeDataPayloadPath(attrs.get(attr))) {
2375
+ return true;
2376
+ }
2377
+ }
2378
+ }
2379
+ return false;
2380
+ }
2381
+ function isRuntimeDataPayloadPath(value) {
2382
+ if (!value) {
2383
+ return false;
2384
+ }
2385
+ const [path] = value.split(/[?#]/, 1);
2386
+ return /(?:^|\/)__data\.json$/.test(path ?? "");
2387
+ }
2388
+ function containsHydrationPayload(html) {
2389
+ const tagPattern = /<([a-z][a-z0-9:-]*)\b([^>]*)>/gi;
2390
+ let match;
2391
+ while ((match = tagPattern.exec(html)) !== null) {
2392
+ const tag = match[1].toLowerCase();
2393
+ const attrs = parseTagAttributes(match[2]);
2394
+ const type = attrs.get("type")?.toLowerCase() ?? "";
2395
+ if (attrs.has("data-sveltekit-fetched") ||
2396
+ attrs.has("data-sveltekit-hydrate") ||
2397
+ attrs.has("data-next-page") ||
2398
+ attrs.has("data-nuxt-data")) {
2399
+ return true;
2400
+ }
2401
+ if (tag === "script" &&
2402
+ (attrs.get("id") === "__NEXT_DATA__" ||
2403
+ attrs.get("id") === "__NUXT_DATA__" ||
2404
+ (type === "application/json" &&
2405
+ (attrs.has("data-sveltekit-fetched") || attrs.has("data-sveltekit-hydrate"))))) {
2406
+ return true;
2407
+ }
2408
+ }
2409
+ return false;
2410
+ }
2411
+ function hasNonEmptyTitle(html) {
2412
+ const match = html.match(/<title\b[^>]*>(.*?)<\/title>/is);
2413
+ return (match?.[1] ?? "").trim() !== "";
2414
+ }
2415
+ function hasMetaNameContent(html, expectedName) {
2416
+ const tagPattern = /<meta\b([^>]*)>/gi;
2417
+ let match;
2418
+ while ((match = tagPattern.exec(html)) !== null) {
2419
+ const attrs = parseTagAttributes(match[1]);
2420
+ if (attrs.get("name")?.toLowerCase() === expectedName &&
2421
+ (attrs.get("content") ?? "").trim() !== "") {
2422
+ return true;
2423
+ }
2424
+ }
2425
+ return false;
2426
+ }
2427
+ function linkRelHref(html, expectedRel) {
2428
+ const tagPattern = /<link\b([^>]*)>/gi;
2429
+ let match;
2430
+ while ((match = tagPattern.exec(html)) !== null) {
2431
+ const attrs = parseTagAttributes(match[1]);
2432
+ const relTokens = new Set((attrs.get("rel") ?? "").toLowerCase().split(/\s+/).filter(Boolean));
2433
+ const href = (attrs.get("href") ?? "").trim();
2434
+ if (relTokens.has(expectedRel) && href !== "") {
2435
+ return href;
2436
+ }
2437
+ }
2438
+ return undefined;
2439
+ }
2440
+ function extractHtmlPrefetchTargets(html) {
2441
+ const targets = new Set();
2442
+ const tagPattern = /<(link|script)\b([^>]*)>/gi;
2443
+ let match;
2444
+ while ((match = tagPattern.exec(html)) !== null) {
2445
+ const tag = match[1].toLowerCase();
2446
+ const attrs = parseTagAttributes(match[2]);
2447
+ if (tag === "link") {
2448
+ const rel = attrs.get("rel")?.toLowerCase() ?? "";
2449
+ const relTokens = new Set(rel.split(/\s+/).filter(Boolean));
2450
+ if (relTokens.has("prefetch")) {
2451
+ const target = localPrefetchTarget(attrs.get("href"));
2452
+ if (target) {
2453
+ targets.add(target);
2454
+ }
2455
+ }
2456
+ continue;
2457
+ }
2458
+ for (const value of (attrs.get("data-prefetch") ?? "").split(/\s+/).filter(Boolean)) {
2459
+ const target = localPrefetchTarget(value);
2460
+ if (target) {
2461
+ targets.add(target);
2462
+ }
2463
+ }
2464
+ }
2465
+ return [...targets].sort();
2466
+ }
2467
+ function extractHtmlAssetReferences(html, htmlFile) {
2468
+ const references = [];
2469
+ const tagPattern = /<(link|script|img|source|video|meta)\b([^>]*)>/gi;
2470
+ let match;
2471
+ while ((match = tagPattern.exec(html)) !== null) {
2472
+ const tag = match[1].toLowerCase();
2473
+ const attrs = parseTagAttributes(match[2]);
2474
+ if (tag === "link") {
2475
+ const rel = attrs.get("rel")?.toLowerCase() ?? "";
2476
+ const relTokens = new Set(rel.split(/\s+/).filter(Boolean));
2477
+ const isAssetLink = relTokens.has("stylesheet") ||
2478
+ relTokens.has("icon") ||
2479
+ relTokens.has("apple-touch-icon") ||
2480
+ relTokens.has("manifest") ||
2481
+ relTokens.has("modulepreload") ||
2482
+ relTokens.has("preload");
2483
+ if (isAssetLink) {
2484
+ pushAssetReference(references, htmlFile, attrs.get("href"));
2485
+ }
2486
+ continue;
2487
+ }
2488
+ if (tag === "meta") {
2489
+ if (isSocialImageMeta(attrs)) {
2490
+ pushAssetReference(references, htmlFile, attrs.get("content"));
2491
+ }
2492
+ continue;
2493
+ }
2494
+ if (tag === "script") {
2495
+ pushAssetReference(references, htmlFile, attrs.get("src"));
2496
+ continue;
2497
+ }
2498
+ if (tag === "video") {
2499
+ pushAssetReference(references, htmlFile, attrs.get("poster"));
2500
+ continue;
2501
+ }
2502
+ pushAssetReference(references, htmlFile, attrs.get("src"));
2503
+ for (const candidate of parseSrcset(attrs.get("srcset"))) {
2504
+ pushAssetReference(references, htmlFile, candidate);
2505
+ }
2506
+ }
2507
+ return references;
2508
+ }
2509
+ function extractHtmlStylesheetOutputPaths(html, htmlFile) {
2510
+ const outputPaths = new Set();
2511
+ const tagPattern = /<link\b([^>]*)>/gi;
2512
+ let match;
2513
+ while ((match = tagPattern.exec(html)) !== null) {
2514
+ const attrs = parseTagAttributes(match[1]);
2515
+ const rel = attrs.get("rel")?.toLowerCase() ?? "";
2516
+ const relTokens = new Set(rel.split(/\s+/).filter(Boolean));
2517
+ if (!relTokens.has("stylesheet")) {
2518
+ continue;
2519
+ }
2520
+ const outputPath = localAssetOutputPath(attrs.get("href"), htmlFile);
2521
+ if (outputPath) {
2522
+ outputPaths.add(outputPath);
2523
+ }
2524
+ }
2525
+ return [...outputPaths];
2526
+ }
2527
+ function isSocialImageMeta(attrs) {
2528
+ const property = attrs.get("property")?.toLowerCase();
2529
+ const name = attrs.get("name")?.toLowerCase();
2530
+ return (property === "og:image" ||
2531
+ property === "og:image:url" ||
2532
+ property === "og:image:secure_url" ||
2533
+ name === "twitter:image" ||
2534
+ name === "twitter:image:src");
2535
+ }
2536
+ function parseTagAttributes(raw) {
2537
+ const attrs = new Map();
2538
+ const attrPattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
2539
+ let match;
2540
+ while ((match = attrPattern.exec(raw)) !== null) {
2541
+ attrs.set(match[1].toLowerCase(), match[2] ?? match[3] ?? match[4] ?? "");
2542
+ }
2543
+ return attrs;
2544
+ }
2545
+ function parseSrcset(srcset) {
2546
+ if (!srcset) {
2547
+ return [];
2548
+ }
2549
+ return srcset
2550
+ .split(",")
2551
+ .map((candidate) => candidate.trim().split(/\s+/, 1)[0])
2552
+ .filter((candidate) => Boolean(candidate));
2553
+ }
2554
+ function pushAssetReference(references, htmlFile, href) {
2555
+ const outputPath = localAssetOutputPath(href, htmlFile);
2556
+ if (!href || !outputPath) {
2557
+ return;
2558
+ }
2559
+ references.push({ href, outputPath });
2560
+ }
2561
+ function localAssetOutputPath(href, htmlFile) {
2562
+ if (!href || isIgnoredAssetUrl(href)) {
2563
+ return undefined;
2564
+ }
2565
+ const [path] = href.split(/[?#]/, 1);
2566
+ if (!path || path.endsWith("/")) {
2567
+ return undefined;
2568
+ }
2569
+ if (path.startsWith("/")) {
2570
+ return path.replace(/^\/+/, "");
2571
+ }
2572
+ return posix.normalize(posix.join(posix.dirname(htmlFile), path));
2573
+ }
2574
+ function localPrefetchTarget(href) {
2575
+ if (!href || isIgnoredAssetUrl(href)) {
2576
+ return undefined;
2577
+ }
2578
+ const [path] = href.split(/[?#]/, 1);
2579
+ if (!path || !path.startsWith("/") || path.startsWith("//")) {
2580
+ return undefined;
2581
+ }
2582
+ return path;
2583
+ }
2584
+ function isIgnoredAssetUrl(href) {
2585
+ return (href.startsWith("#") ||
2586
+ href.startsWith("//") ||
2587
+ /^(?:[a-z][a-z0-9+.-]*:)/i.test(href));
2588
+ }
2589
+ function isSafePublicOutputPath(path) {
2590
+ return path !== "" && !path.split("/").includes("..");
2591
+ }
2592
+ function publicFileType(path) {
2593
+ const extension = posix.extname(path).toLowerCase();
2594
+ return extension ? extension.slice(1) : "none";
2595
+ }
2596
+ const imagePublicFileTypes = new Set(["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"]);
2597
+ function isImagePublicFileType(type) {
2598
+ return imagePublicFileTypes.has(type);
2599
+ }
2600
+ function isCompressiblePublicPath(path) {
2601
+ return /\.(?:html|css|js|xml|json|csv|txt|svg)$/i.test(path);
2602
+ }
2603
+ function isPrecompressedSidecarPath(path) {
2604
+ return /\.(?:br|gz)$/i.test(path);
2605
+ }
2606
+ async function listPublicFiles(outDir, dir = outDir) {
2607
+ let entries;
2608
+ try {
2609
+ entries = await readdir(dir, { withFileTypes: true });
2610
+ }
2611
+ catch (error) {
2612
+ if (isMissingPath(error)) {
2613
+ return [];
2614
+ }
2615
+ throw error;
2616
+ }
2617
+ const files = [];
2618
+ for (const entry of entries) {
2619
+ if (entry.name === ".stoneage") {
2620
+ continue;
2621
+ }
2622
+ const entryPath = join(dir, entry.name);
2623
+ if (entry.isDirectory()) {
2624
+ files.push(...(await listPublicFiles(outDir, entryPath)));
2625
+ continue;
2626
+ }
2627
+ if (entry.isFile()) {
2628
+ files.push(relative(outDir, entryPath).replaceAll("\\", "/"));
2629
+ }
2630
+ }
2631
+ return files.sort();
2632
+ }
2633
+ function dedupeIssues(issues) {
2634
+ const seen = new Set();
2635
+ return issues.filter((issue) => {
2636
+ const key = `${issue.code}\0${issue.path ?? ""}\0${issue.message}\0${issue.bytes ?? ""}\0${issue.limit ?? ""}`;
2637
+ if (seen.has(key)) {
2638
+ return false;
2639
+ }
2640
+ seen.add(key);
2641
+ return true;
2642
+ });
2643
+ }
2644
+ async function readOptionalText(path) {
2645
+ try {
2646
+ return await readFile(path, "utf8");
2647
+ }
2648
+ catch (error) {
2649
+ if (isMissingPath(error)) {
2650
+ return undefined;
2651
+ }
2652
+ throw error;
2653
+ }
2654
+ }
2655
+ async function collectPublicDataExportTargets(outDir, manifest) {
2656
+ const targets = new Map();
2657
+ const dataDir = join(outDir, "data");
2658
+ for (const file of await listPublicDataExportFiles(dataDir)) {
2659
+ const outputPath = `data/${file}`;
2660
+ const format = exportFormatForPath(outputPath);
2661
+ if (format) {
2662
+ targets.set(outputPath, { outputPath, publicPath: outputPath, format });
2663
+ }
2664
+ }
2665
+ for (const artifact of manifest?.artifacts ?? []) {
2666
+ const format = exportFormatForArtifact(artifact);
2667
+ if (format) {
2668
+ targets.set(artifact.outputPath, {
2669
+ outputPath: artifact.outputPath,
2670
+ publicPath: artifact.outputPath,
2671
+ format,
2672
+ });
2673
+ }
2674
+ }
2675
+ return [...targets.values()].sort((left, right) => left.outputPath.localeCompare(right.outputPath));
2676
+ }
2677
+ async function validatePublicDataExports(outDir, targets) {
2678
+ const issues = [];
2679
+ for (const target of targets) {
2680
+ const absolutePath = join(outDir, target.outputPath);
2681
+ let contents;
2682
+ try {
2683
+ contents = await readFile(absolutePath, "utf8");
2684
+ }
2685
+ catch (error) {
2686
+ if (isMissingPath(error)) {
2687
+ continue;
2688
+ }
2689
+ throw error;
2690
+ }
2691
+ if (target.format === "json") {
2692
+ try {
2693
+ JSON.parse(contents);
2694
+ }
2695
+ catch (error) {
2696
+ issues.push({
2697
+ code: "export.invalid_json",
2698
+ path: target.publicPath,
2699
+ message: `${target.publicPath} is not valid JSON: ${errorMessage(error)}.`,
2700
+ });
2701
+ }
2702
+ continue;
2703
+ }
2704
+ const csvIssue = validateCsvExport(contents);
2705
+ if (csvIssue) {
2706
+ issues.push({
2707
+ code: csvIssue.code,
2708
+ path: target.publicPath,
2709
+ message: `${target.publicPath} ${csvIssue.message}`,
2710
+ });
2711
+ }
2712
+ }
2713
+ return issues;
2714
+ }
2715
+ async function listPublicDataExportFiles(path, dir = path) {
2716
+ let entries;
2717
+ try {
2718
+ entries = await readdir(dir, { withFileTypes: true });
2719
+ }
2720
+ catch (error) {
2721
+ if (isMissingPath(error)) {
2722
+ return [];
2723
+ }
2724
+ throw error;
2725
+ }
2726
+ const files = [];
2727
+ for (const entry of entries) {
2728
+ const entryPath = join(dir, entry.name);
2729
+ if (entry.isDirectory()) {
2730
+ files.push(...(await listPublicDataExportFiles(path, entryPath)));
2731
+ continue;
2732
+ }
2733
+ if (entry.isFile() && /\.(csv|json)$/u.test(entry.name)) {
2734
+ files.push(relative(path, entryPath).replaceAll("\\", "/"));
2735
+ }
2736
+ }
2737
+ return files.sort();
2738
+ }
2739
+ async function collectImageInputs(sourceDir, dir = sourceDir, excludeDir) {
2740
+ let entries;
2741
+ try {
2742
+ entries = await readdir(dir, { withFileTypes: true });
2743
+ }
2744
+ catch (error) {
2745
+ if (isMissingPath(error)) {
2746
+ return [];
2747
+ }
2748
+ throw error;
2749
+ }
2750
+ const images = [];
2751
+ for (const entry of entries) {
2752
+ const entryPath = join(dir, entry.name);
2753
+ if (entry.isDirectory()) {
2754
+ // Skip the output directory so generated variants are not re-ingested as
2755
+ // inputs on subsequent runs. Both paths are pre-resolved by the caller.
2756
+ if (excludeDir && resolve(entryPath) === excludeDir) {
2757
+ continue;
2758
+ }
2759
+ images.push(...(await collectImageInputs(sourceDir, entryPath, excludeDir)));
2760
+ continue;
2761
+ }
2762
+ if (entry.isFile() && /\.(jpe?g|png|webp)$/iu.test(entry.name)) {
2763
+ const relativePath = relative(sourceDir, entryPath).replaceAll("\\", "/");
2764
+ images.push({
2765
+ source: entryPath,
2766
+ publicPath: `/${relativePath}`,
2767
+ });
2768
+ }
2769
+ }
2770
+ return images.sort((left, right) => left.publicPath.localeCompare(right.publicPath));
2771
+ }
2772
+ function exportFormatForArtifact(artifact) {
2773
+ const contentType = artifact.contentType?.toLowerCase() ?? "";
2774
+ if (contentType.split(";")[0]?.trim() === "application/json") {
2775
+ return "json";
2776
+ }
2777
+ if (contentType.split(";")[0]?.trim() === "text/csv") {
2778
+ return "csv";
2779
+ }
2780
+ return exportFormatForPath(artifact.outputPath);
2781
+ }
2782
+ function exportFormatForPath(path) {
2783
+ if (path.endsWith(".json")) {
2784
+ return "json";
2785
+ }
2786
+ if (path.endsWith(".csv")) {
2787
+ return "csv";
2788
+ }
2789
+ return undefined;
2790
+ }
2791
+ function validateCsvExport(contents) {
2792
+ if (contents.trim() === "") {
2793
+ return {
2794
+ code: "export.empty_csv",
2795
+ message: "is empty.",
2796
+ };
2797
+ }
2798
+ const parsed = parseCsvRows(contents);
2799
+ if (typeof parsed === "string") {
2800
+ return {
2801
+ code: "export.invalid_csv",
2802
+ message: parsed,
2803
+ };
2804
+ }
2805
+ const rows = parsed.filter((row, index) => index === 0 || row.some((cell) => cell !== ""));
2806
+ const expectedColumns = rows[0]?.length ?? 0;
2807
+ if (expectedColumns === 0) {
2808
+ return {
2809
+ code: "export.empty_csv",
2810
+ message: "does not contain a header row.",
2811
+ };
2812
+ }
2813
+ for (let index = 1; index < rows.length; index += 1) {
2814
+ const row = rows[index];
2815
+ if (row.length !== expectedColumns) {
2816
+ return {
2817
+ code: "export.invalid_csv",
2818
+ message: `row ${index + 1} has ${row.length} column(s), expected ${expectedColumns}.`,
2819
+ };
2820
+ }
2821
+ }
2822
+ return undefined;
2823
+ }
2824
+ function parseCsvRows(contents) {
2825
+ const rows = [];
2826
+ let row = [];
2827
+ let cell = "";
2828
+ let quoted = false;
2829
+ for (let index = 0; index < contents.length; index += 1) {
2830
+ const char = contents[index];
2831
+ const next = contents[index + 1];
2832
+ if (quoted) {
2833
+ if (char === '"' && next === '"') {
2834
+ cell += '"';
2835
+ index += 1;
2836
+ continue;
2837
+ }
2838
+ if (char === '"') {
2839
+ quoted = false;
2840
+ continue;
2841
+ }
2842
+ cell += char;
2843
+ continue;
2844
+ }
2845
+ if (char === '"') {
2846
+ if (cell !== "") {
2847
+ return "has a quote inside an unquoted field.";
2848
+ }
2849
+ quoted = true;
2850
+ continue;
2851
+ }
2852
+ if (char === ",") {
2853
+ row.push(cell);
2854
+ cell = "";
2855
+ continue;
2856
+ }
2857
+ if (char === "\n") {
2858
+ row.push(cell);
2859
+ rows.push(row);
2860
+ row = [];
2861
+ cell = "";
2862
+ continue;
2863
+ }
2864
+ if (char === "\r") {
2865
+ continue;
2866
+ }
2867
+ cell += char;
2868
+ }
2869
+ if (quoted) {
2870
+ return "has an unterminated quoted field.";
2871
+ }
2872
+ row.push(cell);
2873
+ if (row.some((value) => value !== "") || contents.endsWith(",")) {
2874
+ rows.push(row);
2875
+ }
2876
+ return rows;
2877
+ }
2878
+ function errorMessage(error) {
2879
+ return error instanceof Error ? error.message : String(error);
2880
+ }
2881
+ function isMissingPath(error) {
2882
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
2883
+ }
2884
+ function readValue(option, args) {
2885
+ const value = args.shift();
2886
+ if (!value) {
2887
+ throw new Error(`Missing value for ${option}`);
2888
+ }
2889
+ return value;
2890
+ }
2891
+ function readPositiveInteger(option, args) {
2892
+ const raw = readValue(option, args);
2893
+ const value = Number(raw);
2894
+ if (!Number.isInteger(value) || value <= 0) {
2895
+ throw new Error(`Expected a positive integer for ${option}`);
2896
+ }
2897
+ return value;
2898
+ }
2899
+ function readNonNegativeNumber(option, args) {
2900
+ const raw = readValue(option, args);
2901
+ const value = Number(raw);
2902
+ if (!Number.isFinite(value) || value < 0) {
2903
+ throw new Error(`Expected a non-negative number for ${option}`);
2904
+ }
2905
+ return value;
2906
+ }
2907
+ function readNonNegativeInteger(option, args) {
2908
+ const raw = readValue(option, args);
2909
+ const value = Number(raw);
2910
+ if (!Number.isInteger(value) || value < 0) {
2911
+ throw new Error(`Expected a non-negative integer for ${option}`);
2912
+ }
2913
+ return value;
2914
+ }
2915
+ function readTrailingSlash(option, args) {
2916
+ const value = readValue(option, args);
2917
+ if (value !== "always" && value !== "never") {
2918
+ throw new Error(`Expected always or never for ${option}`);
2919
+ }
2920
+ return value;
2921
+ }
2922
+ function readDeployProvider(option, args) {
2923
+ const value = readValue(option, args);
2924
+ if (value !== "netlify" && value !== "github-pages") {
2925
+ throw new Error(`Expected netlify or github-pages for ${option}`);
2926
+ }
2927
+ return value;
2928
+ }
2929
+ function readImageFormat(option, args) {
2930
+ const value = readValue(option, args);
2931
+ if (value !== "webp" && value !== "avif" && value !== "jpeg" && value !== "png") {
2932
+ throw new Error(`Expected webp, avif, jpeg, or png for ${option}`);
2933
+ }
2934
+ return value;
2935
+ }
2936
+ function helpText() {
2937
+ return [
2938
+ "Usage: stoneage <command> [options]",
2939
+ "",
2940
+ "Commands:",
2941
+ " build Run a TSX/TS entry with the html() JSX factory forced",
2942
+ " test Run TSX/TS tests with the html() JSX factory forced",
2943
+ " benchmark Build and measure the example data site",
2944
+ " validate Validate generated output before publishing",
2945
+ " inspect List outputs that depend on one dependency key",
2946
+ " plan Plan outputs affected by dependency keys",
2947
+ " optimize Write precompressed public output sidecars",
2948
+ " optimize-images Generate responsive image variants with sharp",
2949
+ " deploy Emit provider-specific deploy artifacts",
2950
+ " dev Serve generated output with hot reload",
2951
+ " agent-skill Print a Codex skill for StoneAge projects",
2952
+ " audit-sveltekit Audit a SvelteKit route tree for migration",
2953
+ "",
2954
+ "Common options:",
2955
+ " --out-dir <dir> Generated output directory",
2956
+ " --json Print machine-readable JSON where supported",
2957
+ " --help, -h Print this help",
2958
+ "",
2959
+ "Build options:",
2960
+ " stoneage build <entry> [-- <args>] Run <entry> with the html() JSX factory forced",
2961
+ "",
2962
+ "Test options:",
2963
+ " stoneage test [files...] [-- <args>] Run node:test with the html() JSX factory forced",
2964
+ "",
2965
+ "Benchmark options:",
2966
+ " --compare-to <file> Compare size metrics with a previous benchmark JSON report",
2967
+ " --max-size-regression-percent <number> Allowed percentage growth for compared size metrics",
2968
+ " --comparison-report <file> Write benchmark comparison JSON",
2969
+ "",
2970
+ "Validate options:",
2971
+ " --validation-config <file> Load metadata, budget, and publish gate settings",
2972
+ " --write-validation-config <file> Write a validation baseline from current output",
2973
+ " --require-precompressed Fail when Brotli or Gzip sidecars are missing",
2974
+ " --provider <netlify|github-pages> Validate deploy artifacts for the given provider",
2975
+ " --report <file> Write the validation summary JSON",
2976
+ "",
2977
+ "SvelteKit audit options:",
2978
+ " --routes-dir <dir> SvelteKit src/routes directory to audit",
2979
+ " --params-dir <dir> Optional SvelteKit params matcher directory",
2980
+ " --migration-plan Include StoneAge route and artifact plan suggestions",
2981
+ " --emit-stubs <dir> Write non-adapter migration scaffold files",
2982
+ " --report <file> Write the audit report JSON",
2983
+ "",
2984
+ "Optimize-images options:",
2985
+ " --source-dir <dir> Directory to scan for .jpg/.png/.webp source images (required)",
2986
+ " --width <px> Output width; repeat for multiple widths (default 640, 1280)",
2987
+ " --format <webp|avif|jpeg|png> Output format; repeat for multiple formats (default webp)",
2988
+ " Requires the optional 'sharp' dependency (npm install sharp)",
2989
+ "",
2990
+ "Deploy options:",
2991
+ " --provider <netlify|github-pages> Write provider-specific deploy artifacts",
2992
+ " --cname <domain> Custom domain for github-pages (writes CNAME)",
2993
+ "",
2994
+ "Dev options:",
2995
+ " --host <host> Hostname to bind, default 127.0.0.1",
2996
+ " --port <port> Port to bind, default 4321",
2997
+ " --watch <path> Path to watch; repeat for multiple paths",
2998
+ " --build-command <command> Shell command to rebuild generated output before reload",
2999
+ " --debounce-ms <ms> Delay before rebuilding after file changes",
3000
+ "",
3001
+ "Agent skill options:",
3002
+ " --print Print the StoneAge agent skill Markdown",
3003
+ ].join("\n");
3004
+ }
3005
+ const requireFromCli = createRequire(import.meta.url);
3006
+ /**
3007
+ * Absolute path to the Node module registrar that installs the StoneAge JSX
3008
+ * loader. Shipped alongside dist/ (see jsx-register.mjs / jsx-loader.mjs).
3009
+ *
3010
+ * dist/cli.js -> ../jsx-register.mjs (package root)
3011
+ * src/cli.ts -> ../jsx-register.mjs (repo root)
3012
+ */
3013
+ function jsxRegisterPath() {
3014
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "jsx-register.mjs");
3015
+ }
3016
+ /**
3017
+ * Resolve tsx's ESM loader entry (shipped as a dependency of this package).
3018
+ * tsx handles module resolution (e.g. NodeNext ".js" -> ".ts") and ESM/CJS
3019
+ * interop; the StoneAge JSX loader layered on top forces the html() factory.
3020
+ */
3021
+ function resolveTsxLoader() {
3022
+ try {
3023
+ // Return a file:// URL (not a raw path) so `node --import` accepts it on
3024
+ // every platform, including Windows where bare drive-letter paths are not
3025
+ // valid module specifiers.
3026
+ return pathToFileURL(requireFromCli.resolve("tsx/esm")).href;
3027
+ }
3028
+ catch {
3029
+ throw new Error("Could not resolve 'tsx'. Reinstall @t09tanaka/stoneage so its dependencies are available.");
3030
+ }
3031
+ }
3032
+ /**
3033
+ * Run a Node child process with the StoneAge JSX loader + tsx loader installed
3034
+ * so TSX/TS sources compile with jsxFactory=html (jsxFragmentFactory=Fragment),
3035
+ * regardless of the consumer's own tsconfig (e.g. jsx:"react-jsx").
3036
+ *
3037
+ * The JSX registrar is imported before tsx so its load hook runs first and
3038
+ * transforms .tsx/.jsx with the forced factory, while tsx resolves modules and
3039
+ * compiles the remaining .ts sources.
3040
+ */
3041
+ function runWithForcedJsx(nodeArgs) {
3042
+ const child = spawn(process.execPath, [
3043
+ "--import",
3044
+ pathToFileURL(jsxRegisterPath()).href,
3045
+ "--import",
3046
+ resolveTsxLoader(),
3047
+ ...nodeArgs,
3048
+ ], { stdio: "inherit" });
3049
+ return new Promise((resolveRun, rejectRun) => {
3050
+ child.on("error", rejectRun);
3051
+ child.on("exit", (code, signal) => {
3052
+ if (signal) {
3053
+ rejectRun(new Error(`process exited with ${signal}`));
3054
+ return;
3055
+ }
3056
+ resolveRun(code ?? 0);
3057
+ });
3058
+ });
3059
+ }
3060
+ function isExecutedEntrypoint() {
3061
+ const entrypoint = process.argv[1];
3062
+ if (!entrypoint) {
3063
+ return false;
3064
+ }
3065
+ try {
3066
+ return realpathSync(entrypoint) === fileURLToPath(import.meta.url);
3067
+ }
3068
+ catch {
3069
+ return false;
3070
+ }
3071
+ }
3072
+ if (isExecutedEntrypoint()) {
3073
+ runCli().catch((error) => {
3074
+ console.error(error instanceof Error ? error.message : String(error));
3075
+ process.exitCode = 1;
3076
+ });
3077
+ }