pagetrace 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  While the version is below 1.0.0, breaking changes ship in a minor release.
8
8
 
9
+ ## [0.4.0] - 2026-09-06
10
+
11
+ ### Changed
12
+
13
+ - **Breaking.** A failed run now exits `2` rather than `1`. `1` means findings at
14
+ or above `--fail-on`; `2` means the run itself failed — invalid flags, an
15
+ unreadable build directory, an unreachable origin, no pages found. CI could not
16
+ previously distinguish "the site regressed" from "the tool broke", which are
17
+ opposite situations: one should fail the build, the other should page someone.
18
+ - The lockfile is only rewritten when the surface actually changed. It carries a
19
+ `createdAt` timestamp, so every run used to produce a git diff even on an
20
+ unchanged site — which teaches reviewers to discard lockfile changes without
21
+ reading them, the one habit this tool cannot afford. `snapshot` and
22
+ `check --update` now say "already up to date" and leave the file alone.
23
+ - Source maps are no longer published. They were 61% of the package: 715 kB
24
+ unpacked down to 281 kB. Nobody steps through a built CLI.
25
+ - Build target moved from `node18` to `node20`, matching the engines floor.
26
+
27
+ ### Added
28
+
29
+ - Failed requests are retried up to three times with backoff, on network errors,
30
+ 429 and 5xx. 0.2.0 made an unreachable page abort the crawl rather than be
31
+ silently dropped, which is correct but brittle without a retry — one flaky
32
+ response could end a 200-page crawl. A 4xx is an answer, not a hiccup, and is
33
+ not retried.
34
+ - `sameSurface(a, b)` is exported: compares two snapshots ignoring when they were
35
+ taken.
36
+ - The release workflow creates a GitHub Release from each tag, using that
37
+ version's changelog section as the notes. A tag on its own does not appear in
38
+ the repo UI, so the project read as unreleased despite being on npm.
39
+
9
40
  ## [0.3.0] - 2026-09-06
10
41
 
11
42
  ### Changed
@@ -139,6 +170,7 @@ Initial release. `snapshot`, `check` and `audit` commands; filesystem and HTTP
139
170
  crawling; diff classified by transition; absolute, cross-page and hreflang audit
140
171
  rules; pretty, JSON, markdown, GitHub and HTML reporters.
141
172
 
173
+ [0.4.0]: https://github.com/shyamexe/pagetrace/compare/v0.3.0...v0.4.0
142
174
  [0.3.0]: https://github.com/shyamexe/pagetrace/compare/v0.2.0...v0.3.0
143
175
  [0.2.0]: https://github.com/shyamexe/pagetrace/compare/v0.1.0...v0.2.0
144
176
  [0.1.0]: https://github.com/shyamexe/pagetrace/releases/tag/v0.1.0
package/README.md CHANGED
@@ -48,10 +48,12 @@ npx pagetrace check --dir ./out
48
48
  5 error, 9 warning, 2 info
49
49
  ```
50
50
 
51
- Exit code is `1` when anything at or above `--fail-on` is found. It takes `error` (the default), `warn` or `info`, and rejects anything else rather than quietly letting the build pass.
51
+ Exit codes are `0` for clean, `1` for findings at or above `--fail-on`, and `2` when the run itself failed — bad flags, an unreadable build directory, an unreachable origin. CI can tell "the site regressed" from "the tool broke". `--fail-on` takes `error` (the default), `warn` or `info`, and rejects anything else rather than quietly letting the build pass.
52
52
 
53
53
  Note that `check` runs the absolute rules as well as the diff, so it can fail on a problem your build did not introduce. Use `--no-audit` for a pure regression gate.
54
54
 
55
+ The lockfile is only rewritten when the surface actually changed, so an unchanged site leaves it byte-identical and produces no git diff.
56
+
55
57
  Accept the new state once you've reviewed it:
56
58
 
57
59
  ```bash
package/dist/cli.cjs CHANGED
@@ -1278,6 +1278,10 @@ function routeFromUrl(url) {
1278
1278
  return url;
1279
1279
  }
