pagetrace 0.3.0 → 0.5.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,61 @@ 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.5.0] - 2026-09-06
10
+
11
+ ### Added
12
+
13
+ - `check --baseline-branch <ref>` reads the baseline lockfile out of a git ref
14
+ instead of the working tree, so a pull request can diff against `main` without
15
+ carrying a lockfile of its own. Commit the lockfile on the default branch only
16
+ and feature branches stop churning it. An unresolvable ref throws rather than
17
+ reading as an empty baseline, since a typo must not mean "nothing changed"; a
18
+ ref that simply has no lockfile yet returns nothing, which is an ordinary
19
+ first run.
20
+ - A composite GitHub Action (`action.yml`). Three lines in a workflow run the
21
+ check and post the findings as a pull request comment, editing the previous
22
+ comment on each push rather than stacking new ones. It fetches the baseline ref
23
+ first, since a shallow CI checkout usually has only the PR head.
24
+ - `snapshotFromGitRef(ref, path)` is exported.
25
+
26
+ ### Fixed
27
+
28
+ - `formatMarkdown` escapes angle brackets. `The <h1> was removed.` rendered as
29
+ `The was removed.` on GitHub, which parses a tag name in a table cell as
30
+ inline HTML and drops it — losing the part of the message that mattered, in
31
+ the reporter whose whole purpose is the pull request comment.
32
+
33
+ ## [0.4.0] - 2026-09-06
34
+
35
+ ### Changed
36
+
37
+ - **Breaking.** A failed run now exits `2` rather than `1`. `1` means findings at
38
+ or above `--fail-on`; `2` means the run itself failed — invalid flags, an
39
+ unreadable build directory, an unreachable origin, no pages found. CI could not
40
+ previously distinguish "the site regressed" from "the tool broke", which are
41
+ opposite situations: one should fail the build, the other should page someone.
42
+ - The lockfile is only rewritten when the surface actually changed. It carries a
43
+ `createdAt` timestamp, so every run used to produce a git diff even on an
44
+ unchanged site — which teaches reviewers to discard lockfile changes without
45
+ reading them, the one habit this tool cannot afford. `snapshot` and
46
+ `check --update` now say "already up to date" and leave the file alone.
47
+ - Source maps are no longer published. They were 61% of the package: 715 kB
48
+ unpacked down to 281 kB. Nobody steps through a built CLI.
49
+ - Build target moved from `node18` to `node20`, matching the engines floor.
50
+
51
+ ### Added
52
+
53
+ - Failed requests are retried up to three times with backoff, on network errors,
54
+ 429 and 5xx. 0.2.0 made an unreachable page abort the crawl rather than be
55
+ silently dropped, which is correct but brittle without a retry — one flaky
56
+ response could end a 200-page crawl. A 4xx is an answer, not a hiccup, and is
57
+ not retried.
58
+ - `sameSurface(a, b)` is exported: compares two snapshots ignoring when they were
59
+ taken.
60
+ - The release workflow creates a GitHub Release from each tag, using that
61
+ version's changelog section as the notes. A tag on its own does not appear in
62
+ the repo UI, so the project read as unreleased despite being on npm.
63
+
9
64
  ## [0.3.0] - 2026-09-06
10
65
 
11
66
  ### Changed
@@ -139,6 +194,8 @@ Initial release. `snapshot`, `check` and `audit` commands; filesystem and HTTP
139
194
  crawling; diff classified by transition; absolute, cross-page and hreflang audit
140
195
  rules; pretty, JSON, markdown, GitHub and HTML reporters.
141
196
 
197
+ [0.5.0]: https://github.com/shyamexe/pagetrace/compare/v0.4.0...v0.5.0
198
+ [0.4.0]: https://github.com/shyamexe/pagetrace/compare/v0.3.0...v0.4.0
142
199
  [0.3.0]: https://github.com/shyamexe/pagetrace/compare/v0.2.0...v0.3.0
143
200
  [0.2.0]: https://github.com/shyamexe/pagetrace/compare/v0.1.0...v0.2.0
