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 +57 -0
- package/README.md +26 -3
- package/dist/cli.cjs +79 -34
- package/dist/cli.js +79 -34
- package/dist/index.cjs +57 -13
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +55 -13
- 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,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
|
|
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:
|
|
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, "<").replace(/>/g, ">");
|
|
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
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
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 (
|
|
1450
|
-
`, "utf8");
|
|
1499
|
+
const written = await writeLockfile(flags.out, snapshot);
|
|
1451
1500
|
const count = Object.keys(snapshot.pages).length;
|
|
1452
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
1466
|
-
|
|
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 (
|
|
1477
|
-
|
|
1478
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1572
|
+
process.exitCode = EXIT_FINDINGS;
|
|
1527
1573
|
}
|
|
1528
1574
|
});
|
|
1529
1575
|
cli.help();
|
|
1530
|
-
cli.version("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 =
|
|
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, "<").replace(/>/g, ">");
|
|
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
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
-
|
|
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
|
|
1427
|
-
`, "utf8");
|
|
1476
|
+
const written = await writeLockfile(flags.out, snapshot);
|
|
1428
1477
|
const count = Object.keys(snapshot.pages).length;
|
|
1429
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
1443
|
-
|
|
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
|
|
1454
|
-
|
|
1455
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1549
|
+
process.exitCode = EXIT_FINDINGS;
|
|
1504
1550
|
}
|
|
1505
1551
|
});
|
|
1506
1552
|
cli.help();
|
|
1507
|
-
cli.version("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 =
|
|
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, "<").replace(/>/g, ">");
|
|
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
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
|
|
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
|