1280
1280
  }
1281
+ function sameSurface(a, b) {
1282
+ const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1283
+ return strip(a) === strip(b);
1284
+ }
1281
1285
  function shouldIgnore(route, patterns = []) {
1282
1286
  return patterns.some(
1283
1287
  (pattern) => pattern.endsWith("*") ? route.startsWith(pattern.slice(0, -1)) : route === pattern
@@ -1296,19 +1300,32 @@ async function walkHtml(dir, acc = []) {
1296
1300
  return acc;
1297
1301
  }
1298
1302
  var DEFAULT_TIMEOUT_MS = 15e3;
1303
+ var MAX_ATTEMPTS = 3;
1304
+ var RETRY_BASE_MS = 300;
1305
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1306
+ function isTransient(status) {
1307
+ return status === 429 || status >= 500;
1308
+ }
1299
1309
  async function fetchText(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
1300
- let response;
1301
- try {
1302
- response = await fetch(url, {
1303
- signal: AbortSignal.timeout(timeoutMs),
1304
- headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1305
- });
1306
- } catch (cause) {
1307
- throw new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1310
+ let lastError;
1311
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
1312
+ if (attempt > 1) await sleep(RETRY_BASE_MS * 3 ** (attempt - 2));
1313
+ let response;
1314
+ try {
1315
+ response = await fetch(url, {
1316
+ signal: AbortSignal.timeout(timeoutMs),
1317
+ headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1318
+ });
1319
+ } catch (cause) {
1320
+ lastError = new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1321
+ continue;
1322
+ }
1323
+ if (response.status === 404 || response.status === 410) return null;
1324
+ if (response.ok) return await response.text();
1325
+ lastError = new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1326
+ if (!isTransient(response.status)) break;
1308
1327
  }
1309
- if (response.status === 404 || response.status === 410) return null;
1310
- if (!response.ok) throw new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1311
- return await response.text();
1328
+ throw lastError;
1312
1329
  }
1313
1330
  function tryFetchText(url, timeoutMs) {
1314
1331
  return fetchText(url, timeoutMs).catch(() => null);
@@ -1405,6 +1422,15 @@ async function snapshotFromOrigin(origin, options = {}) {
1405
1422
  // src/cli.ts
1406
1423
  var DEFAULT_LOCKFILE = "pagetrace.lock.json";
1407
1424
  var DEFAULT_CONFIG = "pagetrace.config.json";
1425
+ var EXIT_FINDINGS = 1;
1426
+ var EXIT_FAILURE = 2;
1427
+ async function writeLockfile(path, next) {
1428
+ const previous = await (0, import_promises2.readFile)(path, "utf8").then((text2) => JSON.parse(text2)).catch(() => null);
1429
+ if (previous && sameSurface(previous, next)) return false;
1430
+ await (0, import_promises2.writeFile)(path, `${JSON.stringify(next, null, 2)}
1431
+ `, "utf8");
1432
+ return true;
1433
+ }
1408
1434
  var SEVERITIES = ["error", "warn", "info"];
1409
1435
  function parseFailOn(value, allowNever) {
1410
1436
  const expected = [...SEVERITIES, ...allowNever ? ["never"] : []].join(" | ");
@@ -1446,10 +1472,11 @@ var cli = (0, import_cac.cac)("pagetrace");
1446
1472
  cli.command("snapshot", "Record the current SEO/AEO surface to a lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--out <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).action(async (flags) => {
1447
1473
  const config = await loadConfig(flags.config);
1448
1474
  const snapshot = await build(flags, config);
1449
- await (0, import_promises2.writeFile)(flags.out, `${JSON.stringify(snapshot, null, 2)}
1450
- `, "utf8");
1475
+ const written = await writeLockfile(flags.out, snapshot);
1451
1476
  const count = Object.keys(snapshot.pages).length;
1452
- console.log(import_picocolors2.default.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`));
1477
+ console.log(
1478
+ written ? import_picocolors2.default.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`) : import_picocolors2.default.dim(`${flags.out} is already up to date \u2014 ${count} page${count === 1 ? "" : "s"}.`)
1479
+ );
1453
1480
  });
1454
1481
  cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").action(async (flags) => {
1455
1482
  const failOn = parseFailOn(flags.failOn, false);
@@ -1473,9 +1500,10 @@ cli.command("check", "Compare the current surface against the lockfile").option(
1473
1500
  const findings = applyConfig(raw, config);
1474
1501
  console.log(render(findings, flags.format));
1475
1502
  if (flags.update) {
1476
- await (0, import_promises2.writeFile)(flags.lockfile, `${JSON.stringify(next, null, 2)}
1477
- `, "utf8");
1478
- console.error(import_picocolors2.default.dim(`Updated ${flags.lockfile}.`));
1503
+ const written = await writeLockfile(flags.lockfile, next);
1504
+ console.error(
1505
+ import_picocolors2.default.dim(written ? `Updated ${flags.lockfile}.` : `${flags.lockfile} is already up to date.`)
1506
+ );
1479
1507
  }
1480
1508
  const summary = summarize(findings);
1481
1509
  if (previous && shouldFail(findings, failOn)) {
@@ -1483,7 +1511,7 @@ cli.command("check", "Compare the current surface against the lockfile").option(
1483
1511
  import_picocolors2.default.red(`
1484
1512
  Failing: ${summary.error} error, ${summary.warn} warning (--fail-on ${failOn}).`)
1485
1513
  );
1486
- process.exitCode = 1;
1514
+ process.exitCode = EXIT_FINDINGS;
1487
1515
  }
1488
1516
  });
1489
1517
  cli.command("audit", "Audit a site as it stands, with explanations and fixes").option("--url <origin>", "Live origin to crawl").option("--dir <dir>", "Directory of built HTML").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | html", { default: "pretty" }).option("--out <file>", "Write the report to a file instead of stdout").option("--fail-on <severity>", "error | warn | info | never", { default: "never" }).action(async (flags) => {
@@ -1497,7 +1525,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1497
1525
  "No pages found. Check that the sitemap is reachable, or pass --dir with pre-rendered HTML."
1498
1526
  )
1499
1527
  );