144
201
  [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
@@ -149,9 +151,30 @@ Every finding has a stable `code`. Set any code to `error`, `warn`, `info`, or `
149
151
 
150
152
  ## CI
151
153
 
154
+ The GitHub Action is the shortest path. It diffs the build against the baseline committed on your default branch and leaves the result as a pull request comment, updating that same comment on each push rather than stacking new ones.
155
+
156
+ ```yaml
157
+ - uses: actions/checkout@v5
158
+ - run: npm ci && npm run build
159
+ - uses: shyamexe/pagetrace@v1
160
+ with:
161
+ dir: ./out
162
+ baseline-branch: main
163
+ ```
164
+
165
+ `baseline-branch` reads the lockfile out of a git ref rather than the working tree, so feature branches never carry one and you get no lockfile churn in pull requests. Commit the lockfile on your default branch only:
166
+
167
+ ```bash
168
+ npx pagetrace snapshot --dir ./out
169
+ git add pagetrace.lock.json
170
+ ```
171
+
172
+ Needs `pull-requests: write` for the comment. Set `comment: false` to skip it, or `audit: false` for a pure regression gate.
173
+
174
+ Without the Action:
175
+
152
176
  ```yaml
153
- - run: npm run build
154
- - run: npx pagetrace check --dir ./out --format github
177
+ - run: npx pagetrace check --dir ./out --baseline-branch origin/main --format github
155
178
  ```
156
179
 
157
180
  `--format` accepts `pretty`, `json`, `markdown` (sized for a PR comment), and `github` (workflow annotations).
package/dist/cli.cjs CHANGED
@@ -866,7 +866,7 @@ function formatPretty(findings) {
866
866
  function formatJson(findings) {
867
867
  return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
868
868
  }
869
- var escapeCell = (value) => value.replace(/\|/g, "\\|");
869
+ var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "&lt;").replace(/>/g, "&gt;");
870
870
  function formatMarkdown(findings) {
871
871
  const s = summarize(findings);
872
872
  if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
@@ -1055,8 +1055,10 @@ ${cards || "<p>No issues found.</p>"}
1055
1055
  }
1056
1056
 
1057
1057
  // src/snapshot.ts
1058
+ var import_node_child_process = require("child_process");
1058
1059
  var import_promises = require("fs/promises");
1059
1060
  var import_node_path = require("path");
1061
+ var import_node_util = require("util");
1060
1062
 
1061
1063
  // src/extract.ts
1062
1064
  var import_node_html_parser = require("node-html-parser");
@@ -1278,6 +1280,32 @@ function routeFromUrl(url) {
1278
1280
  return url;
1279
1281
  }
1280
1282
  }
1283
+ var exec = (0, import_node_util.promisify)(import_node_child_process.execFile);
1284
+ async function snapshotFromGitRef(ref, path) {
1285
+ try {
1286
+ await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
1287
+ } catch (cause) {
1288
+ throw new Error(
1289
+ `Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
1290
+ { cause }
1291
+ );
1292
+ }
1293
+ let stdout;
1294
+ try {
1295
+ ({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
1296
+ } catch {
1297
+ return null;
1298
+ }
1299
+ try {
1300
+ return JSON.parse(stdout);
1301
+ } catch (cause) {
1302
+ throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
1303
+ }
1304
+ }
1305
+ function sameSurface(a, b) {
1306
+ const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1307
+ return strip(a) === strip(b);
1308
+ }
1281
1309
  function shouldIgnore(route, patterns = []) {
1282
1310
  return patterns.some(
1283
1311
  (pattern) => pattern.endsWith("*") ? route.startsWith(pattern.slice(0, -1)) : route === pattern
@@ -1296,19 +1324,32 @@ async function walkHtml(dir, acc = []) {
1296
1324
  return acc;
1297
1325
  }
1298
1326
  var DEFAULT_TIMEOUT_MS = 15e3;
1327
+ var MAX_ATTEMPTS = 3;
1328
+ var RETRY_BASE_MS = 300;
1329
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1330
+ function isTransient(status) {
1331
+ return status === 429 || status >= 500;
1332
+ }
1299
1333
  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 });
1334
+ let lastError;
1335
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
1336
+ if (attempt > 1) await sleep(RETRY_BASE_MS * 3 ** (attempt - 2));
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
+ lastError = new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1345
+ continue;
1346
+ }
1347
+ if (response.status === 404 || response.status === 410) return null;
1348
+ if (response.ok) return await response.text();
1349
+ lastError = new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1350
+ if (!isTransient(response.status)) break;
1308
1351
  }
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();
1352
+ throw lastError;
1312
1353
  }
