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 +32 -0
- package/README.md +3 -1
- package/dist/cli.cjs +50 -23
- package/dist/cli.js +50 -23
- package/dist/index.cjs +30 -12
- package/dist/index.d.cts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +29 -12
- package/package.json +1 -1
- package/dist/cli.cjs.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
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
|
|
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
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
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 (
|
|
1450
|
-
`, "utf8");
|
|
1475
|
+
const written = await writeLockfile(flags.out, snapshot);
|
|
1451
1476
|
const count = Object.keys(snapshot.pages).length;
|
|
1452
|
-
console.log(
|
|
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 (
|
|
1477
|
-
|
|
1478
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1554
|
+
process.exitCode = EXIT_FINDINGS;
|
|
1527
1555
|
}
|
|
1528
1556
|
});
|
|
1529
1557
|
cli.help();
|
|
1530
|
-
cli.version("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 =
|
|
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
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
-
|
|
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
|
|
1427
|
-
`, "utf8");
|
|
1452
|
+
const written = await writeLockfile(flags.out, snapshot);
|
|
1428
1453
|
const count = Object.keys(snapshot.pages).length;
|
|
1429
|
-
console.log(
|
|
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
|
|
1454
|
-
|
|
1455
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1531
|
+
process.exitCode = EXIT_FINDINGS;
|
|
1504
1532
|
}
|
|
1505
1533
|
});
|
|
1506
1534
|
cli.help();
|
|
1507
|
-
cli.version("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 =
|
|
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
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
|
|
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
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
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
|