1500
- process.exitCode = 1;
1528
+ process.exitCode = EXIT_FAILURE;
1501
1529
  return;
1502
1530
  }
1503
1531
  const platform = detectPlatform(
@@ -1523,19 +1551,18 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1523
1551
  console.log(output);
1524
1552
  }
1525
1553
  if (failOn !== "never" && shouldFail(findings, failOn)) {
1526
- process.exitCode = 1;
1554
+ process.exitCode = EXIT_FINDINGS;
1527
1555
  }
1528
1556
  });
1529
1557
  cli.help();
1530
- cli.version("0.3.0");
1558
+ cli.version("0.4.0");
1531
1559
  async function main() {
1532
1560
  try {
1533
1561
  cli.parse(process.argv, { run: false });
1534
1562
  await cli.runMatchedCommand();
1535
1563
  } catch (error) {
1536
1564
  console.error(import_picocolors2.default.red(error.message));
1537
- process.exitCode = 1;
1565
+ process.exitCode = EXIT_FAILURE;
1538
1566
  }
1539
1567
  }
1540
1568
  void main();
1541
- //# sourceMappingURL=cli.cjs.map
package/dist/cli.js CHANGED
@@ -1255,6 +1255,10 @@ function routeFromUrl(url) {
1255
1255
  return url;
1256
1256
  }
1257
1257
  }