1313
1354
  function tryFetchText(url, timeoutMs) {
1314
1355
  return fetchText(url, timeoutMs).catch(() => null);
@@ -1405,6 +1446,15 @@ async function snapshotFromOrigin(origin, options = {}) {
1405
1446
  // src/cli.ts
1406
1447
  var DEFAULT_LOCKFILE = "pagetrace.lock.json";
1407
1448
  var DEFAULT_CONFIG = "pagetrace.config.json";
1449
+ var EXIT_FINDINGS = 1;
1450
+ var EXIT_FAILURE = 2;
1451
+ async function writeLockfile(path, next) {
1452
+ const previous = await (0, import_promises2.readFile)(path, "utf8").then((text2) => JSON.parse(text2)).catch(() => null);
1453
+ if (previous && sameSurface(previous, next)) return false;
1454
+ await (0, import_promises2.writeFile)(path, `${JSON.stringify(next, null, 2)}
1455
+ `, "utf8");
1456
+ return true;
1457
+ }
1408
1458
  var SEVERITIES = ["error", "warn", "info"];
1409
1459
  function parseFailOn(value, allowNever) {
1410
1460
  const expected = [...SEVERITIES, ...allowNever ? ["never"] : []].join(" | ");
@@ -1446,25 +1496,20 @@ var cli = (0, import_cac.cac)("pagetrace");
1446
1496
  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
1497
  const config = await loadConfig(flags.config);
1448
1498
  const snapshot = await build(flags, config);
1449
- await (0, import_promises2.writeFile)(flags.out, `${JSON.stringify(snapshot, null, 2)}
1450
- `, "utf8");
1499
+ const written = await writeLockfile(flags.out, snapshot);
1451
1500
  const count = Object.keys(snapshot.pages).length;
1452
- console.log(import_picocolors2.default.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`));
1501
+ console.log(
1502
+ 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"}.`)
1503
+ );
1453
1504
  });
1454
- 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) => {
1505
+ 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").option("--baseline-branch <ref>", "Read the baseline lockfile from a git ref instead of disk").action(async (flags) => {
1455
1506
  const failOn = parseFailOn(flags.failOn, false);
1456
1507
  const config = await loadConfig(flags.config);
1457
1508
  const next = await build(flags, config);
1458
- let previous = null;
1459
- try {
1460
- previous = JSON.parse(await (0, import_promises2.readFile)(flags.lockfile, "utf8"));
1461
- } catch {
1462
- previous = null;
1463
- }
1509
+ const previous = flags.baselineBranch ? await snapshotFromGitRef(flags.baselineBranch, flags.lockfile) : await (0, import_promises2.readFile)(flags.lockfile, "utf8").then((text2) => JSON.parse(text2)).catch(() => null);
1464
1510
  if (!previous) {
1465
- console.error(
1466
- import_picocolors2.default.yellow(`No lockfile at ${flags.lockfile}. Run \`pagetrace snapshot\` first to set a baseline.`)
1467
- );
1511
+ const where = flags.baselineBranch ? `No ${flags.lockfile} at ${flags.baselineBranch}.` : `No lockfile at ${flags.lockfile}.`;
1512
+ console.error(import_picocolors2.default.yellow(`${where} Run \`pagetrace snapshot\` first to set a baseline.`));
1468
1513
  }
1469
1514
  const raw = [
1470
1515
  ...previous ? diffSnapshots(previous, next) : [],
@@ -1473,9 +1518,10 @@ cli.command("check", "Compare the current surface against the lockfile").option(
1473
1518
  const findings = applyConfig(raw, config);
1474
1519
  console.log(render(findings, flags.format));
1475
1520
  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}.`));
1521
+ const written = await writeLockfile(flags.lockfile, next);
1522
+ console.error(
1523
+ import_picocolors2.default.dim(written ? `Updated ${flags.lockfile}.` : `${flags.lockfile} is already up to date.`)
1524
+ );
1479
1525
  }
1480
1526
  const summary = summarize(findings);
1481
1527
  if (previous && shouldFail(findings, failOn)) {
@@ -1483,7 +1529,7 @@ cli.command("check", "Compare the current surface against the lockfile").option(
1483
1529
  import_picocolors2.default.red(`
1484
1530
  Failing: ${summary.error} error, ${summary.warn} warning (--fail-on ${failOn}).`)
1485
1531
  );
1486
- process.exitCode = 1;
1532
+ process.exitCode = EXIT_FINDINGS;
1487
1533
  }
1488
1534
  });
1489
1535
  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 +1543,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1497
1543
  "No pages found. Check that the sitemap is reachable, or pass --dir with pre-rendered HTML."
1498
1544
  )
1499
1545
  );
1500
- process.exitCode = 1;
1546
+ process.exitCode = EXIT_FAILURE;
1501
1547
  return;
1502
1548
  }
1503
1549
  const platform = detectPlatform(
@@ -1523,19 +1569,18 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1523
1569
  console.log(output);
1524
1570
  }
1525
1571
  if (failOn !== "never" && shouldFail(findings, failOn)) {
1526
- process.exitCode = 1;
1572
+ process.exitCode = EXIT_FINDINGS;
1527
1573
  }
1528
1574
  });
1529
1575
  cli.help();
1530
- cli.version("0.3.0");
1576
+ cli.version("0.5.0");
1531
1577
  async function main() {
1532
1578
  try {
1533
1579
  cli.parse(process.argv, { run: false });
1534
1580
  await cli.runMatchedCommand();
1535
1581
  } catch (error) {
1536
1582
  console.error(import_picocolors2.default.red(error.message));
1537
- process.exitCode = 1;
1583
+ process.exitCode = EXIT_FAILURE;
1538
1584
  }
1539
1585
  }
1540
1586
  void main();
1541
- //# sourceMappingURL=cli.cjs.map
package/dist/cli.js CHANGED
@@ -843,7 +843,7 @@ function formatPretty(findings) {
843
843
  function formatJson(findings) {
844
844
  return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
845
845
  }
846
- var escapeCell = (value) => value.replace(/\|/g, "\\|");
846
+ var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "&lt;").replace(/>/g, "&gt;");
847
847
  function formatMarkdown(findings) {
848
848
  const s = summarize(findings);
849
849
  if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
@@ -1032,8 +1032,10 @@ ${cards || "<p>No issues found.</p>"}
1032
1032
  }
1033
1033
 
1034
1034
  // src/snapshot.ts
1035
+ import { execFile } from "child_process";
1035
1036
  import { readdir, readFile } from "fs/promises";
1036
1037
  import { join, relative, sep } from "path";
1038
+ import { promisify } from "util";
1037
1039
 
1038
1040
  // src/extract.ts
1039
1041
  import { parse } from "node-html-parser";
@@ -1255,6 +1257,32 @@ function routeFromUrl(url) {
1255
1257
  return url;
1256
1258
  }
1257
1259
  }
1260
+ var exec = promisify(execFile);
1261
+ async function snapshotFromGitRef(ref, path) {
1262
+ try {
1263
+ await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
1264
+ } catch (cause) {
1265
+ throw new Error(
1266
+ `Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
1267
+ { cause }
1268
+ );
1269
+ }
1270
+ let stdout;
1271
+ try {
1272
+ ({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
1273
+ } catch {
1274
+ return null;
1275
+ }
1276
+ try {
1277
+ return JSON.parse(stdout);
1278
+ } catch (cause) {
1279
+ throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
1280
+ }
1281
+ }
1282
+ function sameSurface(a, b) {
1283
+ const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1284
+ return strip(a) === strip(b);
1285
+ }
1258
1286
  function shouldIgnore(route, patterns = []) {
1259
1287
  return patterns.some(
1260
1288
  (pattern) => pattern.endsWith("*") ? route.startsWith(pattern.slice(0, -1)) : route === pattern
@@ -1273,19 +1301,32 @@ async function walkHtml(dir, acc = []) {
1273
1301
  return acc;
1274
1302
  }
1275
1303
  var DEFAULT_TIMEOUT_MS = 15e3;
1304
+ var MAX_ATTEMPTS = 3;
1305
+ var RETRY_BASE_MS = 300;
1306
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1307
+ function isTransient(status) {
1308
+ return status === 429 || status >= 500;
1309
+ }
1276
1310
  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 });
1311
+ let lastError;
1312
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
1313
+ if (attempt > 1) await sleep(RETRY_BASE_MS * 3 ** (attempt - 2));
1314
+ let response;
1315
+ try {
1316
+ response = await fetch(url, {
1317
+ signal: AbortSignal.timeout(timeoutMs),
1318
+ headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1319
+ });
1320
+ } catch (cause) {
1321
+ lastError = new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1322
+ continue;
1323
+ }
1324
+ if (response.status === 404 || response.status === 410) return null;
1325
+ if (response.ok) return await response.text();
1326
+ lastError = new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1327
+ if (!isTransient(response.status)) break;
1285
1328
  }
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();
1329
+ throw lastError;
1289
1330
  }