1258
+ function sameSurface(a, b) {
1259
+ const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1260
+ return strip(a) === strip(b);
1261
+ }
1258
1262
  function shouldIgnore(route, patterns = []) {
1259
1263
  return patterns.some(
1260
1264
  (pattern) => pattern.endsWith("*") ? route.startsWith(pattern.slice(0, -1)) : route === pattern
@@ -1273,19 +1277,32 @@ async function walkHtml(dir, acc = []) {
1273
1277
  return acc;
1274
1278
  }
1275
1279
  var DEFAULT_TIMEOUT_MS = 15e3;
1280
+ var MAX_ATTEMPTS = 3;
1281
+ var RETRY_BASE_MS = 300;
1282
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1283
+ function isTransient(status) {
1284
+ return status === 429 || status >= 500;
1285
+ }
1276
1286
  async function fetchText(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
1277
- let response;
1278
- try {
1279
- response = await fetch(url, {
1280
- signal: AbortSignal.timeout(timeoutMs),
1281
- headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1282
- });
1283
- } catch (cause) {
1284
- throw new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1287
+ let lastError;
1288
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
1289
+ if (attempt > 1) await sleep(RETRY_BASE_MS * 3 ** (attempt - 2));
1290
+ let response;
1291
+ try {
1292
+ response = await fetch(url, {
1293
+ signal: AbortSignal.timeout(timeoutMs),
1294
+ headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1295
+ });
1296
+ } catch (cause) {
1297
+ lastError = new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1298
+ continue;
1299
+ }
1300
+ if (response.status === 404 || response.status === 410) return null;
1301
+ if (response.ok) return await response.text();
1302
+ lastError = new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1303
+ if (!isTransient(response.status)) break;
1285
1304
  }
1286
- if (response.status === 404 || response.status === 410) return null;
1287
- if (!response.ok) throw new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1288
- return await response.text();
1305
+ throw lastError;
1289
1306
  }
1290
1307
  function tryFetchText(url, timeoutMs) {
1291
1308
  return fetchText(url, timeoutMs).catch(() => null);
@@ -1382,6 +1399,15 @@ async function snapshotFromOrigin(origin, options = {}) {
1382
1399
  // src/cli.ts
1383
1400
  var DEFAULT_LOCKFILE = "pagetrace.lock.json";
1384
1401
  var DEFAULT_CONFIG = "pagetrace.config.json";
1402
+ var EXIT_FINDINGS = 1;
1403
+ var EXIT_FAILURE = 2;
1404
+ async function writeLockfile(path, next) {
1405
+ const previous = await readFile2(path, "utf8").then((text2) => JSON.parse(text2)).catch(() => null);
1406
+ if (previous && sameSurface(previous, next)) return false;
1407
+ await writeFile(path, `${JSON.stringify(next, null, 2)}
1408
+ `, "utf8");
1409
+ return true;
1410
+ }
1385
1411
  var SEVERITIES = ["error", "warn", "info"];
1386
1412
  function parseFailOn(value, allowNever) {
1387
1413
  const expected = [...SEVERITIES, ...allowNever ? ["never"] : []].join(" | ");
@@ -1423,10 +1449,11 @@ var cli = cac("pagetrace");
1423
1449
  cli.command("snapshot", "Record the current SEO/AEO surface to a lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--out <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).action(async (flags) => {
1424
1450
  const config = await loadConfig(flags.config);
1425
1451
  const snapshot = await build(flags, config);
1426
- await writeFile(flags.out, `${JSON.stringify(snapshot, null, 2)}
1427
- `, "utf8");
1452
+ const written = await writeLockfile(flags.out, snapshot);
1428
1453
  const count = Object.keys(snapshot.pages).length;
1429
- console.log(pc2.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`));
1454
+ console.log(
1455
+ written ? pc2.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`) : pc2.dim(`${flags.out} is already up to date \u2014 ${count} page${count === 1 ? "" : "s"}.`)
1456
+ );
1430
1457
  });
1431
1458
  cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").action(async (flags) => {
1432
1459
  const failOn = parseFailOn(flags.failOn, false);
@@ -1450,9 +1477,10 @@ cli.command("check", "Compare the current surface against the lockfile").option(
1450
1477
  const findings = applyConfig(raw, config);
1451
1478
  console.log(render(findings, flags.format));
1452
1479
  if (flags.update) {
1453
- await writeFile(flags.lockfile, `${JSON.stringify(next, null, 2)}
1454
- `, "utf8");
1455
- console.error(pc2.dim(`Updated ${flags.lockfile}.`));
1480
+ const written = await writeLockfile(flags.lockfile, next);
1481
+ console.error(
1482
+ pc2.dim(written ? `Updated ${flags.lockfile}.` : `${flags.lockfile} is already up to date.`)
1483
+ );
1456
1484
  }
1457
1485
  const summary = summarize(findings);
1458
1486
  if (previous && shouldFail(findings, failOn)) {
@@ -1460,7 +1488,7 @@ cli.command("check", "Compare the current surface against the lockfile").option(
1460
1488
  pc2.red(`
1461
1489
  Failing: ${summary.error} error, ${summary.warn} warning (--fail-on ${failOn}).`)
1462
1490
  );
1463
- process.exitCode = 1;
1491
+ process.exitCode = EXIT_FINDINGS;
1464
1492
  }
1465
1493
  });
1466
1494
  cli.command("audit", "Audit a site as it stands, with explanations and fixes").option("--url <origin>", "Live origin to crawl").option("--dir <dir>", "Directory of built HTML").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | html", { default: "pretty" }).option("--out <file>", "Write the report to a file instead of stdout").option("--fail-on <severity>", "error | warn | info | never", { default: "never" }).action(async (flags) => {
@@ -1474,7 +1502,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1474
1502
  "No pages found. Check that the sitemap is reachable, or pass --dir with pre-rendered HTML."
1475
1503
  )
1476
1504
  );