1290
1331
  function tryFetchText(url, timeoutMs) {
1291
1332
  return fetchText(url, timeoutMs).catch(() => null);
@@ -1382,6 +1423,15 @@ async function snapshotFromOrigin(origin, options = {}) {
1382
1423
  // src/cli.ts
1383
1424
  var DEFAULT_LOCKFILE = "pagetrace.lock.json";
1384
1425
  var DEFAULT_CONFIG = "pagetrace.config.json";
1426
+ var EXIT_FINDINGS = 1;
1427
+ var EXIT_FAILURE = 2;
1428
+ async function writeLockfile(path, next) {
1429
+ const previous = await readFile2(path, "utf8").then((text2) => JSON.parse(text2)).catch(() => null);
1430
+ if (previous && sameSurface(previous, next)) return false;
1431
+ await writeFile(path, `${JSON.stringify(next, null, 2)}
1432
+ `, "utf8");
1433
+ return true;
1434
+ }
1385
1435
  var SEVERITIES = ["error", "warn", "info"];
1386
1436
  function parseFailOn(value, allowNever) {
1387
1437
  const expected = [...SEVERITIES, ...allowNever ? ["never"] : []].join(" | ");
@@ -1423,25 +1473,20 @@ var cli = cac("pagetrace");
1423
1473
  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
1474
  const config = await loadConfig(flags.config);
1425
1475
  const snapshot = await build(flags, config);
1426
- await writeFile(flags.out, `${JSON.stringify(snapshot, null, 2)}
1427
- `, "utf8");
1476
+ const written = await writeLockfile(flags.out, snapshot);
1428
1477
  const count = Object.keys(snapshot.pages).length;
1429
- console.log(pc2.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`));
1478
+ console.log(
1479
+ 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"}.`)
1480
+ );
1430
1481
  });
1431
- 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) => {
1482
+ 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").option("--baseline-branch <ref>", "Read the baseline lockfile from a git ref instead of disk").action(async (flags) => {
1432
1483
  const failOn = parseFailOn(flags.failOn, false);
1433
1484
  const config = await loadConfig(flags.config);
1434
1485
  const next = await build(flags, config);
1435
- let previous = null;
1436
- try {
1437
- previous = JSON.parse(await readFile2(flags.lockfile, "utf8"));
1438
- } catch {
1439
- previous = null;
1440
- }
1486
+ const previous = flags.baselineBranch ? await snapshotFromGitRef(flags.baselineBranch, flags.lockfile) : await readFile2(flags.lockfile, "utf8").then((text2) => JSON.parse(text2)).catch(() => null);
1441
1487
  if (!previous) {
1442
- console.error(
1443
- pc2.yellow(`No lockfile at ${flags.lockfile}. Run \`pagetrace snapshot\` first to set a baseline.`)
1444
- );
1488
+ const where = flags.baselineBranch ? `No ${flags.lockfile} at ${flags.baselineBranch}.` : `No lockfile at ${flags.lockfile}.`;
1489
+ console.error(pc2.yellow(`${where} Run \`pagetrace snapshot\` first to set a baseline.`));
1445
1490
  }
1446
1491
  const raw = [
1447
1492
  ...previous ? diffSnapshots(previous, next) : [],
@@ -1450,9 +1495,10 @@ cli.command("check", "Compare the current surface against the lockfile").option(
1450
1495
  const findings = applyConfig(raw, config);
1451
1496
  console.log(render(findings, flags.format));
1452
1497
  if (flags.update) {
1453
- await writeFile(flags.lockfile, `${JSON.stringify(next, null, 2)}
1454
- `, "utf8");
1455
- console.error(pc2.dim(`Updated ${flags.lockfile}.`));
1498
+ const written = await writeLockfile(flags.lockfile, next);
1499
+ console.error(
1500
+ pc2.dim(written ? `Updated ${flags.lockfile}.` : `${flags.lockfile} is already up to date.`)
1501
+ );
1456
1502
  }
1457
1503
  const summary = summarize(findings);
1458
1504
  if (previous && shouldFail(findings, failOn)) {
@@ -1460,7 +1506,7 @@ cli.command("check", "Compare the current surface against the lockfile").option(
1460
1506
  pc2.red(`
1461
1507
  Failing: ${summary.error} error, ${summary.warn} warning (--fail-on ${failOn}).`)
1462
1508
  );
1463
- process.exitCode = 1;
1509
+ process.exitCode = EXIT_FINDINGS;
1464
1510
  }
1465
1511
  });
1466
1512
  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 +1520,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1474
1520
  "No pages found. Check that the sitemap is reachable, or pass --dir with pre-rendered HTML."
1475
1521
  )
1476
1522
  );
1477
- process.exitCode = 1;
1523
+ process.exitCode = EXIT_FAILURE;
1478
1524
  return;
1479
1525
  }
1480
1526
  const platform = detectPlatform(
@@ -1500,19 +1546,18 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1500
1546
  console.log(output);
1501
1547
  }
1502
1548
  if (failOn !== "never" && shouldFail(findings, failOn)) {
1503
- process.exitCode = 1;
1549
+ process.exitCode = EXIT_FINDINGS;
1504
1550
  }
1505
1551
  });
1506
1552
  cli.help();
1507
- cli.version("0.3.0");
1553
+ cli.version("0.5.0");
1508
1554
  async function main() {
1509
1555
  try {
1510
1556
  cli.parse(process.argv, { run: false });
1511
1557
  await cli.runMatchedCommand();
1512
1558
  } catch (error) {
1513
1559
  console.error(pc2.red(error.message));
1514
- process.exitCode = 1;
1560
+ process.exitCode = EXIT_FAILURE;
1515
1561
  }
1516
1562
  }
1517
1563
  void main();
1518
- //# sourceMappingURL=cli.js.map
package/dist/index.cjs CHANGED
@@ -58,9 +58,11 @@ __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,
65
+ snapshotFromGitRef: () => snapshotFromGitRef,
64
66
  snapshotFromOrigin: () => snapshotFromOrigin,
65
67
  summarize: () => summarize,
66
68
  withGuidance: () => withGuidance