1477
- process.exitCode = 1;
1505
+ process.exitCode = EXIT_FAILURE;
1478
1506
  return;
1479
1507
  }
1480
1508
  const platform = detectPlatform(
@@ -1500,19 +1528,18 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1500
1528
  console.log(output);
1501
1529
  }
1502
1530
  if (failOn !== "never" && shouldFail(findings, failOn)) {
1503
- process.exitCode = 1;
1531
+ process.exitCode = EXIT_FINDINGS;
1504
1532
  }
1505
1533
  });
1506
1534
  cli.help();
1507
- cli.version("0.3.0");
1535
+ cli.version("0.4.0");
1508
1536
  async function main() {
1509
1537
  try {
1510
1538
  cli.parse(process.argv, { run: false });
1511
1539
  await cli.runMatchedCommand();
1512
1540
  } catch (error) {
1513
1541
  console.error(pc2.red(error.message));
1514
- process.exitCode = 1;
1542
+ process.exitCode = EXIT_FAILURE;
1515
1543
  }
1516
1544
  }
1517
1545
  void main();
1518
- //# sourceMappingURL=cli.js.map
package/dist/index.cjs CHANGED
@@ -58,6 +58,7 @@ __export(src_exports, {
58
58
  formatPretty: () => formatPretty,
59
59
  routeFromFilePath: () => routeFromFilePath,
60
60
  routeFromUrl: () => routeFromUrl,
61
+ sameSurface: () => sameSurface,
61
62
  shouldFail: () => shouldFail,
62
63
  shouldIgnore: () => shouldIgnore,
63
64
  snapshotFromDir: () => snapshotFromDir,
@@ -1315,6 +1316,10 @@ function routeFromUrl(url) {
1315
1316
  return url;
1316
1317
  }
1317
1318
  }
1319
+ function sameSurface(a, b) {
1320
+ const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1321
+ return strip(a) === strip(b);
1322
+ }
1318
1323
  function shouldIgnore(route, patterns = []) {
1319
1324
  return patterns.some(
1320
1325
  (pattern) => pattern.endsWith("*") ? route.startsWith(pattern.slice(0, -1)) : route === pattern
@@ -1333,19 +1338,32 @@ async function walkHtml(dir, acc = []) {
1333
1338
  return acc;
1334
1339
  }
1335
1340
  var DEFAULT_TIMEOUT_MS = 15e3;
1341
+ var MAX_ATTEMPTS = 3;
1342
+ var RETRY_BASE_MS = 300;
1343
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1344
+ function isTransient(status) {
1345
+ return status === 429 || status >= 500;
1346
+ }
1336
1347
  async function fetchText(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
1337
- let response;
1338
- try {
1339
- response = await fetch(url, {
1340
- signal: AbortSignal.timeout(timeoutMs),
1341
- headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1342
- });
1343
- } catch (cause) {
1344
- throw new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1348
+ let lastError;
1349
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
1350
+ if (attempt > 1) await sleep(RETRY_BASE_MS * 3 ** (attempt - 2));
1351
+ let response;
1352
+ try {
1353
+ response = await fetch(url, {
1354
+ signal: AbortSignal.timeout(timeoutMs),
1355
+ headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1356
+ });
1357
+ } catch (cause) {
1358
+ lastError = new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1359
+ continue;
1360
+ }
1361
+ if (response.status === 404 || response.status === 410) return null;
1362
+ if (response.ok) return await response.text();
1363
+ lastError = new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1364
+ if (!isTransient(response.status)) break;
1345
1365
  }
1346
- if (response.status === 404 || response.status === 410) return null;
1347
- if (!response.ok) throw new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1348
- return await response.text();
1366
+ throw lastError;
1349
1367
  }
1350
1368
  function tryFetchText(url, timeoutMs) {
1351
1369
  return fetchText(url, timeoutMs).catch(() => null);
@@ -1468,6 +1486,7 @@ async function snapshotFromOrigin(origin, options = {}) {
1468
1486
  formatPretty,
1469
1487
  routeFromFilePath,
1470
1488
  routeFromUrl,
1489
+ sameSurface,
1471
1490
  shouldFail,
1472
1491
  shouldIgnore,
1473
1492
  snapshotFromDir,
@@ -1475,4 +1494,3 @@ async function snapshotFromOrigin(origin, options = {}) {
1475
1494
  summarize,
1476
1495
  withGuidance
1477
1496
  });
1478
- //# sourceMappingURL=index.cjs.map
package/dist/index.d.cts CHANGED
@@ -215,6 +215,14 @@ declare const DEFAULT_AI_AGENTS: string[];
215
215
 
216
216
  declare function routeFromFilePath(root: string, filePath: string): string;
217
217
  declare function routeFromUrl(url: string): string;
218
+ /**
219
+ * Whether two snapshots describe the same surface, ignoring when they were
220
+ * taken. The lockfile is meant to be committed, so writing a fresh timestamp on
221
+ * every run put a diff in front of the reviewer even when nothing had changed.
222
+ * That trains people to discard lockfile changes without reading them, which is
223
+ * the one habit this tool cannot afford.
224
+ */
225
+ declare function sameSurface(a: Snapshot, b: Snapshot): boolean;
218
226
  declare function shouldIgnore(route: string, patterns?: string[]): boolean;
219
227
  /** Build a snapshot from a directory of pre-rendered HTML (next export, dist, out). */
220
228
  declare function snapshotFromDir(dir: string, config?: Config): Promise<Snapshot>;
@@ -229,4 +237,4 @@ interface CrawlOptions extends Config {
229
237
  /** Build a snapshot by fetching a live origin, discovering routes via sitemap. */
230
238
  declare function snapshotFromOrigin(origin: string, options?: CrawlOptions): Promise<Snapshot>;
231
239
 
232
- export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromOrigin, summarize, withGuidance };
240
+ export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, sameSurface, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromOrigin, summarize, withGuidance };
package/dist/index.d.ts CHANGED
@@ -215,6 +215,14 @@ declare const DEFAULT_AI_AGENTS: string[];
215
215
 
216
216
  declare function routeFromFilePath(root: string, filePath: string): string;
217
217
  declare function routeFromUrl(url: string): string;
218
+ /**
219
+ * Whether two snapshots describe the same surface, ignoring when they were
220
+ * taken. The lockfile is meant to be committed, so writing a fresh timestamp on
221
+ * every run put a diff in front of the reviewer even when nothing had changed.
222
+ * That trains people to discard lockfile changes without reading them, which is
223
+ * the one habit this tool cannot afford.
224
+ */
225
+ declare function sameSurface(a: Snapshot, b: Snapshot): boolean;
218
226
  declare function shouldIgnore(route: string, patterns?: string[]): boolean;
219
227
  /** Build a snapshot from a directory of pre-rendered HTML (next export, dist, out). */
220
228
  declare function snapshotFromDir(dir: string, config?: Config): Promise<Snapshot>;
@@ -229,4 +237,4 @@ interface CrawlOptions extends Config {
229
237
  /** Build a snapshot by fetching a live origin, discovering routes via sitemap. */
230
238
  declare function snapshotFromOrigin(origin: string, options?: CrawlOptions): Promise<Snapshot>;
231
239
 
232
- export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromOrigin, summarize, withGuidance };
240
+ export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, sameSurface, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromOrigin, summarize, withGuidance };
package/dist/index.js CHANGED
@@ -1246,6 +1246,10 @@ function routeFromUrl(url) {
1246
1246
  return url;
1247
1247
  }
1248
1248
  }
1249
+ function sameSurface(a, b) {
1250
+ const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1251
+ return strip(a) === strip(b);
1252
+ }
1249
1253
  function shouldIgnore(route, patterns = []) {
1250
1254
  return patterns.some(
1251
1255
  (pattern) => pattern.endsWith("*") ? route.startsWith(pattern.slice(0, -1)) : route === pattern
@@ -1264,19 +1268,32 @@ async function walkHtml(dir, acc = []) {
1264
1268
  return acc;
1265
1269
  }
1266
1270
  var DEFAULT_TIMEOUT_MS = 15e3;
1271
+ var MAX_ATTEMPTS = 3;
1272
+ var RETRY_BASE_MS = 300;
1273
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1274
+ function isTransient(status) {
1275
+ return status === 429 || status >= 500;
1276
+ }
1267
1277
  async function fetchText(url, timeoutMs = DEFAULT_TIMEOUT_MS) {
1268
- let response;
1269
- try {
1270
- response = await fetch(url, {
1271
- signal: AbortSignal.timeout(timeoutMs),
1272
- headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1273
- });
1274
- } catch (cause) {
1275
- throw new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1278
+ let lastError;
1279
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
1280
+ if (attempt > 1) await sleep(RETRY_BASE_MS * 3 ** (attempt - 2));
1281
+ let response;
1282
+ try {
1283
+ response = await fetch(url, {
1284
+ signal: AbortSignal.timeout(timeoutMs),
1285
+ headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1286
+ });
1287
+ } catch (cause) {
1288
+ lastError = new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1289
+ continue;
1290
+ }
1291
+ if (response.status === 404 || response.status === 410) return null;
1292
+ if (response.ok) return await response.text();
1293
+ lastError = new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1294
+ if (!isTransient(response.status)) break;
1276
1295
  }
1277
- if (response.status === 404 || response.status === 410) return null;
1278
- if (!response.ok) throw new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1279
- return await response.text();
1296
+ throw lastError;
1280
1297
  }
1281
1298
  function tryFetchText(url, timeoutMs) {
1282
1299
  return fetchText(url, timeoutMs).catch(() => null);
@@ -1398,6 +1415,7 @@ export {
1398
1415
  formatPretty,
1399
1416
  routeFromFilePath,
1400
1417
  routeFromUrl,
1418
+ sameSurface,
1401
1419
  shouldFail,
1402
1420
  shouldIgnore,
1403
1421
  snapshotFromDir,
@@ -1405,4 +1423,3 @@ export {
1405
1423
  summarize,
1406
1424
  withGuidance
1407
1425
  };
1408
- //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagetrace",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Baseline your site's SEO and AEO surface, diff every build against it, and fail CI on regressions.",
5
5
  "main": "./dist/index.cjs",
6
6
  "scripts": {