@@ -1109,7 +1111,7 @@ function formatPretty(findings) {
1109
1111
  function formatJson(findings) {
1110
1112
  return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
1111
1113
  }
1112
- var escapeCell = (value) => value.replace(/\|/g, "\\|");
1114
+ var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1113
1115
  function formatMarkdown(findings) {
1114
1116
  const s = summarize(findings);
1115
1117
  if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
@@ -1298,8 +1300,10 @@ ${cards || "<p>No issues found.</p>"}
1298
1300
  }
1299
1301
 
1300
1302
  // src/snapshot.ts
1303
+ var import_node_child_process = require("child_process");
1301
1304
  var import_promises = require("fs/promises");
1302
1305
  var import_node_path = require("path");
1306
+ var import_node_util = require("util");
1303
1307
  function routeFromFilePath(root, filePath) {
1304
1308
  const rel = (0, import_node_path.relative)(root, filePath).split(import_node_path.sep).join("/");
1305
1309
  const withoutExt = rel.replace(/\.html?$/i, "");
@@ -1315,6 +1319,32 @@ function routeFromUrl(url) {
1315
1319
  return url;
1316
1320
  }
1317
1321
  }
1322
+ var exec = (0, import_node_util.promisify)(import_node_child_process.execFile);
1323
+ async function snapshotFromGitRef(ref, path) {
1324
+ try {
1325
+ await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
1326
+ } catch (cause) {
1327
+ throw new Error(
1328
+ `Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
1329
+ { cause }
1330
+ );
1331
+ }
1332
+ let stdout;
1333
+ try {
1334
+ ({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
1335
+ } catch {
1336
+ return null;
1337
+ }
1338
+ try {
1339
+ return JSON.parse(stdout);
1340
+ } catch (cause) {
1341
+ throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
1342
+ }
1343
+ }
1344
+ function sameSurface(a, b) {
1345
+ const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1346
+ return strip(a) === strip(b);
1347
+ }
1318
1348
  function shouldIgnore(route, patterns = []) {
1319
1349
  return patterns.some(
1320
1350
  (pattern) => pattern.endsWith("*") ? route.startsWith(pattern.slice(0, -1)) : route === pattern
@@ -1333,19 +1363,32 @@ async function walkHtml(dir, acc = []) {
1333
1363
  return acc;
1334
1364
  }
1335
1365
  var DEFAULT_TIMEOUT_MS = 15e3;
1366
+ var MAX_ATTEMPTS = 3;
1367
+ var RETRY_BASE_MS = 300;
1368
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1369
+ function isTransient(status) {
1370
+ return status === 429 || status >= 500;
1371
+ }
1336
1372
  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 });
1373
+ let lastError;
1374
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
1375
+ if (attempt > 1) await sleep(RETRY_BASE_MS * 3 ** (attempt - 2));
1376
+ let response;
1377
+ try {
1378
+ response = await fetch(url, {
1379
+ signal: AbortSignal.timeout(timeoutMs),
1380
+ headers: { "user-agent": "pagetrace (+https://npmjs.com/package/pagetrace)" }
1381
+ });
1382
+ } catch (cause) {
1383
+ lastError = new Error(`Could not reach ${url}: ${cause.message}`, { cause });
1384
+ continue;
1385
+ }
1386
+ if (response.status === 404 || response.status === 410) return null;
1387
+ if (response.ok) return await response.text();
1388
+ lastError = new Error(`Could not reach ${url}: HTTP ${response.status}.`);
1389
+ if (!isTransient(response.status)) break;
1345
1390
  }
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();
1391
+ throw lastError;
1349
1392
  }
1350
1393
  function tryFetchText(url, timeoutMs) {
1351
1394
  return fetchText(url, timeoutMs).catch(() => null);
@@ -1468,11 +1511,12 @@ async function snapshotFromOrigin(origin, options = {}) {
1468
1511
  formatPretty,
1469
1512
  routeFromFilePath,
1470
1513
  routeFromUrl,
1514
+ sameSurface,
1471
1515
  shouldFail,
1472
1516
  shouldIgnore,
1473
1517
  snapshotFromDir,
1518
+ snapshotFromGitRef,
1474
1519
  snapshotFromOrigin,
1475
1520
  summarize,
1476
1521
  withGuidance
1477
1522
  });
1478
- //# sourceMappingURL=index.cjs.map